diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 57f372ef709a..58497319d482 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -428,8 +428,23 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, { priority: 1 }, ) + // Copy-on-select keeps the highlight so it can still be added to the prompt. Dismiss it on + // the first key that no binding consumed, leaving leader sequences mid-flight untouched. + // Dialogs are exempt so typing in the command palette cannot drop the pending selection. + const offRetainedSelection = keymap.intercept( + "key:after", + (ctx) => { + if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + if (ctx.handled || ctx.pendingSequence.length > 0) return + if (dialog.stack.length > 0) return + renderer.clearSelection() + }, + { priority: 1 }, + ) + onCleanup(() => { offSelectionKeys() + offRetainedSelection() attention.dispose() }) @@ -1091,7 +1106,12 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi flexDirection="column" backgroundColor={theme.background} onMouseDown={(evt) => { - if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) { + // A click that did not start a new selection dismisses the retained highlight, + // so the click still reaches handlers that ignore selection releases. + if (renderer.getSelection()?.getSelectedText()) renderer.clearSelection() + return + } if (evt.button !== MouseButton.RIGHT) return if (!Selection.copy(renderer, toast, clipboard)) return @@ -1100,7 +1120,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }} onMouseUp={ !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT - ? () => Selection.copy(renderer, toast, clipboard) + ? () => Selection.copy(renderer, toast, clipboard, { retain: true }) : undefined } > diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index fe7f4a22f75f..809b386673a1 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -56,6 +56,7 @@ import { useTuiConfig } from "../../config" import { usePromptWorkspace } from "./workspace" import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" +import { Selection } from "../../util/selection" import { useLocation } from "../../context/location" registerOpencodeSpinner() @@ -367,6 +368,27 @@ export function Prompt(props: PromptProps) { dialog.clear() }, }, + { + title: "Add selection to chat", + desc: "Quote the selected text into the prompt", + name: "prompt.add_selection", + category: "Prompt", + run: () => { + const selected = Selection.text(renderer)?.trim() + if (!selected) { + toast.show({ message: "Select text in the conversation first", variant: "warning" }) + dialog.clear() + return + } + + const lines = selected.split("\n").length + pasteText(Selection.quote(selected), `[Quoted ${lines} ${lines === 1 ? "line" : "lines"}]`) + // Only drop the highlight once the quote is actually in the prompt. + renderer.clearSelection() + input.focus() + dialog.clear() + }, + }, { title: "Paste", name: "prompt.paste", @@ -569,6 +591,7 @@ export function Prompt(props: PromptProps) { "prompt.submit", "prompt.editor", "prompt.editor_context.clear", + "prompt.add_selection", "prompt.stash", "prompt.stash.pop", "prompt.stash.list", diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 5dd7e4b5aafe..e6d5bb9272b7 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -152,6 +152,7 @@ export const Definitions = { prompt_submit: keybind("none", "Submit prompt"), prompt_editor_context_clear: keybind("none", "Clear editor context"), + prompt_add_selection: keybind("p", "Add the selected text to the prompt as a quote"), prompt_skills: keybind("none", "Open skill selector"), prompt_stash: keybind("none", "Stash prompt"), prompt_stash_pop: keybind("none", "Pop stashed prompt"), @@ -356,6 +357,7 @@ export const CommandMap = { display_thinking: "session.toggle.thinking", prompt_submit: "prompt.submit", prompt_editor_context_clear: "prompt.editor_context.clear", + prompt_add_selection: "prompt.add_selection", prompt_skills: "prompt.skills", prompt_stash: "prompt.stash", prompt_stash_pop: "prompt.stash.pop", diff --git a/packages/tui/src/feature-plugins/home/tips-view.tsx b/packages/tui/src/feature-plugins/home/tips-view.tsx index d9ef81e40cca..313373514791 100644 --- a/packages/tui/src/feature-plugins/home/tips-view.tsx +++ b/packages/tui/src/feature-plugins/home/tips-view.tsx @@ -8,6 +8,7 @@ const themeCount = Object.keys(DEFAULT_THEMES).length type TipPart = { text: string; highlight: boolean } type TipShortcut = Accessor type Shortcuts = { + addSelection: TipShortcut agentCycle: TipShortcut childFirst: TipShortcut childNext: TipShortcut @@ -98,6 +99,7 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) { const theme = useTheme().theme const tipOffset = Math.random() const shortcuts: Shortcuts = { + addSelection: configShortcut(props.api, "prompt.add_selection"), agentCycle: useCommandShortcut("agent.cycle"), childFirst: configShortcut(props.api, "session.child.first"), childNext: configShortcut(props.api, "session.child.next"), @@ -165,6 +167,7 @@ const TIPS: Tip[] = [ "Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files", "Start a message with {highlight}!{/highlight} to run shell commands (e.g., {highlight}!ls -la{/highlight})", (shortcuts) => press(shortcuts.agentCycle(), "to cycle between Build and Plan agents"), + (shortcuts) => press(shortcuts.addSelection(), "to quote selected text into the prompt as context"), "Use {highlight}/undo{/highlight} to revert the last message and file changes", "Use {highlight}/redo{/highlight} to restore previously undone messages and file changes", "Run {highlight}/share{/highlight} to create a public opencode.ai link", diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 50281630bc5b..179069e05d84 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -102,36 +102,32 @@ function init() { }, 1) } + // A retained copy-on-select highlight is dismissed first so it is never closed out from + // behind the dialog; the next press closes. Escape stays live either way. + function dismiss() { + const selected = renderer.getSelection()?.getSelectedText() + renderer.clearSelection() + if (selected) return + const current = store.stack.at(-1) + current?.onClose?.() + setStore("stack", store.stack.slice(0, -1)) + refocus() + } + useBindings(() => ({ - enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(), + enabled: store.stack.length > 0, bindings: [ { key: "escape", desc: "Close dialog", group: "Dialog", - cmd: () => { - if (renderer.getSelection()) { - renderer.clearSelection() - } - const current = store.stack.at(-1) - current?.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - refocus() - }, + cmd: dismiss, }, { key: "ctrl+c", desc: "Close dialog", group: "Dialog", - cmd: () => { - if (renderer.getSelection()) { - renderer.clearSelection() - } - const current = store.stack.at(-1) - current?.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - refocus() - }, + cmd: dismiss, }, ], })) diff --git a/packages/tui/src/util/selection.ts b/packages/tui/src/util/selection.ts index d9158ba40760..e825cb96fc1a 100644 --- a/packages/tui/src/util/selection.ts +++ b/packages/tui/src/util/selection.ts @@ -23,23 +23,44 @@ type SelectionKeyEvent = { stopPropagation: () => void } -export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean { +export function text(renderer: Renderer) { const selection = renderer.getSelection() - if (!selection) return false + if (!selection) return undefined + const selected = selection.getSelectedText() + if (!selected) return undefined + const focus = renderer.currentFocusedRenderable + if (focus?.getClipboardText && selection.selectedRenderables.includes(focus)) return focus.getClipboardText(selected) + return selected +} - const text = selection.getSelectedText() - if (!text) return false +// Blank lines carry a bare ">" and content lines drop trailing padding, so a quoted block +// never introduces trailing whitespace. Already-quoted lines nest, which is what they mean. +export function quote(value: string) { + return ( + value + .split("\n") + .map((line) => (line.trim() ? `> ${line.trimEnd()}` : ">")) + .join("\n") + "\n" + ) +} - const focus = renderer.currentFocusedRenderable - const clipboardText = - focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text +// `retain` keeps the highlight up after copy-on-select so it can still be acted on, for +// example added to the prompt. The highlight is dismissed by the next click or key press. +export function copy( + renderer: Renderer, + toast: Toast, + clipboard: ClipboardService, + options?: { retain?: boolean }, +): boolean { + const clipboardText = text(renderer) + if (!clipboardText) return false clipboard ?.write?.(clipboardText) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) - renderer.clearSelection() + if (!options?.retain) renderer.clearSelection() return true } diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx index b0eed3081248..981ded174b18 100644 --- a/packages/tui/test/keymap.test.tsx +++ b/packages/tui/test/keymap.test.tsx @@ -139,3 +139,11 @@ test("mode-less bindings stay active when opencode mode changes", async () => { app.renderer.destroy() } }) + +test("add selection to chat resolves through the keybind command map", () => { + const config = createResolvedKeymapConfig() + expect(config.keybinds.get("prompt.add_selection").map((binding) => binding.key)).toEqual(["p"]) + expect(config.keybinds.get("prompt.add_selection")).toEqual( + config.keybinds.gather("prompt.palette", ["prompt.add_selection"]), + ) +}) diff --git a/packages/tui/test/selection-retention.test.ts b/packages/tui/test/selection-retention.test.ts new file mode 100644 index 000000000000..451b4dcd4ca2 --- /dev/null +++ b/packages/tui/test/selection-retention.test.ts @@ -0,0 +1,96 @@ +import { ScrollBoxRenderable, TextRenderable } from "@opentui/core" +import { createTestRenderer } from "@opentui/core/testing" +import { createBindingLookup } from "@opentui/keymap/extras" +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { expect, test } from "bun:test" +import { TuiKeybind } from "../src/config/keybind" +import { OPENCODE_BASE_MODE, registerOpencodeKeymap } from "../src/keymap" + +// Mirrors the intercept in app.tsx that dismisses a retained copy-on-select highlight. +test("a retained selection survives the leader sequence but not an unbound key", async () => { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + const keymap = createDefaultOpenTuiKeymap(setup.renderer) + const config = { + keybinds: createBindingLookup(TuiKeybind.toBindingConfig(TuiKeybind.parse({})), { + commandMap: TuiKeybind.CommandMap, + bindingDefaults: TuiKeybind.bindingDefaults(), + }), + leader_timeout: 2000, + } + const offKeymap = registerOpencodeKeymap(keymap, setup.renderer, config) + + const ran: string[] = [] + const offLayer = keymap.registerLayer({ + mode: OPENCODE_BASE_MODE, + commands: [ + { + name: "prompt.add_selection", + run: () => { + ran.push("add_selection") + }, + }, + ], + bindings: config.keybinds.gather("prompt.palette", ["prompt.add_selection"]), + }) + + let dialogOpen = false + const dismissed: string[] = [] + const offIntercept = keymap.intercept( + "key:after", + (ctx) => { + if (ctx.handled || ctx.pendingSequence.length > 0) return + if (dialogOpen) return + dismissed.push(ctx.event.name ?? "?") + }, + { priority: 1 }, + ) + + try { + setup.mockInput.pressKey("x", { ctrl: true }) + expect(dismissed).toEqual([]) + + setup.mockInput.pressKey("p") + expect(ran).toEqual(["add_selection"]) + expect(dismissed).toEqual([]) + + setup.mockInput.pressKey("a") + expect(dismissed).toEqual(["a"]) + + // Filtering the command palette must not drop the selection the command is about to read. + dialogOpen = true + setup.mockInput.pressKey("d") + setup.mockInput.pressKey("d") + expect(dismissed).toEqual(["a"]) + } finally { + offIntercept() + offLayer() + offKeymap() + setup.renderer.destroy() + } +}) + +// A retained highlight is anchored to each renderable's text buffer once the drag finishes, +// so scrolling moves it with its content instead of re-reading whatever is at those coordinates. +test("scroll does not re-target a retained selection", async () => { + const setup = await createTestRenderer({ width: 40, height: 6, useThread: false }) + const scroll = new ScrollBoxRenderable(setup.renderer, { id: "scroll", width: 40, height: 6 }) + setup.renderer.root.add(scroll) + for (let i = 0; i < 20; i++) { + scroll.add( + new TextRenderable(setup.renderer, { id: `l${i}`, content: `line-${i}`, selectable: true, width: 40, height: 1 }), + ) + } + await setup.renderOnce() + + await setup.mockMouse.drag(0, 0, 7, 0) + const before = setup.renderer.getSelection()?.getSelectedText() + + await setup.mockMouse.scroll(10, 3, "down") + await setup.renderOnce() + await setup.mockMouse.scroll(10, 3, "down") + await setup.renderOnce() + const after = setup.renderer.getSelection()?.getSelectedText() + + setup.renderer.destroy() + expect(after).toBe(before) +}) diff --git a/packages/tui/test/selection.test.ts b/packages/tui/test/selection.test.ts new file mode 100644 index 000000000000..c0a3e2b0a69c --- /dev/null +++ b/packages/tui/test/selection.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "bun:test" +import { Selection } from "../src/util/selection" + +function createRenderer( + selected?: string, + focus?: { hasSelection: () => boolean; getClipboardText: (text: string) => string }, +) { + let current = selected + return { + cleared: 0, + currentFocusedRenderable: focus ?? null, + getSelection() { + if (current === undefined) return null + return { + getSelectedText: () => current!, + selectedRenderables: focus ? [focus] : [], + } + }, + clearSelection() { + current = undefined + this.cleared++ + }, + } +} + +const toast = { show: () => {}, error: () => {} } +const clipboard = { write: async () => {} } + +test("text reads the highlight without clearing it", () => { + const renderer = createRenderer("hello\nworld") + expect(Selection.text(renderer)).toBe("hello\nworld") + // the command clears only once the quote is in the prompt, so a failure keeps the highlight + expect(renderer.cleared).toBe(0) + expect(Selection.text(createRenderer())).toBeUndefined() +}) + +test("copy-on-select retains the highlight so it can still be added to the prompt", () => { + const renderer = createRenderer("from the response") + expect(Selection.copy(renderer, toast, clipboard, { retain: true })).toBe(true) + expect(renderer.cleared).toBe(0) + expect(Selection.text(renderer)).toBe("from the response") +}) + +test("an explicit copy clears the highlight", () => { + const renderer = createRenderer("from the response") + expect(Selection.copy(renderer, toast, clipboard)).toBe(true) + expect(renderer.getSelection()).toBeNull() + expect(Selection.text(renderer)).toBeUndefined() +}) + +test("text expands placeholders when the selection is inside the focused input", () => { + const focus = { + hasSelection: () => true, + getClipboardText: (text: string) => text.replace("[Pasted ~3 lines]", "a\nb\nc"), + } + expect(Selection.text(createRenderer("look at [Pasted ~3 lines]", focus))).toBe("look at a\nb\nc") +}) + +test("quote leaves no trailing whitespace on blank or padded lines", () => { + expect(Selection.quote("first\n\nsecond ")).toBe("> first\n>\n> second\n") + expect(Selection.quote(" \nonly")).toBe(">\n> only\n") +}) + +test("quote nests an already quoted region rather than flattening it", () => { + expect(Selection.quote("> cited\nreply")).toBe("> > cited\n> reply\n") +}) diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 86f67dfd73ca..be582be57c00 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -89,6 +89,7 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. "prompt_submit": "none", "prompt_editor_context_clear": "none", + "prompt_add_selection": "p", "prompt_skills": "none", "prompt_stash": "none", "prompt_stash_pop": "none",