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.
This commit is contained in:
Simon Klee
2026-07-30 15:16:34 +02:00
parent a89944497c
commit bbb7298d90
3 changed files with 272 additions and 55 deletions
+120 -53
View File
@@ -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<typeof capturePrompt>) {
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<typeof capturePrompt>) {
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<typeof capturePrompt>) => Promise<void>) {
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<typeof capturePrompt>) {
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)
}}
>
<Show when={config.prompt?.image_preview && imageAttachments().length > 0}>
<Show when={config.prompt?.image_preview && imagePreviewsVisible()}>
<box
width="100%"
height={imagePreviewHeight() + 2}
@@ -1454,7 +1517,7 @@ export function Prompt(props: PromptProps) {
gap={1}
paddingBottom={1}
>
<For each={imageAttachments().slice(0, imagePreviewLimit())}>
<For each={imageAttachments().slice(0, visibleImageCount())}>
{(file, index) => {
const [failed, setFailed] = createSignal(false)
return (
@@ -1481,7 +1544,7 @@ export function Prompt(props: PromptProps) {
)
}}
</For>
<Show when={hiddenImageAttachmentCount() > 0 && dimensions().width >= 22}>
<Show when={imageOverflowVisible()}>
<box
width={8}
height={imagePreviewHeight()}
@@ -1506,13 +1569,17 @@ export function Prompt(props: PromptProps) {
minHeight={1}
maxHeight={maxHeight()}
onContentChange={() => {
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
@@ -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<string, string> = {
}
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")
+150 -1
View File
@@ -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<ClipboardReadResult>()
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<ClipboardReadResult>()
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<ClipboardReadResult>()
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<ClipboardReadResult>()
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 {