tui: preserve clipboard paste semantics
This commit is contained in:
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<ReturnType<typeof createTestRenderer>> | 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<ClipboardReadResult>) {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
let reads = 0
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((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<void>((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<unknown> | 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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user