From 2c6000e94f8277aa38f1e4fb2d90acd36630318f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 10 Sep 2026 00:38:17 +0000 Subject: [PATCH 1/2] fix(web): middle-click pastes in the terminal on Linux The terminal canvas ignored the middle button: onPointerDown returned for any button but 0, so the standard Linux middle-click paste did nothing and Chromium started autoscroll instead. Middle-click now pastes through the same bracketed-paste path as every other paste. A browser cannot read the X11/Wayland PRIMARY buffer, so the terminal's own selection is the source when the user highlighted here and the system clipboard covers the rest. Applications that track the mouse still receive the click. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/terminal/ghostty/surface.test.ts | 36 ++++++++++++++- apps/web/src/terminal/ghostty/surface.ts | 44 ++++++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 7174261e67f6..9933c23257f0 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -167,13 +167,13 @@ describe("GhosttyTerminalSurface visibility", () => { resize() { for (const callback of resizeCallbacks) callback(); }, - pointer(type: string, clientX: number, buttons: number, shiftKey = false) { + pointer(type: string, clientX: number, buttons: number, shiftKey = false, button = 0) { canvas.dispatchEvent( Object.assign(new Event(type, { cancelable: true }), { clientX, clientY: 5, pointerId: 1, - button: 0, + button, buttons, shiftKey, }), @@ -280,6 +280,38 @@ describe("GhosttyTerminalSurface visibility", () => { expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false); }); + it("pastes the current selection on a Linux middle click", async () => { + const harness = createHarness(); + vi.stubGlobal("navigator", { platform: "Linux x86_64" }); + const surface = await harness.create(); + surface.write("hello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointermove", 37, 1); + harness.pointer("pointerup", 37, 0); + expect(surface.getSelection()).toBe("hello"); + + harness.onData.mockClear(); + harness.pointer("pointerdown", 5, 4, false, 1); + await vi.waitFor(() => expect(harness.onData).toHaveBeenCalled()); + expect(harness.onData.mock.calls.at(-1)?.[0]).toBe("hello"); + expect(surface.getSelection()).toBe("hello"); + }); + + it("forwards a middle click to an application that tracks the mouse", async () => { + const harness = createHarness(); + vi.stubGlobal("navigator", { platform: "Linux x86_64" }); + const surface = await harness.create(); + surface.write("\x1b[?1000hhello world"); + harness.flushFrame(); + + harness.onData.mockClear(); + harness.pointer("pointerdown", 5, 4, false, 1); + // Legacy X10 report for a middle-button press at 1,1: the application gets + // the click instead of a paste. + expect(harness.onData.mock.calls.at(-1)?.[0]).toBe("\x1b[M!!!"); + }); + 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 6a069902918a..13b9a32434b3 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -395,6 +395,15 @@ export function isTerminalPasteShortcut( return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; } +/** + * Middle-click paste is an X11/Wayland convention. macOS and Windows have no + * primary selection and use the button for autoscroll, so only desktops that + * expect the gesture get it. + */ +export function isTerminalMiddleClickPastePlatform(platform = navigator.platform): boolean { + return /linux|bsd|x11/i.test(platform); +} + export function isTerminalCompositionCommitInput(event: Pick): boolean { return ( event.inputType === "" || @@ -938,6 +947,26 @@ export class GhosttyTerminalSurface { if (encoded.length > 0) this.options.onData(encoded); } + /** + * The middle-click paste source. A browser cannot read the X11/Wayland + * PRIMARY buffer, so the terminal's own selection stands in for it when the + * user highlighted here, and the system clipboard covers the rest. Both go + * through pasteFromClipboard, so a middle click joins the same paste race as + * every other paste path. + */ + private pasteFromSelectionBuffer(): void { + const selection = this.getSelection(); + if (selection.length > 0) { + void this.pasteFromClipboard(() => Promise.resolve(selection)); + return; + } + const clipboard = navigator.clipboard; + if (typeof clipboard?.readText !== "function") return; + void this.pasteFromClipboard(() => clipboard.readText()).catch(() => { + // Clipboard read denied; middle-click has no other source to fall back to. + }); + } + hasSelection(): boolean { return this.core.selectionText().length > 0; } @@ -1272,6 +1301,14 @@ export class GhosttyTerminalSurface { this.canvas.setPointerCapture(event.pointerId); return; } + if (event.button === 1 && isTerminalMiddleClickPastePlatform()) { + // Cancelling the pointer event also suppresses the compatibility + // mousedown, and with it Chromium's middle-click autoscroll. + event.preventDefault(); + event.stopPropagation(); + this.pasteFromSelectionBuffer(); + return; + } if (event.button !== 0) return; const clickCount = this.recordSelectionClick(event); const link = this.linkAt(event.clientX, event.clientY); @@ -1551,7 +1588,12 @@ export class GhosttyTerminalSurface { }; private readonly onMouseDown = (event: MouseEvent) => { - if (event.button === 0) event.preventDefault(); + // onPointerDown answers the middle button; cancelling it here too keeps + // Chromium's autoscroll from starting in browsers that still deliver the + // compatibility mousedown after a cancelled pointerdown. + if (event.button === 0 || (event.button === 1 && isTerminalMiddleClickPastePlatform())) { + event.preventDefault(); + } this.focus(); }; From d3cf91135f051bcbd75b20af3597d81edfabdad2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 10 Sep 2026 00:49:02 +0000 Subject: [PATCH 2/2] fix(terminal): paste only the terminal selection on middle click Middle click no longer falls back to the system clipboard: with nothing selected in the terminal there is no primary-selection-like buffer to read, and CLIPBOARD holds text the user only ever copied. The gesture also stops cancelling pointerdown, which used to drop the compatibility mousedown that activates an inactive split pane, and now cancels the middle mouseup so Chromium cannot paste PRIMARY into the hidden textarea on top of the paste already sent. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/terminal/ghostty/surface.test.ts | 21 +++---- apps/web/src/terminal/ghostty/surface.ts | 60 ++++++++++--------- 2 files changed, 40 insertions(+), 41 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 9933c23257f0..59150ee320ae 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -280,9 +280,10 @@ describe("GhosttyTerminalSurface visibility", () => { expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false); }); - it("pastes the current selection on a Linux middle click", async () => { + it("pastes the terminal selection, and only that, on a Linux middle click", async () => { const harness = createHarness(); - vi.stubGlobal("navigator", { platform: "Linux x86_64" }); + const readText = vi.fn(async () => "clipboard text"); + vi.stubGlobal("navigator", { platform: "Linux x86_64", clipboard: { readText } }); const surface = await harness.create(); surface.write("hello world"); harness.flushFrame(); @@ -296,20 +297,12 @@ describe("GhosttyTerminalSurface visibility", () => { await vi.waitFor(() => expect(harness.onData).toHaveBeenCalled()); expect(harness.onData.mock.calls.at(-1)?.[0]).toBe("hello"); expect(surface.getSelection()).toBe("hello"); - }); - it("forwards a middle click to an application that tracks the mouse", async () => { - const harness = createHarness(); - vi.stubGlobal("navigator", { platform: "Linux x86_64" }); - const surface = await harness.create(); - surface.write("\x1b[?1000hhello world"); - harness.flushFrame(); - - harness.onData.mockClear(); + // Without a selection there is no primary buffer to paste; the clipboard + // holds what the user copied and must not be substituted. + surface.clearSelection(); harness.pointer("pointerdown", 5, 4, false, 1); - // Legacy X10 report for a middle-button press at 1,1: the application gets - // the click instead of a paste. - expect(harness.onData.mock.calls.at(-1)?.[0]).toBe("\x1b[M!!!"); + expect(readText).not.toHaveBeenCalled(); }); it("starts a selection when dragging from a link", async () => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 13b9a32434b3..be62ede4d065 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -400,8 +400,8 @@ export function isTerminalPasteShortcut( * primary selection and use the button for autoscroll, so only desktops that * expect the gesture get it. */ -export function isTerminalMiddleClickPastePlatform(platform = navigator.platform): boolean { - return /linux|bsd|x11/i.test(platform); +function isMiddleClickPastePlatform(): boolean { + return /linux|bsd/i.test(navigator.platform); } export function isTerminalCompositionCommitInput(event: Pick): boolean { @@ -948,23 +948,17 @@ export class GhosttyTerminalSurface { } /** - * The middle-click paste source. A browser cannot read the X11/Wayland - * PRIMARY buffer, so the terminal's own selection stands in for it when the - * user highlighted here, and the system clipboard covers the rest. Both go - * through pasteFromClipboard, so a middle click joins the same paste race as - * every other paste path. + * Middle-click pastes the terminal's own selection, which is the only + * primary-selection-like buffer a browser can read. It goes through + * pasteFromClipboard so it joins the same paste race as every other path. + * With nothing selected here there is no buffer to paste, and CLIPBOARD is + * deliberately not substituted: middle-click must never emit text the user + * only ever copied. */ - private pasteFromSelectionBuffer(): void { + private pasteTerminalSelection(): void { const selection = this.getSelection(); - if (selection.length > 0) { - void this.pasteFromClipboard(() => Promise.resolve(selection)); - return; - } - const clipboard = navigator.clipboard; - if (typeof clipboard?.readText !== "function") return; - void this.pasteFromClipboard(() => clipboard.readText()).catch(() => { - // Clipboard read denied; middle-click has no other source to fall back to. - }); + if (selection.length === 0) return; + void this.pasteFromClipboard(() => Promise.resolve(selection)); } hasSelection(): boolean { @@ -1301,12 +1295,10 @@ export class GhosttyTerminalSurface { this.canvas.setPointerCapture(event.pointerId); return; } - if (event.button === 1 && isTerminalMiddleClickPastePlatform()) { - // Cancelling the pointer event also suppresses the compatibility - // mousedown, and with it Chromium's middle-click autoscroll. - event.preventDefault(); - event.stopPropagation(); - this.pasteFromSelectionBuffer(); + if (event.button === 1 && isMiddleClickPastePlatform()) { + // Left uncancelled on purpose: cancelling pointerdown drops the + // compatibility mousedown, which is what activates a split pane. + this.pasteTerminalSelection(); return; } if (event.button !== 0) return; @@ -1552,6 +1544,10 @@ export class GhosttyTerminalSurface { if (this.canvas.hasPointerCapture(event.pointerId)) { this.canvas.releasePointerCapture(event.pointerId); } + if (event.button === 1 && isMiddleClickPastePlatform()) { + event.preventDefault(); + return; + } if (event.button !== 0) return; if (!this.selectionMoved && this.selectionMode === "cell") { this.clearSelection(); @@ -1588,15 +1584,23 @@ export class GhosttyTerminalSurface { }; private readonly onMouseDown = (event: MouseEvent) => { - // onPointerDown answers the middle button; cancelling it here too keeps - // Chromium's autoscroll from starting in browsers that still deliver the - // compatibility mousedown after a cancelled pointerdown. - if (event.button === 0 || (event.button === 1 && isTerminalMiddleClickPastePlatform())) { + // Cancelling the middle button here stops autoscroll while still letting + // the event bubble to the drawer handler that activates a split pane. + if (event.button === 0 || (event.button === 1 && isMiddleClickPastePlatform())) { event.preventDefault(); } this.focus(); }; + /** + * Chromium pastes PRIMARY into the focused editable on a middle mouseup, and + * the hidden textarea is focused, so leaving the default alive would deliver + * a second paste through onPaste on top of the one onPointerDown sent. + */ + private readonly onMouseUp = (event: MouseEvent) => { + if (event.button === 1 && isMiddleClickPastePlatform()) event.preventDefault(); + }; + private readonly onContextMenu = (event: MouseEvent) => { if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { event.preventDefault(); @@ -1686,6 +1690,7 @@ export class GhosttyTerminalSurface { this.canvas.addEventListener("pointercancel", this.onPointerUp); this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); this.canvas.addEventListener("mousedown", this.onMouseDown); + this.canvas.addEventListener("mouseup", this.onMouseUp); this.canvas.addEventListener("contextmenu", this.onContextMenu); this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown); this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove); @@ -1711,6 +1716,7 @@ export class GhosttyTerminalSurface { this.canvas.removeEventListener("pointercancel", this.onPointerUp); this.canvas.removeEventListener("wheel", this.onWheel); this.canvas.removeEventListener("mousedown", this.onMouseDown); + this.canvas.removeEventListener("mouseup", this.onMouseUp); this.canvas.removeEventListener("contextmenu", this.onContextMenu); this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown); this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove);