From 9db2df59b0228307482fe1720cd5f6cf85ffccb9 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Wed, 29 Jul 2026 22:17:40 +0200 Subject: [PATCH] tui: preserve clipboard paste semantics --- packages/tui/src/clipboard.ts | 2 +- packages/tui/src/component/prompt/index.tsx | 3 +- packages/tui/test/clipboard.test.ts | 6 +- packages/tui/test/prompt/clipboard.test.ts | 208 ++++++++++++++++++++ 4 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 packages/tui/test/prompt/clipboard.test.ts diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts index bbfe496868..a1f0f24fee 100644 --- a/packages/tui/src/clipboard.ts +++ b/packages/tui/src/clipboard.ts @@ -70,7 +70,7 @@ export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedCl if (result.status === "failed") throw result.error if (result.status === "timed-out") throw new Error(`Clipboard read timed out after ${timeoutMs}ms`) if (result.status === "limit-exceeded") { - throw new RangeError(`Clipboard read exceeded the ${maxReadBytes}-byte limit`) + throw new RangeError("Clipboard content exceeded configured read or image conversion limits") } throw new Error(`Unexpected clipboard read status: ${result.status}`) } diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 0e111c620d..1d7acc620f 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1397,11 +1397,10 @@ export function Prompt(props: PromptProps) { // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste // Replace CRLF first, then any remaining CR const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n") - const pastedContent = normalizedText.trim() // Windows Terminal <1.25 can surface image-only clipboard as an // empty bracketed paste. Windows Terminal 1.25+ does not. - if (!pastedContent) { + if (event.bytes.byteLength === 0) { keymap.dispatch("prompt.paste") return } diff --git a/packages/tui/test/clipboard.test.ts b/packages/tui/test/clipboard.test.ts index c773647672..f82b37903f 100644 --- a/packages/tui/test/clipboard.test.ts +++ b/packages/tui/test/clipboard.test.ts @@ -89,14 +89,16 @@ function writeError(result: ClipboardWriteResult) { test("requests the standard clipboard with image-first preferences and adapts PNG bytes", async () => { const requests: ClipboardReadOptions[] = [] + const bytes = new Uint8Array([0, 1, 2, 255]) const clipboard = createClipboardAdapter( openTuiClipboard({ - read: { status: "read", representation: { mimeType: "image/png", bytes: new Uint8Array([0, 1, 2, 255]) } }, + read: { status: "read", representation: { mimeType: "image/png", bytes } }, onCoreRead: (input) => requests.push(input), }), ) expect(await clipboard.read()).toEqual({ data: "AAEC/w==", mime: "image/png" }) + expect(bytes).toEqual(new Uint8Array([0, 1, 2, 255])) expect(requests).toEqual([{ preferredTypes: ["image/png", "text/plain"], selection: "clipboard" }]) }) @@ -137,7 +139,7 @@ test("preserves backend read failures and synthesizes operational errors", async const limitError = await limited.read().then(undefined, (error) => error) expect(limitError).toBeInstanceOf(RangeError) if (!(limitError instanceof RangeError)) throw limitError - expect(limitError.message).toBe("Clipboard read exceeded the 8388608-byte limit") + expect(limitError.message).toBe("Clipboard content exceeded configured read or image conversion limits") const unexpected = createClipboardAdapter( openTuiClipboard({ diff --git a/packages/tui/test/prompt/clipboard.test.ts b/packages/tui/test/prompt/clipboard.test.ts new file mode 100644 index 0000000000..7c11103a3e --- /dev/null +++ b/packages/tui/test/prompt/clipboard.test.ts @@ -0,0 +1,208 @@ +import { afterAll, expect, mock, test } from "bun:test" +import { TextareaRenderable, type ClipboardReadResult, type HostClipboardService } from "@opentui/core" +import { createTestRenderer } from "@opentui/core/testing" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Global } from "@opencode-ai/util/global" +import { Effect, FileSystem } from "effect" +import { createComponent } from "solid-js" +import { Prompt, type PromptRef } from "../../src/component/prompt" +import { createEventStream, createFetch } from "../fixture/tui-client" + +const openTui = { ...(await import("@opentui/core")) } +let activeSetup: Awaited> | undefined +let activeHost: HostClipboardService | undefined +let activePromptRef: PromptRef | undefined + +await mock.module("@opentui/core", () => ({ + ...openTui, + createCliRenderer: async () => { + if (!activeSetup) throw new Error("Prompt renderer is not mounted") + return activeSetup.renderer + }, + createHostClipboard: () => { + if (!activeHost) throw new Error("Prompt clipboard is not mounted") + return activeHost + }, +})) +await mock.module("../../src/routes/home", () => ({ + Home: () => + createComponent(Prompt, { + ref: (value) => (activePromptRef = value), + showPlaceholder: false, + }), +})) +const { run } = await import("../../src/app") + +afterAll(() => mock.restore()) + +async function mountPrompt(read: () => Promise) { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + let reads = 0 + let ready!: () => void + const mounted = new Promise((resolve) => (ready = resolve)) + const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer) + setup.renderer.setTerminalTitle = (title) => { + if (title === "OpenCode") ready() + setTitle(title) + } + + const host: HostClipboardService = { + maxWriteBytes: 8 * 1024 * 1024, + async read() { + reads++ + return read() + }, + async writeText() { + return { status: "written" } + }, + async clear() { + return { status: "cleared" } + }, + async dispose() {}, + } + activeSetup = setup + activeHost = host + activePromptRef = undefined + + const events = createEventStream() + const calls = createFetch(undefined, events) + let preloaded!: () => void + const preload = new Promise((resolve) => (preloaded = resolve)) + const server = Bun.serve({ + port: 0, + fetch: async (request) => { + const response = await calls.fetch(request) + const url = new URL(request.url) + if (url.pathname === "/api/session" && url.searchParams.has("project")) preloaded() + return response + }, + }) + let task: Promise | undefined + try { + task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({ prompt: { paste: "full" as const } }), update: async () => ({}) }, + packages: { resolve: async () => undefined }, + args: {}, + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + await mounted + await setup.waitFor(() => activePromptRef?.focused === true) + await preload + await Bun.sleep(0) + } catch (error) { + setup.renderer.destroy() + await task?.catch(() => undefined) + await server.stop() + activeSetup = undefined + activeHost = undefined + activePromptRef = undefined + throw error + } + + return { + setup, + get input() { + const input = setup.renderer.currentFocusedEditor + if (!(input instanceof TextareaRenderable)) throw new Error("Prompt textarea is not focused") + return input + }, + get reads() { + return reads + }, + get prompt() { + if (!activePromptRef) throw new Error("Prompt ref is not mounted") + return activePromptRef + }, + async dispose() { + activePromptRef?.reset() + setup.renderer.destroy() + await task + await server.stop() + activeSetup = undefined + activeHost = undefined + activePromptRef = undefined + }, + } +} + +test("inserts nonempty whitespace-only terminal paste without reading the host clipboard", async () => { + const prompt = await mountPrompt(async () => ({ status: "empty" })) + try { + await prompt.setup.mockInput.pasteBracketedText(" \t\n") + await prompt.setup.waitFor(() => prompt.input.plainText === " \t\n") + + expect(prompt.input.plainText).toBe(" \t\n") + expect(prompt.reads).toBe(0) + } finally { + await prompt.dispose() + } +}) + +test("uses one host clipboard read for a zero-byte terminal paste", async () => { + const prompt = await mountPrompt(async () => ({ status: "empty" })) + try { + prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) + await prompt.setup.waitFor(() => prompt.reads === 1) + + expect(prompt.input.plainText).toBe("") + expect(prompt.reads).toBe(1) + } finally { + await prompt.dispose() + } +}) + +test("normalizes host clipboard text once before inserting it", async () => { + const bytes = new TextEncoder().encode("first\r\nsecond\rthird") + const prompt = await mountPrompt(async () => ({ status: "read", representation: { mimeType: "text/plain", bytes } })) + try { + prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) + await prompt.setup.waitFor(() => prompt.input.plainText === "first\nsecond\nthird") + + expect(prompt.input.plainText).toBe("first\nsecond\nthird") + expect(prompt.reads).toBe(1) + } finally { + await prompt.dispose() + } +}) + +test("creates one image mention from PNG clipboard bytes", async () => { + const prompt = await mountPrompt(async () => ({ + status: "read", + representation: { mimeType: "image/png", bytes: new Uint8Array([137, 80, 78, 71]) }, + })) + try { + prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) + await prompt.setup.waitFor(() => prompt.input.plainText === "[Image 1] ") + + expect(prompt.input.plainText).toBe("[Image 1] ") + expect(prompt.input.extmarks.getVirtual()).toHaveLength(1) + expect(prompt.prompt.current.files).toEqual([ + { + uri: "data:image/png;base64,iVBORw==", + name: "clipboard", + mention: { start: 0, end: 9, text: "[Image 1]" }, + }, + ]) + expect(prompt.reads).toBe(1) + } finally { + await prompt.dispose() + } +}) + +test("shows clipboard read failures without changing prompt state", async () => { + const prompt = await mountPrompt(async () => ({ status: "failed", error: new Error("clipboard read failed") })) + try { + prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) + await prompt.setup.waitForFrame((frame) => frame.includes("clipboard read failed")) + + expect(prompt.input.plainText).toBe("") + expect(prompt.input.extmarks.getAll()).toHaveLength(0) + expect(prompt.reads).toBe(1) + } finally { + await prompt.dispose() + } +})