From 4f9cb7a76b0ac198bcc9d46804730a51c8c5f2c3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 03:10:16 -0700 Subject: [PATCH 1/2] fix(web): terminal paste no longer lands twice in Chromium The paste shortcut races navigator.clipboard.readText() against the browser's native paste event. The token only covered the native event landing first. When Chromium routes the paste through its menu (installed web apps, Electron), the read resolves first and the native paste that follows was also sent. The read now records the text it sent on the shortcut's gesture, and onPaste drops the one matching native paste. The gesture ends on the shortcut key's keyup, the next shortcut, blur, or that paste, so a later Edit > Paste of the same text still lands. Design from r4iju's #13357 and the #13346 triage. Fixes #13346 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/terminal/ghostty/surface.test.ts | 44 +++++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 29 +++++++++--- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index b23513249455..a1677951a7c3 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -760,6 +760,50 @@ describe("GhosttyTerminalSurface visibility", () => { expect(readText).not.toHaveBeenCalled(); }); + it("pastes once per shortcut whichever of the clipboard read and native paste lands first", async () => { + const harness = createHarness(); + const readText = vi.fn(async () => "echo hi"); + vi.stubGlobal("navigator", { platform: "MacIntel", clipboard: { readText } }); + const surface = await harness.create({ beforeKey: () => true }); + const paste = (text: string) => + surface.input.dispatchEvent( + Object.assign(new Event("paste", { cancelable: true }), { + clipboardData: { getData: () => text }, + }), + ); + const settleRead = async (reads: number) => { + expect(readText).toHaveBeenCalledTimes(reads); + await readText.mock.results[reads - 1]?.value; + }; + + // Read first (Chromium menu paste): the native paste that follows is dropped. + key(surface, "v", "KeyV", { metaKey: true }); + await settleRead(1); + paste("echo hi"); + key(surface, "v", "KeyV", { metaKey: true }, "keyup"); + expect(harness.onData).toHaveBeenCalledTimes(1); + + // Native first: the read that settles afterwards is dropped. + key(surface, "v", "KeyV", { metaKey: true }); + paste("echo hi"); + await settleRead(2); + key(surface, "v", "KeyV", { metaKey: true }, "keyup"); + expect(harness.onData).toHaveBeenCalledTimes(2); + + // A shortcut served only by the read must not swallow a later Edit → Paste + // of the same text, after the keyup or after a blur. + key(surface, "v", "KeyV", { metaKey: true }); + await settleRead(3); + key(surface, "v", "KeyV", { metaKey: true }, "keyup"); + paste("echo hi"); + key(surface, "v", "KeyV", { metaKey: true }); + await settleRead(4); + surface.input.dispatchEvent(new Event("blur")); + paste("echo hi"); + expect(harness.onData).toHaveBeenCalledTimes(6); + expect(new Set(harness.onData.mock.calls.map(([data]) => data))).toEqual(new Set(["echo hi"])); + }); + it("starts a selection when dragging from a link", async () => { const harness = createHarness(); const onLinkActivate = vi.fn(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 76908556f0d1..1669d74439cf 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -627,6 +627,11 @@ export class GhosttyTerminalSurface { private theme: GhosttyTheme; private readonly suppressedKeyCodes = new Set(); private pasteShortcutToken = 0; + // The paste shortcut whose native paste event may still arrive, and the text + // its clipboard read already sent ("" until then). Chromium can deliver that + // event after the read resolves, but before the shortcut key's keyup, so the + // gesture ends on that keyup, the next shortcut, blur, or the matching paste. + private pasteShortcutGesture: { code: string; readText: string } | null = null; private copyShortcutToken = 0; private clearSelectionAfterCopy = false; private primedCopySelection = ""; @@ -1212,19 +1217,24 @@ export class GhosttyTerminalSurface { } if (isTerminalPasteShortcut(event)) { this.suppressedKeyCodes.add(event.code); + const gesture = { code: event.code, readText: "" }; + this.pasteShortcutGesture = gesture; const clipboard = navigator.clipboard; if (typeof clipboard?.readText === "function") { - // Race the async clipboard read against the browser's own paste event: - // the native event (dispatched synchronously with the default action) - // always claims the token first when it fires, and the read covers - // browsers whose paste shortcut produces no paste event. Not preventing - // the default keeps the native path alive when the read is denied. + // Race the async clipboard read against the browser's own paste event. + // The read covers browsers whose paste shortcut produces no paste + // event; not preventing the default keeps the native path alive when + // the read is denied. Whichever lands first wins: a native paste + // claims the token, and a read records its text on the gesture so + // onPaste drops the native paste that follows (Chromium menu paste). const token = ++this.pasteShortcutToken; void clipboard.readText().then( (text) => { if (this.disposed || this.pasteShortcutToken !== token) return; this.pasteShortcutToken += 1; - if (text.length > 0) this.options.onData(this.core.encodePaste(text)); + if (text.length === 0) return; + if (this.pasteShortcutGesture === gesture) gesture.readText = text; + this.options.onData(this.core.encodePaste(text)); }, () => { // Clipboard read denied; the native paste event remains the path. @@ -1243,6 +1253,7 @@ export class GhosttyTerminalSurface { }; private readonly onKeyUp = (event: KeyboardEvent) => { + if (event.code === this.pasteShortcutGesture?.code) this.pasteShortcutGesture = null; if (this.suppressedKeyCodes.delete(event.code)) return; if (isTerminalCompositionKey(event, this.composing)) { return; @@ -1264,6 +1275,7 @@ export class GhosttyTerminalSurface { private readonly onBlur = () => { this.focused = false; + this.pasteShortcutGesture = null; this.refreshHoveredLink(); // Suppressions survive blur deliberately: a shortcut that moves focus (for // example terminal-toggle) must still swallow its own keyup if focus comes @@ -1323,6 +1335,11 @@ export class GhosttyTerminalSurface { event.preventDefault(); const data = event.clipboardData?.getData("text/plain") ?? ""; if (data.length === 0) return; + if (data === this.pasteShortcutGesture?.readText) { + // The shortcut's clipboard read already sent this paste. + this.pasteShortcutGesture = null; + return; + } // The native paste won the race with actual text; a pending clipboard read // must not double. An empty native paste leaves the read as the only path. this.pasteShortcutToken += 1; From b4e75c1e63ae94083d6afcde77b95bb1987f7b50 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 02:28:16 +0000 Subject: [PATCH 2/2] fix(web): end terminal paste gestures on modifier release --- apps/web/src/terminal/ghostty/surface.test.ts | 79 ++++++++++--------- apps/web/src/terminal/ghostty/surface.ts | 24 +++--- 2 files changed, 55 insertions(+), 48 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 435083fd76c6..f967fcff23fd 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -777,50 +777,55 @@ describe("GhosttyTerminalSurface visibility", () => { expect(readText).not.toHaveBeenCalled(); }); - it("pastes once per shortcut whichever of the clipboard read and native paste lands first", async () => { + function paste(surface: GhosttyTerminalSurface, text: string) { + surface.input.dispatchEvent( + Object.assign(new Event("paste", { cancelable: true }), { + clipboardData: { getData: () => text }, + }), + ); + } + + it.each(["native", "clipboard"])("pastes once when %s arrives first", async (first) => { const harness = createHarness(); - const readText = vi.fn(async () => "echo hi"); - vi.stubGlobal("navigator", { platform: "MacIntel", clipboard: { readText } }); + const read = Promise.resolve("echo hi"); + vi.stubGlobal("navigator", { + platform: "MacIntel", + clipboard: { readText: () => read }, + }); const surface = await harness.create({ beforeKey: () => true }); - const paste = (text: string) => - surface.input.dispatchEvent( - Object.assign(new Event("paste", { cancelable: true }), { - clipboardData: { getData: () => text }, - }), - ); - const settleRead = async (reads: number) => { - expect(readText).toHaveBeenCalledTimes(reads); - await readText.mock.results[reads - 1]?.value; - }; - // Read first (Chromium menu paste): the native paste that follows is dropped. key(surface, "v", "KeyV", { metaKey: true }); - await settleRead(1); - paste("echo hi"); - key(surface, "v", "KeyV", { metaKey: true }, "keyup"); - expect(harness.onData).toHaveBeenCalledTimes(1); + if (first === "native") paste(surface, "echo hi"); + await read; + if (first === "clipboard") paste(surface, "echo hi"); - // Native first: the read that settles afterwards is dropped. - key(surface, "v", "KeyV", { metaKey: true }); - paste("echo hi"); - await settleRead(2); - key(surface, "v", "KeyV", { metaKey: true }, "keyup"); - expect(harness.onData).toHaveBeenCalledTimes(2); - - // A shortcut served only by the read must not swallow a later Edit → Paste - // of the same text, after the keyup or after a blur. - key(surface, "v", "KeyV", { metaKey: true }); - await settleRead(3); - key(surface, "v", "KeyV", { metaKey: true }, "keyup"); - paste("echo hi"); - key(surface, "v", "KeyV", { metaKey: true }); - await settleRead(4); - surface.input.dispatchEvent(new Event("blur")); - paste("echo hi"); - expect(harness.onData).toHaveBeenCalledTimes(6); - expect(new Set(harness.onData.mock.calls.map(([data]) => data))).toEqual(new Set(["echo hi"])); + expect(harness.onData.mock.calls).toEqual([["echo hi"]]); }); + it.each(["V keyup", "blur", "Meta keyup without V keyup"])( + "accepts a later identical menu paste after %s", + async (end) => { + const harness = createHarness(); + const read = Promise.resolve("echo hi"); + vi.stubGlobal("navigator", { + platform: "MacIntel", + clipboard: { readText: () => read }, + }); + const surface = await harness.create({ beforeKey: () => true }); + + key(surface, "v", "KeyV", { metaKey: true }); + await read; + expect(harness.onData.mock.calls).toEqual([["echo hi"]]); + if (end === "blur") surface.input.dispatchEvent(new Event("blur")); + else if (end === "V keyup") key(surface, "v", "KeyV", { metaKey: true }, "keyup"); + // macOS can omit V keyup while Command is held. + else key(surface, "Meta", "MetaLeft", {}, "keyup"); + paste(surface, "echo hi"); + + expect(harness.onData.mock.calls).toEqual([["echo hi"], ["echo hi"]]); + }, + ); + it("starts a selection when dragging from a link", async () => { const harness = createHarness(); const onLinkActivate = vi.fn(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index d538aec842c6..7ebf0932ad7b 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -580,10 +580,8 @@ export class GhosttyTerminalSurface { private theme: GhosttyTheme; private readonly suppressedKeyCodes = new Set(); private pasteShortcutToken = 0; - // The paste shortcut whose native paste event may still arrive, and the text - // its clipboard read already sent ("" until then). Chromium can deliver that - // event after the read resolves, but before the shortcut key's keyup, so the - // gesture ends on that keyup, the next shortcut, blur, or the matching paste. + // Chromium can deliver native paste after the clipboard read resolves. + // Suppress its duplicate only for the current shortcut gesture. private pasteShortcutGesture: { code: string; readText: string } | null = null; private copyShortcutToken = 0; private clearSelectionAfterCopy = false; @@ -1146,12 +1144,8 @@ export class GhosttyTerminalSurface { this.pasteShortcutGesture = gesture; const clipboard = navigator.clipboard; if (typeof clipboard?.readText === "function") { - // Race the async clipboard read against the browser's own paste event. - // The read covers browsers whose paste shortcut produces no paste - // event; not preventing the default keeps the native path alive when - // the read is denied. Whichever lands first wins: a native paste - // claims the token, and a read records its text on the gesture so - // onPaste drops the native paste that follows (Chromium menu paste). + // Keep native paste available if the read is denied. The token drops + // a late read; the gesture drops a native duplicate when the read wins. const token = ++this.pasteShortcutToken; void clipboard.readText().then( (text) => { @@ -1176,7 +1170,15 @@ export class GhosttyTerminalSurface { }; private readonly onKeyUp = (event: KeyboardEvent) => { - if (event.code === this.pasteShortcutGesture?.code) this.pasteShortcutGesture = null; + // macOS can omit V keyup while Command is held. + if ( + event.code === this.pasteShortcutGesture?.code || + event.key === "Meta" || + event.key === "Control" || + event.key === "Shift" + ) { + this.pasteShortcutGesture = null; + } if (this.suppressedKeyCodes.delete(event.code)) return; if (isTerminalCompositionKey(event, this.composing)) { return;