summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLuke Parker <[email protected]>2026-02-13 07:38:27 +1000
committerGitHub <[email protected]>2026-02-13 07:38:27 +1000
commita8f2884521e755cea9b9e4e52406267bcbda15d2 (patch)
tree014408313094e4c1ebbb36f4340a866167e48db5
parentc0814da785d40273f36eda835c4cfd583cf20d75 (diff)
downloadopencode-a8f2884521e755cea9b9e4e52406267bcbda15d2.tar.gz
opencode-a8f2884521e755cea9b9e4e52406267bcbda15d2.zip
feat: windows selection behavior, manual ctrl+c (#13315)
-rw-r--r--packages/opencode/src/cli/cmd/tui/app.tsx53
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/dialog.tsx47
-rw-r--r--packages/opencode/src/cli/cmd/tui/util/selection.ts25
-rw-r--r--packages/opencode/src/flag/flag.ts12
4 files changed, 105 insertions, 32 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 7b5a2278c..ab3d09689 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -1,6 +1,7 @@
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
-import { TextAttributes } from "@opentui/core"
+import { Selection } from "@tui/util/selection"
+import { MouseButton, TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
@@ -210,6 +211,35 @@ function App() {
const exit = useExit()
const promptRef = usePromptRef()
+ useKeyboard((evt) => {
+ if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
+ if (!renderer.getSelection()) return
+
+ // Windows Terminal-like behavior:
+ // - Ctrl+C copies and dismisses selection
+ // - Esc dismisses selection
+ // - Most other key input dismisses selection and is passed through
+ if (evt.ctrl && evt.name === "c") {
+ if (!Selection.copy(renderer, toast)) {
+ renderer.clearSelection()
+ return
+ }
+
+ evt.preventDefault()
+ evt.stopPropagation()
+ return
+ }
+
+ if (evt.name === "escape") {
+ renderer.clearSelection()
+ evt.preventDefault()
+ evt.stopPropagation()
+ return
+ }
+
+ renderer.clearSelection()
+ })
+
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
renderer.console.onCopySelection = async (text: string) => {
if (!text || text.length === 0) return
@@ -217,6 +247,7 @@ function App() {
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
+
renderer.clearSelection()
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
@@ -703,19 +734,15 @@ function App() {
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background}
- onMouseUp={async () => {
- if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) {
- renderer.clearSelection()
- return
- }
- const text = renderer.getSelection()?.getSelectedText()
- if (text && text.length > 0) {
- await Clipboard.copy(text)
- .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
- .catch(toast.error)
- renderer.clearSelection()
- }
+ onMouseDown={(evt) => {
+ if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
+ if (evt.button !== MouseButton.RIGHT) return
+
+ if (!Selection.copy(renderer, toast)) return
+ evt.preventDefault()
+ evt.stopPropagation()
}}
+ onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
>
<Switch>
<Match when={route.data.type === "home"}>
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
index 0b57ad29c..8cebd9cba 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
@@ -1,10 +1,11 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "@tui/context/theme"
-import { Renderable, RGBA } from "@opentui/core"
+import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
-import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "./toast"
+import { Flag } from "@/flag/flag"
+import { Selection } from "@tui/util/selection"
export function Dialog(
props: ParentProps<{
@@ -16,10 +17,18 @@ export function Dialog(
const { theme } = useTheme()
const renderer = useRenderer()
+ let dismiss = false
+
return (
<box
- onMouseUp={async () => {
- if (renderer.getSelection()) return
+ onMouseDown={() => {
+ dismiss = !!renderer.getSelection()
+ }}
+ onMouseUp={() => {
+ if (dismiss) {
+ dismiss = false
+ return
+ }
props.onClose?.()
}}
width={dimensions().width}
@@ -32,8 +41,8 @@ export function Dialog(
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
>
<box
- onMouseUp={async (e) => {
- if (renderer.getSelection()) return
+ onMouseUp={(e) => {
+ dismiss = false
e.stopPropagation()
}}
width={props.size === "large" ? 80 : 60}
@@ -56,8 +65,13 @@ function init() {
size: "medium" as "medium" | "large",
})
+ const renderer = useRenderer()
+
useKeyboard((evt) => {
- if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && store.stack.length > 0) {
+ if (store.stack.length === 0) return
+ if (evt.defaultPrevented) return
+ if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
+ if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
@@ -67,7 +81,6 @@ function init() {
}
})
- const renderer = useRenderer()
let focus: Renderable | null
function refocus() {
setTimeout(() => {
@@ -138,15 +151,17 @@ export function DialogProvider(props: ParentProps) {
{props.children}
<box
position="absolute"
- onMouseUp={async () => {
- const text = renderer.getSelection()?.getSelectedText()
- if (text && text.length > 0) {
- await Clipboard.copy(text)
- .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
- .catch(toast.error)
- renderer.clearSelection()
- }
+ onMouseDown={(evt) => {
+ if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
+ if (evt.button !== MouseButton.RIGHT) return
+
+ if (!Selection.copy(renderer, toast)) return
+ evt.preventDefault()
+ evt.stopPropagation()
}}
+ onMouseUp={
+ !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? () => Selection.copy(renderer, toast) : undefined
+ }
>
<Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size}>
diff --git a/packages/opencode/src/cli/cmd/tui/util/selection.ts b/packages/opencode/src/cli/cmd/tui/util/selection.ts
new file mode 100644
index 000000000..1230852dc
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/util/selection.ts
@@ -0,0 +1,25 @@
+import { Clipboard } from "./clipboard"
+
+type Toast = {
+ show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
+ error: (err: unknown) => void
+}
+
+type Renderer = {
+ getSelection: () => { getSelectedText: () => string } | null
+ clearSelection: () => void
+}
+
+export namespace Selection {
+ export function copy(renderer: Renderer, toast: Toast): boolean {
+ const text = renderer.getSelection()?.getSelectedText()
+ if (!text) return false
+
+ Clipboard.copy(text)
+ .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
+ .catch(toast.error)
+
+ renderer.clearSelection()
+ return true
+ }
+}
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index b11058b34..8c999a1c0 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -1,6 +1,10 @@
+function truthyValue(value: string | undefined) {
+ const v = value?.toLowerCase()
+ return v === "true" || v === "1"
+}
+
function truthy(key: string) {
- const value = process.env[key]?.toLowerCase()
- return value === "true" || value === "1"
+ return truthyValue(process.env[key])
}
export namespace Flag {
@@ -37,7 +41,9 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER")
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
- export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
+ const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
+ export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT =
+ copy === undefined ? process.platform === "win32" : truthyValue(copy)
export const OPENCODE_ENABLE_EXA =
truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA")
export const OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS")