diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index dbad3f699fc6..53339082ae21 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" @@ -209,6 +210,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 @@ -216,6 +246,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)) @@ -702,19 +733,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)} > diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 0b57ad29cf41..8cebd9cba54d 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 ( { - 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)} > { - 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} { - 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 + } > 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 000000000000..1230852dcc07 --- /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 b11058b34058..8c999a1c0106 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")