authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-09 23:25:56-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-09 23:25:56-03:00
logd1eeb16247a0bd2eca8bbbfa5ab777e096af949c
tree930a57e4dcad0c36431fcefa0b0f8cdd8bea6ed0
parent385cc0a4c669aeae786ad47bfe3ecbbc352eff24
signature Signed by PGP key B5690EEEBB952194

fix(web): middle-click pastes in the terminal on Linux (#11018)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

2 files changed, 76 insertions(+), 3 deletions(-)

apps/web/src/terminal/ghostty/surface.test.ts+27-2
......@@ -167,13 +167,13 @@ describe("GhosttyTerminalSurface visibility", () => {
167167 resize() {
168168 for (const callback of resizeCallbacks) callback();
169169 },
170 pointer(type: string, clientX: number, buttons: number, shiftKey = false) {
170 pointer(type: string, clientX: number, buttons: number, shiftKey = false, button = 0) {
171171 canvas.dispatchEvent(
172172 Object.assign(new Event(type, { cancelable: true }), {
173173 clientX,
174174 clientY: 5,
175175 pointerId: 1,
176 button: 0,
176 button,
177177 buttons,
178178 shiftKey,
179179 }),
......@@ -280,6 +280,31 @@ describe("GhosttyTerminalSurface visibility", () => {
280280 expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false);
281281 });
282282
283 it("pastes the terminal selection, and only that, on a Linux middle click", async () => {
284 const harness = createHarness();
285 const readText = vi.fn(async () => "clipboard text");
286 vi.stubGlobal("navigator", { platform: "Linux x86_64", clipboard: { readText } });
287 const surface = await harness.create();
288 surface.write("hello world");
289 harness.flushFrame();
290 harness.pointer("pointerdown", 5, 1);
291 harness.pointer("pointermove", 37, 1);
292 harness.pointer("pointerup", 37, 0);
293 expect(surface.getSelection()).toBe("hello");
294
295 harness.onData.mockClear();
296 harness.pointer("pointerdown", 5, 4, false, 1);
297 await vi.waitFor(() => expect(harness.onData).toHaveBeenCalled());
298 expect(harness.onData.mock.calls.at(-1)?.[0]).toBe("hello");
299 expect(surface.getSelection()).toBe("hello");
300
301 // Without a selection there is no primary buffer to paste; the clipboard
302 // holds what the user copied and must not be substituted.
303 surface.clearSelection();
304 harness.pointer("pointerdown", 5, 4, false, 1);
305 expect(readText).not.toHaveBeenCalled();
306 });
307
283308 it("starts a selection when dragging from a link", async () => {
284309 const harness = createHarness();
285310 const onLinkActivate = vi.fn();
apps/web/src/terminal/ghostty/surface.ts+49-1
......@@ -395,6 +395,15 @@ export function isTerminalPasteShortcut(
395395 return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey;
396396}
397397
398/**
399 * Middle-click paste is an X11/Wayland convention. macOS and Windows have no
400 * primary selection and use the button for autoscroll, so only desktops that
401 * expect the gesture get it.
402 */
403function isMiddleClickPastePlatform(): boolean {
404 return /linux|bsd/i.test(navigator.platform);
405}
406
398407export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputType">): boolean {
399408 return (
400409 event.inputType === "" ||
......@@ -938,6 +947,20 @@ export class GhosttyTerminalSurface {
938947 if (encoded.length > 0) this.options.onData(encoded);
939948 }
940949
950 /**
951 * Middle-click pastes the terminal's own selection, which is the only
952 * primary-selection-like buffer a browser can read. It goes through
953 * pasteFromClipboard so it joins the same paste race as every other path.
954 * With nothing selected here there is no buffer to paste, and CLIPBOARD is
955 * deliberately not substituted: middle-click must never emit text the user
956 * only ever copied.
957 */
958 private pasteTerminalSelection(): void {
959 const selection = this.getSelection();
960 if (selection.length === 0) return;
961 void this.pasteFromClipboard(() => Promise.resolve(selection));
962 }
963
941964 hasSelection(): boolean {
942965 return this.core.selectionText().length > 0;
943966 }
......@@ -1272,6 +1295,12 @@ export class GhosttyTerminalSurface {
12721295 this.canvas.setPointerCapture(event.pointerId);
12731296 return;
12741297 }
1298 if (event.button === 1 && isMiddleClickPastePlatform()) {
1299 // Left uncancelled on purpose: cancelling pointerdown drops the
1300 // compatibility mousedown, which is what activates a split pane.
1301 this.pasteTerminalSelection();
1302 return;
1303 }
12751304 if (event.button !== 0) return;
12761305 const clickCount = this.recordSelectionClick(event);
12771306 const link = this.linkAt(event.clientX, event.clientY);
......@@ -1515,6 +1544,10 @@ export class GhosttyTerminalSurface {
15151544 if (this.canvas.hasPointerCapture(event.pointerId)) {
15161545 this.canvas.releasePointerCapture(event.pointerId);
15171546 }
1547 if (event.button === 1 && isMiddleClickPastePlatform()) {
1548 event.preventDefault();
1549 return;
1550 }
15181551 if (event.button !== 0) return;
15191552 if (!this.selectionMoved && this.selectionMode === "cell") {
15201553 this.clearSelection();
......@@ -1551,10 +1584,23 @@ export class GhosttyTerminalSurface {
15511584 };
15521585
15531586 private readonly onMouseDown = (event: MouseEvent) => {
1554 if (event.button === 0) event.preventDefault();
1587 // Cancelling the middle button here stops autoscroll while still letting
1588 // the event bubble to the drawer handler that activates a split pane.
1589 if (event.button === 0 || (event.button === 1 && isMiddleClickPastePlatform())) {
1590 event.preventDefault();
1591 }
15551592 this.focus();
15561593 };
15571594
1595 /**
1596 * Chromium pastes PRIMARY into the focused editable on a middle mouseup, and
1597 * the hidden textarea is focused, so leaving the default alive would deliver
1598 * a second paste through onPaste on top of the one onPointerDown sent.
1599 */
1600 private readonly onMouseUp = (event: MouseEvent) => {
1601 if (event.button === 1 && isMiddleClickPastePlatform()) event.preventDefault();
1602 };
1603
15581604 private readonly onContextMenu = (event: MouseEvent) => {
15591605 if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) {
15601606 event.preventDefault();
......@@ -1644,6 +1690,7 @@ export class GhosttyTerminalSurface {
16441690 this.canvas.addEventListener("pointercancel", this.onPointerUp);
16451691 this.canvas.addEventListener("wheel", this.onWheel, { passive: false });
16461692 this.canvas.addEventListener("mousedown", this.onMouseDown);
1693 this.canvas.addEventListener("mouseup", this.onMouseUp);
16471694 this.canvas.addEventListener("contextmenu", this.onContextMenu);
16481695 this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown);
16491696 this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove);
......@@ -1669,6 +1716,7 @@ export class GhosttyTerminalSurface {
16691716 this.canvas.removeEventListener("pointercancel", this.onPointerUp);
16701717 this.canvas.removeEventListener("wheel", this.onWheel);
16711718 this.canvas.removeEventListener("mousedown", this.onMouseDown);
1719 this.canvas.removeEventListener("mouseup", this.onMouseUp);
16721720 this.canvas.removeEventListener("contextmenu", this.onContextMenu);
16731721 this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown);
16741722 this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove);