From 9e621726a90f7fc6fa2efaf992f8d3dbf61ac0f8 Mon Sep 17 00:00:00 2001 From: neriousy Date: Sun, 23 Aug 2026 15:18:48 +0000 Subject: [PATCH] fix(tui): coalesce rapid selection copies --- packages/tui/src/app.tsx | 12 ++++---- packages/tui/src/util/selection.ts | 27 +++++++++++++++++ packages/tui/test/selection.test.ts | 45 +++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 packages/tui/test/selection.test.ts diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 64f3293e71e..1448351f9cd 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -571,15 +571,13 @@ function App(props: { pair?: DialogPairCredentials }) { offSelectionKeys() }) - // Wire up console copy-to-clipboard via opentui's onCopySelection callback - renderer.console.onCopySelection = async (text: string) => { + // Windows clipboard mutations are serialized by OpenTUI. Coalesce rapid selections here so + // they cannot fill its native operation queue and starve renderer polling. + const copySelection = Selection.createSelectionCopy(clipboard, toast) + renderer.console.onCopySelection = (text: string) => { if (!text || text.length === 0) return - await clipboard - .write(text) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) - .catch(toast.error) - + copySelection(text) renderer.clearSelection() } const terminalTitleEnabled = () => config.data.terminal?.title ?? true diff --git a/packages/tui/src/util/selection.ts b/packages/tui/src/util/selection.ts index 243d9b88feb..6d0c68f82dd 100644 --- a/packages/tui/src/util/selection.ts +++ b/packages/tui/src/util/selection.ts @@ -23,6 +23,33 @@ type SelectionKeyEvent = { stopPropagation: () => void } +export function createSelectionCopy(clipboard: ClipboardService, toast: Toast) { + let pending: string | undefined + let running = false + + const drain = async () => { + while (pending !== undefined) { + const text = pending + pending = undefined + const error = await clipboard.write(text).then( + () => undefined, + (error) => error, + ) + if (pending !== undefined) continue + if (error !== undefined) toast.error(error) + else toast.show({ message: "Copied to clipboard", variant: "info" }) + } + running = false + } + + return (text: string) => { + pending = text + if (running) return + running = true + void drain() + } +} + export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean { const selection = renderer.getSelection() if (!selection) return false diff --git a/packages/tui/test/selection.test.ts b/packages/tui/test/selection.test.ts new file mode 100644 index 00000000000..003a188974b --- /dev/null +++ b/packages/tui/test/selection.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "bun:test" +import { createSelectionCopy } from "../src/util/selection" + +test("keeps one clipboard write active and coalesces rapid selections", async () => { + const writes: string[] = [] + const releases: (() => void)[] = [] + let active = 0 + let maximumActive = 0 + const clipboard = { + async read() { + return undefined + }, + async write(text: string) { + writes.push(text) + active++ + maximumActive = Math.max(maximumActive, active) + await new Promise((resolve) => releases.push(resolve)) + active-- + }, + } + const copied: string[] = [] + const failed: unknown[] = [] + const copy = createSelectionCopy(clipboard, { + show: (input) => copied.push(input.message), + error: (error) => failed.push(error), + }) + + copy("first") + for (let index = 0; index < 100; index++) copy(`selection-${index}`) + + expect(writes).toEqual(["first"]) + expect(maximumActive).toBe(1) + + releases.shift()?.() + await Bun.sleep(0) + + expect(writes).toEqual(["first", "selection-99"]) + expect(maximumActive).toBe(1) + + releases.shift()?.() + await Bun.sleep(0) + + expect(copied).toEqual(["Copied to clipboard"]) + expect(failed).toEqual([]) +})