From bbb7298d908da369de90772bec03dca8b5a1d215 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 30 Jul 2026 15:16:34 +0200 Subject: [PATCH] tui: serialize clipboard and paste queue Concurrent clipboard and terminal pastes raced the prompt, so later work could clobber earlier attachments or touch a destroyed input. Queue pastes FIFO, drop stale work after prompt mutations, and keep hit testing aligned with thumbnail geometry so overflow stays usable. --- packages/tui/src/component/prompt/index.tsx | 173 ++++++++++++------ .../src/component/prompt/local-attachment.ts | 3 +- packages/tui/test/prompt/clipboard.test.ts | 151 ++++++++++++++- 3 files changed, 272 insertions(+), 55 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index b7ae0f331d..fafc4e64f2 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -305,15 +305,96 @@ export function Prompt(props: PromptProps) { extmarkToPart: new Map(), interrupt: 0, }) + let disposed = false + let pasteQueue = Promise.resolve() + let pasteEpoch = 0 + let pasteMutating = false + let pasteMutation = 0 + + function capturePrompt() { + return { + epoch: pasteEpoch, + sessionID: props.sessionID, + mode: store.mode, + text: input.plainText, + cursor: input.cursorOffset, + files: store.prompt.files && unwrap(store.prompt.files), + agents: store.prompt.agents && unwrap(store.prompt.agents), + pasted: unwrap(store.prompt.pasted), + } + } + + function promptChanged(before: ReturnType) { + if (disposed || input.isDestroyed) return true + return ( + pasteEpoch !== before.epoch || + props.sessionID !== before.sessionID || + store.mode !== before.mode || + input.plainText !== before.text || + input.cursorOffset !== before.cursor || + (store.prompt.files && unwrap(store.prompt.files)) !== before.files || + (store.prompt.agents && unwrap(store.prompt.agents)) !== before.agents || + unwrap(store.prompt.pasted) !== before.pasted + ) + } + + function cancelChangedPrompt(before: ReturnType) { + if (!promptChanged(before)) return false + pasteEpoch = Math.max(pasteEpoch, before.epoch + 1) + if (!disposed && !input.isDestroyed) { + toast.show({ message: "Attachment paste canceled because the prompt changed", variant: "warning" }) + } + return true + } + + function enqueuePaste(run: (before: ReturnType) => Promise) { + const epoch = pasteEpoch + pasteQueue = pasteQueue + .then(async () => { + if (disposed || epoch !== pasteEpoch) return + await run(capturePrompt()) + }) + .catch((error) => { + if (!disposed) toast.error(error) + }) + return pasteQueue + } + + function setPromptMode(mode: "normal" | "shell") { + if (store.mode === mode) return + pasteEpoch++ + setStore("mode", mode) + } + + function applyPaste(run: () => void) { + const mutation = ++pasteMutation + pasteMutating = true + try { + run() + } finally { + queueMicrotask(() => { + if (pasteMutation === mutation) pasteMutating = false + }) + } + } + const imageAttachments = createMemo(() => (store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")), ) const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4)))) const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2) + const imagePreviewAvailableWidth = createMemo(() => Math.min(70, Math.max(0, dimensions().width - 9))) const imagePreviewLimit = createMemo(() => - Math.max(1, Math.min(3, Math.floor((Math.min(70, dimensions().width - 9) - 8) / (imagePreviewWidth() + 1)))), + Math.max(1, Math.min(3, Math.floor((imagePreviewAvailableWidth() - 8) / (imagePreviewWidth() + 1)))), + ) + const visibleImageCount = createMemo(() => Math.min(imagePreviewLimit(), imageAttachments().length)) + const hiddenImageAttachmentCount = createMemo(() => imageAttachments().length - visibleImageCount()) + const imagePreviewsVisible = createMemo( + () => imageAttachments().length > 0 && imagePreviewAvailableWidth() >= imagePreviewWidth(), + ) + const imageOverflowVisible = createMemo( + () => hiddenImageAttachmentCount() > 0 && imagePreviewAvailableWidth() >= imagePreviewWidth() + 9, ) - const hiddenImageAttachmentCount = createMemo(() => Math.max(0, imageAttachments().length - imagePreviewLimit())) function openImagePreview(initial: number) { const images = imageAttachments() @@ -322,14 +403,14 @@ export function Prompt(props: PromptProps) { } function imagePreviewMouseIndex(event: MouseEvent): number | undefined { - if (!config.prompt?.image_preview || imageAttachments().length === 0) return undefined - const x = event.x - anchor.x - 2 + if (!config.prompt?.image_preview || !imagePreviewsVisible()) return undefined + const x = event.x - anchor.x - 3 const y = event.y - anchor.y - 1 if (x < 0 || y < 0 || y >= imagePreviewHeight()) return undefined const stride = imagePreviewWidth() + 1 const index = Math.floor(x / stride) - if (index < imagePreviewLimit() && x % stride < imagePreviewWidth()) return index - if (index === imagePreviewLimit() && hiddenImageAttachmentCount() > 0) return imagePreviewLimit() + if (index < visibleImageCount() && x % stride < imagePreviewWidth()) return index + if (index === visibleImageCount() && imageOverflowVisible() && x % stride < 8) return visibleImageCount() return undefined } @@ -401,11 +482,12 @@ export function Prompt(props: PromptProps) { name: "prompt.paste", category: "Prompt", palette: undefined, - run: async (_input: string | undefined, event?: KeyEvent) => { + run: (_input: string | undefined, event?: KeyEvent) => { event?.preventDefault() event?.stopPropagation() - try { + return enqueuePaste(async (before) => { const content = await clipboard.read() + if (cancelChangedPrompt(before)) return if (content?.mime.startsWith("image/")) { pasteAttachment({ filename: "clipboard", @@ -414,11 +496,9 @@ export function Prompt(props: PromptProps) { return } if (content?.mime === "text/plain") { - await pasteInputText(content.data) + await pasteInputText(content.data, before) } - } catch (error) { - toast.error(error) - } + }) }, }, { @@ -439,7 +519,7 @@ export function Prompt(props: PromptProps) { if (!input.focused) return // TODO: this should be its own command if (store.mode === "shell") { - setStore("mode", "normal") + setPromptMode("normal") return } if (!props.sessionID) return @@ -584,12 +664,14 @@ export function Prompt(props: PromptProps) { input.blur() }, set(prompt) { + pasteEpoch++ input.setText(prompt.text) setStore("prompt", prompt) restoreExtmarksFromPrompt(prompt) input.gotoBufferEnd() }, reset() { + pasteEpoch++ input.clear() input.extmarks.clear() setStore("prompt", emptyPrompt()) @@ -613,6 +695,7 @@ export function Prompt(props: PromptProps) { }) onCleanup(() => { + disposed = true if (store.prompt.text) { stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } } @@ -836,7 +919,7 @@ export function Prompt(props: PromptProps) { group: "Prompt", run: () => { setStore("placeholder", randomIndex(shell().length)) - setStore("mode", "shell") + setPromptMode("shell") }, }, ], @@ -847,7 +930,7 @@ export function Prompt(props: PromptProps) { return { target: inputTarget, enabled: inputTarget() !== undefined && store.mode === "shell", - commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }], + commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setPromptMode("normal") }], } }) @@ -858,9 +941,7 @@ export function Prompt(props: PromptProps) { cursorVersion() return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0 })(), - commands: [ - { bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }, - ], + commands: [{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setPromptMode("normal") }], } }) @@ -891,7 +972,7 @@ export function Prompt(props: PromptProps) { if (!item) return false input.setText(item.text) setStore("prompt", item) - setStore("mode", item.mode ?? "normal") + setPromptMode(item.mode ?? "normal") restoreExtmarksFromPrompt(item) input.cursorOffset = 0 }, @@ -930,7 +1011,7 @@ export function Prompt(props: PromptProps) { if (!item) return false input.setText(item.text) setStore("prompt", item) - setStore("mode", item.mode ?? "normal") + setPromptMode(item.mode ?? "normal") restoreExtmarksFromPrompt(item) input.cursorOffset = input.plainText.length }, @@ -1048,7 +1129,7 @@ export function Prompt(props: PromptProps) { sessionID, command: inputText, }) - setStore("mode", "normal") + setPromptMode("normal") } else if ( inputText.startsWith("/") && (data.location.command.list(currentLocation.current) ?? []).some( @@ -1179,7 +1260,7 @@ export function Prompt(props: PromptProps) { const extmarkStart = currentOffset const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText) - input.insertText(virtualText + " ") + applyPaste(() => input.insertText(virtualText + " ")) const extmarkId = input.extmarks.create({ start: extmarkStart, @@ -1201,35 +1282,15 @@ export function Prompt(props: PromptProps) { ) } - async function pasteInputText(text: string) { + async function pasteInputText(text: string, before: ReturnType) { const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") const pastedContent = normalizedText.trim() const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform) const isUrl = /^(https?):\/\//.test(filepath) if (!isUrl) { - const promptBefore = { - sessionID: props.sessionID, - text: input.plainText, - cursor: input.cursorOffset, - files: store.prompt.files && unwrap(store.prompt.files), - agents: store.prompt.agents && unwrap(store.prompt.agents), - pasted: unwrap(store.prompt.pasted), - } - const promptChanged = () => - props.sessionID !== promptBefore.sessionID || - input.plainText !== promptBefore.text || - input.cursorOffset !== promptBefore.cursor || - (store.prompt.files && unwrap(store.prompt.files)) !== promptBefore.files || - (store.prompt.agents && unwrap(store.prompt.agents)) !== promptBefore.agents || - unwrap(store.prompt.pasted) !== promptBefore.pasted - const cancelChangedPrompt = () => { - if (!promptChanged()) return false - toast.show({ message: "Attachment drop canceled because the prompt changed", variant: "warning" }) - return true - } const attachment = await readLocalAttachment(filepath) if (attachment) { - if (cancelChangedPrompt()) return + if (cancelChangedPrompt(before)) return pasteLocalAttachment(filepath, attachment) return } @@ -1245,20 +1306,22 @@ export function Prompt(props: PromptProps) { attachments.push({ filepath: candidate, attachment: next }) } if (attachments.length === filepaths.length) { - if (cancelChangedPrompt()) return + if (cancelChangedPrompt(before)) return for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment) return } } } + if (cancelChangedPrompt(before)) return + const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") { pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) return } - input.insertText(normalizedText) + applyPaste(() => input.insertText(normalizedText)) setTimeout(() => { if (!input || input.isDestroyed) return @@ -1292,7 +1355,7 @@ export function Prompt(props: PromptProps) { const extmarkEnd = extmarkStart + virtualText.length const textToInsert = virtualText + " " - input.insertText(textToInsert) + applyPaste(() => input.insertText(textToInsert)) const extmarkId = input.extmarks.create({ start: extmarkStart, @@ -1444,7 +1507,7 @@ export function Prompt(props: PromptProps) { openImagePreview(index) }} > - 0}> + - + {(file, index) => { const [failed, setFailed] = createSignal(false) return ( @@ -1481,7 +1544,7 @@ export function Prompt(props: PromptProps) { ) }} - 0 && dimensions().width >= 22}> + { + if (!pasteMutating) pasteEpoch++ const value = input.plainText setStore("prompt", "text", value) auto()?.onInput(value) syncExtmarksWithPromptParts() setCursorVersion((value) => value + 1) }} - onCursorChange={() => setCursorVersion((value) => value + 1)} + onCursorChange={() => { + if (!pasteMutating) pasteEpoch++ + setCursorVersion((value) => value + 1) + }} onKeyDown={(e: { preventDefault(): void }) => { if (props.disabled) { e.preventDefault() @@ -1524,7 +1591,7 @@ export function Prompt(props: PromptProps) { // hangul) is flushed to plainText before we read it for submission. setTimeout(() => setTimeout(() => submit(), 0), 0) }} - onPaste={async (event: PasteEvent) => { + onPaste={(event: PasteEvent) => { if (props.disabled) { event.preventDefault() return @@ -1546,7 +1613,7 @@ export function Prompt(props: PromptProps) { // default paste unless we suppress it first and handle insertion ourselves. event.preventDefault() - await pasteInputText(normalizedText) + void enqueuePaste((before) => pasteInputText(normalizedText, before)) }} ref={(r: TextareaRenderable) => { input = r diff --git a/packages/tui/src/component/prompt/local-attachment.ts b/packages/tui/src/component/prompt/local-attachment.ts index 82e56a5930..510372074b 100644 --- a/packages/tui/src/component/prompt/local-attachment.ts +++ b/packages/tui/src/component/prompt/local-attachment.ts @@ -1,3 +1,4 @@ +import { constants } from "node:fs" import { open } from "node:fs/promises" import path from "node:path" import { fileURLToPath } from "node:url" @@ -40,7 +41,7 @@ const mimeTypes: Record = { } async function readFileBounded(file: string, maxBytes: number) { - const handle = await open(file, "r") + const handle = await open(file, constants.O_RDONLY | constants.O_NONBLOCK) try { const info = await handle.stat() if (!info.isFile() || info.size > maxBytes) throw new Error("Attachment exceeds the local file limit") diff --git a/packages/tui/test/prompt/clipboard.test.ts b/packages/tui/test/prompt/clipboard.test.ts index ed29f33d9b..f7e772ffc2 100644 --- a/packages/tui/test/prompt/clipboard.test.ts +++ b/packages/tui/test/prompt/clipboard.test.ts @@ -181,6 +181,107 @@ test("normalizes host clipboard text once before inserting it", async () => { } }) +test("serializes overlapping local image pastes", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "opencode-overlap-")) + const file = path.join(directory, "image.png") + await writeFile(file, PNG_1X1) + const prompt = await mountPrompt(async () => ({ status: "empty" })) + try { + await Promise.all([ + prompt.setup.mockInput.pasteBracketedText(`'${file}'`), + prompt.setup.mockInput.pasteBracketedText(`'${file}'`), + ]) + await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 2) + + expect(prompt.input.plainText).toBe("[Image 1] [Image 2] ") + } finally { + await prompt.dispose() + await rm(directory, { recursive: true, force: true }) + } +}) + +test("continues queued paste work after a clipboard failure", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "opencode-recovery-")) + const file = path.join(directory, "image.png") + await writeFile(file, PNG_1X1) + const result = Promise.withResolvers() + const prompt = await mountPrompt(() => result.promise) + try { + prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) + await prompt.setup.waitFor(() => prompt.reads === 1) + await prompt.setup.mockInput.pasteBracketedText(`'${file}'`) + result.reject(new Error("clipboard failed")) + await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 1) + + expect(prompt.input.plainText).toBe("[Image 1] ") + } finally { + await prompt.dispose() + await rm(directory, { recursive: true, force: true }) + } +}) + +test("cancels delayed and queued pastes after a reversible prompt change", async () => { + const result = Promise.withResolvers() + const prompt = await mountPrompt(() => result.promise) + try { + prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) + await prompt.setup.waitFor(() => prompt.reads === 1) + await prompt.setup.mockInput.pasteBracketedText("queued") + prompt.setup.mockInput.pressKey("x") + await prompt.setup.waitFor(() => prompt.input.plainText === "x") + prompt.setup.mockInput.pressBackspace() + await prompt.setup.waitFor(() => prompt.input.plainText === "") + prompt.prompt.set({ text: "temporary", files: [], agents: [], pasted: [] }) + prompt.prompt.reset() + await prompt.setup.renderOnce() + await prompt.setup.mockInput.pasteBracketedText("new") + + result.resolve({ + status: "read", + representation: { mimeType: "text/plain", bytes: new TextEncoder().encode("/missing-one.png /missing-two.png") }, + }) + await prompt.setup.waitForFrame((frame) => frame.includes("Attachment paste canceled")) + + await prompt.setup.waitFor(() => prompt.input.plainText === "new") + expect(prompt.prompt.current.files).toEqual([]) + } finally { + await prompt.dispose() + } +}) + +test("does not insert a delayed image after entering shell mode", async () => { + const result = Promise.withResolvers() + const prompt = await mountPrompt(() => result.promise) + try { + prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) + await prompt.setup.waitFor(() => prompt.reads === 1) + prompt.setup.mockInput.pressKey("!") + await prompt.setup.waitForFrame((frame) => frame.includes("Shell")) + prompt.setup.mockInput.pressBackspace() + await prompt.setup.waitForFrame((frame) => !frame.includes("Shell")) + + result.resolve(await readPngClipboard()) + await prompt.setup.waitForFrame((frame) => frame.includes("Attachment paste canceled")) + + expect(prompt.input.plainText).toBe("") + expect(prompt.prompt.current.files).toEqual([]) + } finally { + await prompt.dispose() + } +}) + +test("ignores a clipboard read that completes after prompt teardown", async () => { + const result = Promise.withResolvers() + const prompt = await mountPrompt(() => result.promise) + + prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) + await prompt.setup.waitFor(() => prompt.reads === 1) + prompt.setup.renderer.destroy() + result.resolve(await readPngClipboard()) + await Bun.sleep(0) + await prompt.dispose() +}) + test("creates one image mention from PNG clipboard bytes", async () => { const prompt = await mountPrompt(readPngClipboard) try { @@ -217,19 +318,40 @@ test("renders at most three left-aligned cropped thumbnails", async () => { expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-3")).toBeUndefined() const frame = await prompt.setup.waitForFrame((frame) => frame.includes("+1 more")) expect(frame).toMatch(/^┃ █/m) + + await prompt.setup.mockMouse.click(49, 1, MouseButtons.LEFT) + await prompt.setup.waitForFrame((frame) => frame.includes("Image 4 of 4")) + prompt.setup.mockInput.pressCtrlC() + await prompt.setup.waitForFrame((frame) => !frame.includes("Image 4 of 4")) + await prompt.setup.mockMouse.click(50, 1, MouseButtons.LEFT) + await prompt.setup.renderOnce() + expect(prompt.setup.captureCharFrame()).not.toContain("Image 4 of 4") } finally { await prompt.dispose() } }) -test("opens the large image viewer by mouse and command palette", async () => { +test("opens thumbnails by their exact mouse bounds and from the command palette", async () => { const prompt = await mountPrompt(readPngClipboard, true) try { await pasteImages(prompt, 2) const thumbnail = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1") if (!(thumbnail instanceof ImageRenderable)) throw new Error("Second image thumbnail did not render") + await prompt.setup.mockMouse.click(14, 1, MouseButtons.LEFT) + + await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2")) + prompt.setup.mockInput.pressCtrlC() + await prompt.setup.waitForFrame((frame) => !frame.includes("Image 1 of 2")) + await prompt.setup.mockMouse.click(15, 1, MouseButtons.LEFT) + await prompt.setup.renderOnce() + expect(prompt.setup.captureCharFrame()).not.toContain("Image 2 of 2") + await prompt.setup.mockMouse.click(29, 1, MouseButtons.LEFT) + await prompt.setup.renderOnce() + expect(prompt.setup.captureCharFrame()).not.toContain("Image 2 of 2") + + await prompt.setup.mockMouse.click(16, 1, MouseButtons.LEFT) await prompt.setup.waitForFrame((frame) => frame.includes("Image 2 of 2")) const large = prompt.setup.renderer.root.findDescendantById("prompt-image-viewer-image") @@ -286,6 +408,33 @@ test("reduces the preview count to fit a narrow terminal", async () => { } }) +test("does not click a hidden overflow control", async () => { + const prompt = await mountPrompt(readPngClipboard, true, 22) + try { + await pasteImages(prompt, 2) + await prompt.setup.mockMouse.click(16, 1, MouseButtons.LEFT) + await prompt.setup.renderOnce() + + expect(prompt.setup.captureCharFrame()).not.toContain("Image 2 of 2") + } finally { + await prompt.dispose() + } +}) + +test("hides thumbnails when their minimum width does not fit", async () => { + const prompt = await mountPrompt(readPngClipboard, true, 16) + try { + await pasteImages(prompt, 1) + expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined() + + await prompt.setup.mockMouse.click(12, 1, MouseButtons.LEFT) + await prompt.setup.renderOnce() + expect(prompt.setup.captureCharFrame()).not.toContain("Image 1 of 1") + } finally { + await prompt.dispose() + } +}) + test("removes an image preview when its mention is deleted", async () => { const prompt = await mountPrompt(readPngClipboard, true) try {