diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 93d1fc19ae2b..3196a5ddf0a2 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -302,6 +302,16 @@ function App(props: { onSnapshot?: () => Promise }) { const sel = renderer.getSelection() if (!sel) return + if (keybind.match("input_quote_selection", evt)) { + if (route.data.type !== "session") return + if (!Selection.text(renderer)) return + evt.preventDefault() + evt.stopPropagation() + promptRef.current?.focus() + command.trigger("prompt.quote_selection") + return + } + // Windows Terminal-like behavior: // - Ctrl+C copies and dismisses selection // - Esc dismisses selection diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 382bd2806ec7..4947055e304a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -22,6 +22,7 @@ import { useKeyboard, useRenderer, type JSX } from "@opentui/solid" import { Editor } from "@tui/util/editor" import { useExit } from "../../context/exit" import { Clipboard } from "../../util/clipboard" +import { Selection } from "../../util/selection" import type { AssistantMessage, FilePart } from "@opencode-ai/sdk/v2" import { TuiEvent } from "../../event" import { iife } from "@/util/iife" @@ -246,6 +247,19 @@ export function Prompt(props: PromptProps) { } }, }, + { + title: "Quote selection", + value: "prompt.quote_selection", + keybind: "input_quote_selection", + category: "Prompt", + hidden: true, + onSelect: () => { + const text = Selection.text(renderer) + if (!text) return + insert(Selection.quote(text)) + renderer.clearSelection() + }, + }, { title: "Interrupt session", value: "session.interrupt", @@ -775,6 +789,10 @@ export function Prompt(props: PromptProps) { ) } + function insert(text: string) { + input.insertText(text) + } + async function pasteImage(file: { filename?: string; content: string; mime: string }) { const currentOffset = input.visualCursor.offset const extmarkStart = currentOffset diff --git a/packages/opencode/src/cli/cmd/tui/util/selection.ts b/packages/opencode/src/cli/cmd/tui/util/selection.ts index 1230852dcc07..5493f88de01f 100644 --- a/packages/opencode/src/cli/cmd/tui/util/selection.ts +++ b/packages/opencode/src/cli/cmd/tui/util/selection.ts @@ -11,8 +11,21 @@ type Renderer = { } export namespace Selection { + export function text(renderer: Renderer) { + return renderer.getSelection()?.getSelectedText() + } + + export function quote(text: string) { + return text + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .split("\n") + .map((line) => `> ${line}`) + .join("\n") + "\n\n" + } + export function copy(renderer: Renderer, toast: Toast): boolean { - const text = renderer.getSelection()?.getSelectedText() + const text = Selection.text(renderer) if (!text) return false Clipboard.copy(text) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 850bcc28bcd9..9d7ca851ee1c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -670,6 +670,7 @@ export namespace Config { variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"), input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), + input_quote_selection: z.string().optional().default("ctrl+q").describe("Quote selected text into input"), input_submit: z.string().optional().default("return").describe("Submit input"), input_newline: z .string() diff --git a/packages/opencode/test/cli/tui/selection.test.ts b/packages/opencode/test/cli/tui/selection.test.ts new file mode 100644 index 000000000000..ea5a600e4117 --- /dev/null +++ b/packages/opencode/test/cli/tui/selection.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { Selection } from "../../../src/cli/cmd/tui/util/selection" + +describe("Selection.quote", () => { + test("quotes each line and appends spacing", () => { + expect(Selection.quote("original\nfew\nlines")).toBe("> original\n> few\n> lines\n\n") + }) + + test("normalizes windows line endings", () => { + expect(Selection.quote("first\r\nsecond\rthird")).toBe("> first\n> second\n> third\n\n") + }) + + test("preserves blank lines inside selection", () => { + expect(Selection.quote("first\n\nthird")).toBe("> first\n> \n> third\n\n") + }) +}) diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index a8d98b66cdec..a7eb6a620d04 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -159,6 +159,22 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist }) }) +test("accepts input_quote_selection keybind", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_quote_selection: "ctrl+q" } }, null, 2)) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.keybinds?.input_quote_selection).toBe("ctrl+q") + }, + }) +}) + test("migrates project legacy tui keys even when global tui.json already exists", async () => { await using tmp = await tmpdir({ init: async (dir) => {