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 cc3eac820fb9..a8a992b454ad 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -422,7 +422,12 @@ export function Prompt(props: PromptProps) { { title: "Paste", value: "prompt.paste", - keybind: "input_paste", + // Deliberately no `keybind`: the command dialog's global keypress + // listener would intercept input_paste (ctrl+v) first and + // preventDefault, which skips the textarea's onKeyDown — where the + // actual text/image paste runs. Keep ctrl+v flowing to the textarea; + // this command stays reachable only via command.trigger() (e.g. the + // empty-bracketed-paste image fallback in onPaste). category: "Prompt", hidden: true, onSelect: async () => { @@ -1136,6 +1141,73 @@ export function Prompt(props: PromptProps) { return } + // Shared text-paste pipeline used by both bracketed paste (onPaste) and + // terminals that forward Ctrl+V as a keydown (e.g. Windows Terminal 1.25+ + // with the kitty keyboard protocol active). Keeps paste behavior identical + // regardless of which signal the terminal delivers it through. + async function handlePastedText(normalizedText: string) { + const pastedContent = normalizedText.trim() + + const filepath = iife(() => { + const raw = pastedContent.replace(/^['"]+|['"]+$/g, "") + if (raw.startsWith("file://")) { + try { + return fileURLToPath(raw) + } catch {} + } + if (process.platform === "win32") return raw + return raw.replace(/\\(.)/g, "$1") + }) + const isUrl = /^(https?):\/\//.test(filepath) + if (!isUrl) { + try { + const mime = await Filesystem.mimeType(filepath) + const filename = path.basename(filepath) + // Handle SVG as raw text content, not as base64 image + if (mime === "image/svg+xml") { + const content = await Filesystem.readText(filepath).catch(() => {}) + if (content) { + pasteText(content, `[SVG: ${filename ?? "image"}]`) + return + } + } + if (mime.startsWith("image/") || mime === "application/pdf") { + const content = await Filesystem.readArrayBuffer(filepath) + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(() => {}) + if (content) { + await pasteAttachment({ + filename, + filepath, + mime, + content, + }) + return + } + } + } catch {} + } + + const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 + if ( + (lineCount >= 3 || pastedContent.length > 150) && + kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary) + ) { + pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) + return + } + + input.insertText(normalizedText) + + // Force layout update and render for the pasted content + setTimeout(() => { + // setTimeout is a workaround and needs to be addressed properly + if (!input || input.isDestroyed) return + input.getLayoutNode().markDirty() + renderer.requestRender() + }, 0) + } + const highlight = createMemo(() => { if (keybind.leader) return theme.border if (store.mode === "shell") return theme.primary @@ -1395,7 +1467,19 @@ export function Prompt(props: PromptProps) { }) return } - // If no image, let the default paste behavior continue + // Terminals that forward Ctrl+V to the app instead of pasting + // themselves (e.g. Windows Terminal 1.25+ with the kitty + // keyboard protocol active) never emit a bracketed paste, so a + // text clipboard would otherwise be dropped entirely. Insert it + // through the same pipeline as bracketed paste. Terminals that + // paste natively never deliver the Ctrl+V keydown, so this + // cannot double-insert. + if (content?.mime.startsWith("text/") && content.data.length > 0) { + e.preventDefault() + const normalizedText = content.data.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + await handlePastedText(normalizedText) + return + } } if (keybind.match("input_clear", e) && store.prompt.input !== "") { input.clear() @@ -1482,64 +1566,7 @@ export function Prompt(props: PromptProps) { // default paste unless we suppress it first and handle insertion ourselves. event.preventDefault() - const filepath = iife(() => { - const raw = pastedContent.replace(/^['"]+|['"]+$/g, "") - if (raw.startsWith("file://")) { - try { - return fileURLToPath(raw) - } catch {} - } - if (process.platform === "win32") return raw - return raw.replace(/\\(.)/g, "$1") - }) - const isUrl = /^(https?):\/\//.test(filepath) - if (!isUrl) { - try { - const mime = await Filesystem.mimeType(filepath) - const filename = path.basename(filepath) - // Handle SVG as raw text content, not as base64 image - if (mime === "image/svg+xml") { - const content = await Filesystem.readText(filepath).catch(() => {}) - if (content) { - pasteText(content, `[SVG: ${filename ?? "image"}]`) - return - } - } - if (mime.startsWith("image/") || mime === "application/pdf") { - const content = await Filesystem.readArrayBuffer(filepath) - .then((buffer) => Buffer.from(buffer).toString("base64")) - .catch(() => {}) - if (content) { - await pasteAttachment({ - filename, - filepath, - mime, - content, - }) - return - } - } - } catch {} - } - - const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 - if ( - (lineCount >= 3 || pastedContent.length > 150) && - kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary) - ) { - pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) - return - } - - input.insertText(normalizedText) - - // Force layout update and render for the pasted content - setTimeout(() => { - // setTimeout is a workaround and needs to be addressed properly - if (!input || input.isDestroyed) return - input.getLayoutNode().markDirty() - renderer.requestRender() - }, 0) + await handlePastedText(normalizedText) }} ref={(r: TextareaRenderable) => { input = r diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index c7d2c93d18ef..108d58afc0e5 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -4,6 +4,8 @@ import { DialogHeader, useDialog, type DialogContext } from "./dialog" import { Show, createEffect, createMemo, onMount, type JSX } from "solid-js" import { useKeyboard } from "@opentui/solid" import { Spans, type GlowSpan } from "@tui/ui/glow" +import { useKeybind } from "@tui/context/keybind" +import * as Clipboard from "@tui/util/clipboard" export type DialogPromptProps = { title: string @@ -22,15 +24,22 @@ export type DialogPromptProps = { export function DialogPrompt(props: DialogPromptProps) { const dialog = useDialog() const { theme } = useTheme() + const keybind = useKeybind() let textarea: TextareaRenderable - useKeyboard((evt) => { + useKeyboard(async (evt) => { if (props.busy) { if (evt.name === "escape") return evt.preventDefault() evt.stopPropagation() return } + if (keybind.match("input_paste", evt)) { + evt.preventDefault() + evt.stopPropagation() + await Clipboard.pasteText(textarea) + return + } if (evt.name === "return") { evt.preventDefault() evt.stopPropagation() diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 98994f2bb279..3e65da668c5f 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -13,6 +13,7 @@ import { Locale } from "@/util/locale" import { getScrollAcceleration } from "../util/scroll" import { useTuiConfig } from "../context/tui-config" import { sinkColor, Spans, type GlowSpan } from "@tui/ui/glow" +import * as Clipboard from "@tui/util/clipboard" export interface DialogSelectProps { title: string @@ -198,9 +199,15 @@ export function DialogSelect(props: DialogSelectProps) { } const keybind = useKeybind() - useKeyboard((evt) => { + useKeyboard(async (evt) => { setStore("input", "keyboard") + if (keybind.match("input_paste", evt)) { + evt.preventDefault() + evt.stopPropagation() + await Clipboard.pasteText(input) + return + } if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1) if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) if (evt.name === "pageup") move(-10) diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 3a9996902ddf..ba6955de438f 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -88,6 +88,22 @@ export async function read(): Promise { return { data: imageBuffer.toString("base64"), mime: "image/png" } } } + + // Text read via PowerShell. The compiled single-file exe bundles clipboardy's + // Windows implementation, which spawns a helper binary (clipboard_x86_64.exe) + // from a virtual in-bundle path that cannot be executed, so every clipboard + // read fails at runtime there. PowerShell (the same mechanism as the image + // probe above and the write path below) works identically in dev and in the + // compiled exe. `[Console]::Write` avoids PowerShell's output formatting, so + // the returned text is byte-exact (no appended newline). + const textScript = + "Add-Type -AssemblyName System.Windows.Forms; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::Write([System.Windows.Forms.Clipboard]::GetText())" + const textResult = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", textScript], { + nothrow: true, + }) + if (textResult.text) { + return { data: textResult.text, mime: "text/plain" } + } } if (os === "linux") { @@ -110,6 +126,16 @@ export async function read(): Promise { } } +export async function pasteText( + input: { insertText(text: string): void }, + readClipboard: () => Promise = read, +) { + const content = await readClipboard() + if (!content?.mime.startsWith("text/") || content.data.length === 0) return false + input.insertText(content.data.replace(/\r\n/g, "\n").replace(/\r/g, "\n")) + return true +} + const getCopyMethod = lazy(async () => { const os = platform() const which = await getWhich() diff --git a/packages/opencode/test/cli/cmd/tui/clipboard-paste.test.ts b/packages/opencode/test/cli/cmd/tui/clipboard-paste.test.ts new file mode 100644 index 000000000000..5f9b3a0a96e0 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/clipboard-paste.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import * as Clipboard from "../../../../src/cli/cmd/tui/util/clipboard" + +describe("clipboard text insertion", () => { + test("normalizes and inserts clipboard text", async () => { + const inserted: string[] = [] + const result = await Clipboard.pasteText( + { insertText: (text) => inserted.push(text) }, + async () => ({ data: "first\r\nsecond\rthird", mime: "text/plain" }), + ) + + expect(result).toBe(true) + expect(inserted).toEqual(["first\nsecond\nthird"]) + }) + + test("ignores non-text and empty clipboards", async () => { + const inserted: string[] = [] + const input = { insertText: (text: string) => inserted.push(text) } + + expect(await Clipboard.pasteText(input, async () => ({ data: "image", mime: "image/png" }))).toBe(false) + expect(await Clipboard.pasteText(input, async () => ({ data: "", mime: "text/plain" }))).toBe(false) + expect(await Clipboard.pasteText(input, async () => undefined)).toBe(false) + expect(inserted).toEqual([]) + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/dialog-paste-ctrlv.test.tsx b/packages/opencode/test/cli/cmd/tui/dialog-paste-ctrlv.test.tsx new file mode 100644 index 000000000000..12d5c4e56a20 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/dialog-paste-ctrlv.test.tsx @@ -0,0 +1,37 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import type { KeyEvent, TextareaRenderable } from "@opentui/core" +import { testRender, useKeyboard } from "@opentui/solid" +import { Keybind } from "../../../../src/util/keybind" +import * as Clipboard from "../../../../src/cli/cmd/tui/util/clipboard" + +test("a dialog keyboard listener pastes into its focused input", async () => { + let input: TextareaRenderable | undefined + const handle = await testRender( + () => { + useKeyboard(async (evt: KeyEvent) => { + if (!Keybind.parse("ctrl+v").some((bind) => Keybind.match(bind, Keybind.fromParsedKey(evt)))) return + evt.preventDefault() + evt.stopPropagation() + await Clipboard.pasteText(input!, async () => ({ data: "api-key-from-clipboard", mime: "text/plain" })) + }) + return