Skip to content

Commit a8f2884

Browse files
authored
feat: windows selection behavior, manual ctrl+c (#13315)
1 parent c0814da commit a8f2884

File tree

4 files changed

+105
-32
lines changed

4 files changed

+105
-32
lines changed

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
22
import { Clipboard } from "@tui/util/clipboard"
3-
import { TextAttributes } from "@opentui/core"
3+
import { Selection } from "@tui/util/selection"
4+
import { MouseButton, TextAttributes } from "@opentui/core"
45
import { RouteProvider, useRoute } from "@tui/context/route"
56
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
67
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
@@ -210,13 +211,43 @@ function App() {
210211
const exit = useExit()
211212
const promptRef = usePromptRef()
212213

214+
useKeyboard((evt) => {
215+
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
216+
if (!renderer.getSelection()) return
217+
218+
// Windows Terminal-like behavior:
219+
// - Ctrl+C copies and dismisses selection
220+
// - Esc dismisses selection
221+
// - Most other key input dismisses selection and is passed through
222+
if (evt.ctrl && evt.name === "c") {
223+
if (!Selection.copy(renderer, toast)) {
224+
renderer.clearSelection()
225+
return
226+
}
227+
228+
evt.preventDefault()
229+
evt.stopPropagation()
230+
return
231+
}
232+
233+
if (evt.name === "escape") {
234+
renderer.clearSelection()
235+
evt.preventDefault()
236+
evt.stopPropagation()
237+
return
238+
}
239+
240+
renderer.clearSelection()
241+
})
242+
213243
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
214244
renderer.console.onCopySelection = async (text: string) => {
215245
if (!text || text.length === 0) return
216246

217247
await Clipboard.copy(text)
218248
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
219249
.catch(toast.error)
250+
220251
renderer.clearSelection()
221252
}
222253
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
@@ -703,19 +734,15 @@ function App() {
703734
width={dimensions().width}
704735
height={dimensions().height}
705736
backgroundColor={theme.background}
706-
onMouseUp={async () => {
707-
if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) {
708-
renderer.clearSelection()
709-
return
710-
}
711-
const text = renderer.getSelection()?.getSelectedText()
712-
if (text && text.length > 0) {
713-
await Clipboard.copy(text)
714-
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
715-
.catch(toast.error)
716-
renderer.clearSelection()
717-
}
737+
onMouseDown={(evt) => {
738+
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
739+
if (evt.button !== MouseButton.RIGHT) return
740+
741+
if (!Selection.copy(renderer, toast)) return
742+
evt.preventDefault()
743+
evt.stopPropagation()
718744
}}
745+
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
719746
>
720747
<Switch>
721748
<Match when={route.data.type === "home"}>

packages/opencode/src/cli/cmd/tui/ui/dialog.tsx

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
22
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
33
import { useTheme } from "@tui/context/theme"
4-
import { Renderable, RGBA } from "@opentui/core"
4+
import { MouseButton, Renderable, RGBA } from "@opentui/core"
55
import { createStore } from "solid-js/store"
6-
import { Clipboard } from "@tui/util/clipboard"
76
import { useToast } from "./toast"
7+
import { Flag } from "@/flag/flag"
8+
import { Selection } from "@tui/util/selection"
89

910
export function Dialog(
1011
props: ParentProps<{
@@ -16,10 +17,18 @@ export function Dialog(
1617
const { theme } = useTheme()
1718
const renderer = useRenderer()
1819

20+
let dismiss = false
21+
1922
return (
2023
<box
21-
onMouseUp={async () => {
22-
if (renderer.getSelection()) return
24+
onMouseDown={() => {
25+
dismiss = !!renderer.getSelection()
26+
}}
27+
onMouseUp={() => {
28+
if (dismiss) {
29+
dismiss = false
30+
return
31+
}
2332
props.onClose?.()
2433
}}
2534
width={dimensions().width}
@@ -32,8 +41,8 @@ export function Dialog(
3241
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
3342
>
3443
<box
35-
onMouseUp={async (e) => {
36-
if (renderer.getSelection()) return
44+
onMouseUp={(e) => {
45+
dismiss = false
3746
e.stopPropagation()
3847
}}
3948
width={props.size === "large" ? 80 : 60}
@@ -56,8 +65,13 @@ function init() {
5665
size: "medium" as "medium" | "large",
5766
})
5867

68+
const renderer = useRenderer()
69+
5970
useKeyboard((evt) => {
60-
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && store.stack.length > 0) {
71+
if (store.stack.length === 0) return
72+
if (evt.defaultPrevented) return
73+
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
74+
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
6175
const current = store.stack.at(-1)!
6276
current.onClose?.()
6377
setStore("stack", store.stack.slice(0, -1))
@@ -67,7 +81,6 @@ function init() {
6781
}
6882
})
6983

70-
const renderer = useRenderer()
7184
let focus: Renderable | null
7285
function refocus() {
7386
setTimeout(() => {
@@ -138,15 +151,17 @@ export function DialogProvider(props: ParentProps) {
138151
{props.children}
139152
<box
140153
position="absolute"
141-
onMouseUp={async () => {
142-
const text = renderer.getSelection()?.getSelectedText()
143-
if (text && text.length > 0) {
144-
await Clipboard.copy(text)
145-
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
146-
.catch(toast.error)
147-
renderer.clearSelection()
148-
}
154+
onMouseDown={(evt) => {
155+
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
156+
if (evt.button !== MouseButton.RIGHT) return
157+
158+
if (!Selection.copy(renderer, toast)) return
159+
evt.preventDefault()
160+
evt.stopPropagation()
149161
}}
162+
onMouseUp={
163+
!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? () => Selection.copy(renderer, toast) : undefined
164+
}
150165
>
151166
<Show when={value.stack.length}>
152167
<Dialog onClose={() => value.clear()} size={value.size}>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Clipboard } from "./clipboard"
2+
3+
type Toast = {
4+
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
5+
error: (err: unknown) => void
6+
}
7+
8+
type Renderer = {
9+
getSelection: () => { getSelectedText: () => string } | null
10+
clearSelection: () => void
11+
}
12+
13+
export namespace Selection {
14+
export function copy(renderer: Renderer, toast: Toast): boolean {
15+
const text = renderer.getSelection()?.getSelectedText()
16+
if (!text) return false
17+
18+
Clipboard.copy(text)
19+
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
20+
.catch(toast.error)
21+
22+
renderer.clearSelection()
23+
return true
24+
}
25+
}

packages/opencode/src/flag/flag.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
function truthyValue(value: string | undefined) {
2+
const v = value?.toLowerCase()
3+
return v === "true" || v === "1"
4+
}
5+
16
function truthy(key: string) {
2-
const value = process.env[key]?.toLowerCase()
3-
return value === "true" || value === "1"
7+
return truthyValue(process.env[key])
48
}
59

610
export namespace Flag {
@@ -37,7 +41,9 @@ export namespace Flag {
3741
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER")
3842
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
3943
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
40-
export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
44+
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
45+
export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT =
46+
copy === undefined ? process.platform === "win32" : truthyValue(copy)
4147
export const OPENCODE_ENABLE_EXA =
4248
truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA")
4349
export const OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS")

0 commit comments

Comments
 (0)