From 4cfca7ab68293f8757f944f0c3953f91f1e03b0b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:39:27 +0800 Subject: [PATCH] fix(app): restore terminal application mouse handling (#45140) Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Hona <10430890+Hona@users.noreply.github.com> Co-authored-by: Brendan Allan --- bun.lock | 1 + package.json | 1 + .../session/terminal/terminal-mouse.test.ts | 74 +++++++ packages/client/test/shared-events.test.ts | 22 ++ patches/ghostty-web@0.3.0.patch | 193 ++++++++++++++++++ 5 files changed, 291 insertions(+) create mode 100644 packages/app/src/session/terminal/terminal-mouse.test.ts create mode 100644 patches/ghostty-web@0.3.0.patch diff --git a/bun.lock b/bun.lock index 5dfcc06453c..7cc4d581144 100644 --- a/bun.lock +++ b/bun.lock @@ -1046,6 +1046,7 @@ "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", + "ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", diff --git a/package.json b/package.json index d7aa88efd63..0b4b63dfa30 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,7 @@ "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch", "@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch", + "ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch", "vite@8.2.2": "patches/vite@8.2.2.patch" } } diff --git a/packages/app/src/session/terminal/terminal-mouse.test.ts b/packages/app/src/session/terminal/terminal-mouse.test.ts new file mode 100644 index 00000000000..529a4229797 --- /dev/null +++ b/packages/app/src/session/terminal/terminal-mouse.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { Ghostty, Terminal } from "ghostty-web" + +describe("terminal application mouse reporting", () => { + let container: HTMLDivElement + let terminal: Terminal + let canvas: HTMLCanvasElement + let data: string[] + + beforeEach(async () => { + container = document.createElement("div") + document.body.appendChild(container) + terminal = new Terminal({ cols: 80, rows: 24, ghostty: await Ghostty.load() }) + terminal.open(container) + terminal.write("\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1015h\x1b[?1006h") + canvas = container.querySelector("canvas")! + data = [] + terminal.onData((value) => data.push(value)) + }) + + afterEach(() => { + terminal.dispose() + container.remove() + }) + + const wheel = (deltaX: number, deltaY: number) => { + const event = new WheelEvent("wheel", { deltaX, deltaY, bubbles: true }) + Object.defineProperties(event, { clientX: { value: 40 }, clientY: { value: 30 } }) + canvas.dispatchEvent(event) + } + + test("reports wheel input with its terminal coordinates instead of arrow keys", () => { + wheel(0, -100) + + expect(data).toEqual(["\x1b[<64;6;3M"]) + }) + + test("does not discard small trackpad deltas", () => { + wheel(0, -1) + + expect(data).toEqual(["\x1b[<64;6;3M"]) + }) + + test("reports horizontal scrolling", () => { + wheel(100, 0) + + expect(data).toEqual(["\x1b[<67;6;3M"]) + }) + + test("distinguishes pointer movement from a left-button drag", () => { + canvas.dispatchEvent(new MouseEvent("mousemove", { clientX: 40, clientY: 30, bubbles: true })) + + expect(data).toEqual(["\x1b[<35;6;3M"]) + }) + + test("encodes Alt-modified clicks", () => { + canvas.dispatchEvent(new MouseEvent("mousedown", { altKey: true, clientX: 40, clientY: 30, bubbles: true })) + + expect(data).toEqual(["\x1b[<8;6;3M"]) + }) + + test("leaves application clicks available for process selection", () => { + canvas.dispatchEvent(new MouseEvent("mousedown", { clientX: 40, clientY: 30, bubbles: true })) + canvas.dispatchEvent(new MouseEvent("mouseup", { clientX: 40, clientY: 30, bubbles: true })) + + expect(data).toEqual(["\x1b[<0;6;3M", "\x1b[<0;6;3m"]) + }) + + test("preserves Shift as the terminal selection override", () => { + canvas.dispatchEvent(new MouseEvent("mousedown", { shiftKey: true, clientX: 40, clientY: 30, bubbles: true })) + + expect(data).toEqual([]) + }) +}) diff --git a/packages/client/test/shared-events.test.ts b/packages/client/test/shared-events.test.ts index 5c760129ecf..bd99ca853f4 100644 --- a/packages/client/test/shared-events.test.ts +++ b/packages/client/test/shared-events.test.ts @@ -119,6 +119,28 @@ test("multiple consumers share one source and receive live native and RPC events await events.connections[0].closed }) +test("a slow consumer does not stall other subscribers", async () => { + const events = source() + const shared = SharedEvents.make(events.connect) + const slow = shared.subscribe()[Symbol.asyncIterator]() + const fast = shared.subscribe()[Symbol.asyncIterator]() + + const connected = [slow.next(), fast.next()] + events.connections[0].push({ type: "server.connected" }) + await Promise.all(connected) + + events.connections[0].push({ type: "permission.asked", value: 1 }) + expect(await fast.next()).toEqual({ done: false, value: { type: "permission.asked", value: 1 } }) + events.connections[0].push({ type: "session.updated", value: 2 }) + expect(await fast.next()).toEqual({ done: false, value: { type: "session.updated", value: 2 } }) + + expect(await slow.next()).toEqual({ done: false, value: { type: "permission.asked", value: 1 } }) + expect(await slow.next()).toEqual({ done: false, value: { type: "session.updated", value: 2 } }) + await slow.return!() + await fast.return!() + await events.connections[0].closed +}) + test("an idle consumer does not stall events for an active consumer", async () => { const events = source() const shared = SharedEvents.make(events.connect) diff --git a/patches/ghostty-web@0.3.0.patch b/patches/ghostty-web@0.3.0.patch new file mode 100644 index 00000000000..a477518492a --- /dev/null +++ b/patches/ghostty-web@0.3.0.patch @@ -0,0 +1,193 @@ +--- a/dist/ghostty-web.js ++++ b/dist/ghostty-web.js +@@ -1209,7 +1209,7 @@ + */ + getMouseModifiers(A) { + let g = 0; +- return A.shiftKey && (g |= 4), A.metaKey && (g |= 8), A.ctrlKey && (g |= 16), g; ++ return A.shiftKey && (g |= 4), A.altKey && (g |= 8), A.ctrlKey && (g |= 16), g; + } + /** + * Encode mouse event as SGR sequence +@@ -1246,7 +1246,7 @@ + */ + handleMouseDown(A) { + var C; +- if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking())) ++ if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking()) || A.shiftKey) + return; + const g = this.pixelToCell(A); + if (!g) +@@ -1259,7 +1259,7 @@ + */ + handleMouseUp(A) { + var C; +- if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking())) ++ if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking()) || A.shiftKey) + return; + const g = this.pixelToCell(A); + if (!g) +@@ -1272,7 +1272,7 @@ + */ + handleMouseMove(A) { + var E, D, i; +- if (this.isDisposed || !((E = this.mouseConfig) != null && E.hasMouseTracking())) ++ if (this.isDisposed || !((E = this.mouseConfig) != null && E.hasMouseTracking()) || A.shiftKey) + return; + const g = ((D = this.getModeCallback) == null ? void 0 : D.call(this, 1002)) ?? !1, I = ((i = this.getModeCallback) == null ? void 0 : i.call(this, 1003)) ?? !1; + if (!g && !I || g && !I && this.mouseButtonsPressed === 0) +@@ -1280,21 +1280,21 @@ + const C = this.pixelToCell(A); + if (!C) + return; +- let Q = 32; +- this.mouseButtonsPressed & 1 ? Q += 0 : this.mouseButtonsPressed & 2 ? Q += 1 : this.mouseButtonsPressed & 4 && (Q += 2), this.sendMouseEvent(Q, C.col, C.row, !1, A); ++ let Q = 35; ++ this.mouseButtonsPressed & 1 ? Q = 32 : this.mouseButtonsPressed & 2 ? Q = 33 : this.mouseButtonsPressed & 4 && (Q = 34), this.sendMouseEvent(Q, C.col, C.row, !1, A); + } + /** + * Handle wheel event (scroll) + */ + handleWheel(A) { +- var C; +- if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking())) ++ var Q; ++ if (this.isDisposed || !((Q = this.mouseConfig) != null && Q.hasMouseTracking()) || A.shiftKey) + return; + const g = this.pixelToCell(A); +- if (!g) ++ if (!g || A.deltaX === 0 && A.deltaY === 0) + return; +- const I = A.deltaY < 0 ? 64 : 65; +- this.sendMouseEvent(I, g.col, g.row, !1, A), A.preventDefault(); ++ const C = Math.abs(A.deltaX) > Math.abs(A.deltaY) ? A.deltaX < 0 ? 66 : 67 : A.deltaY < 0 ? 64 : 65; ++ this.sendMouseEvent(C, g.col, g.row, !1, A), A.preventDefault(); + } + /** + * Emit paste data with bracketed paste support +@@ -2353,7 +2353,7 @@ + attachEventListeners() { + const A = this.renderer.getCanvas(); + A.addEventListener("mousedown", (g) => { +- if (g.button === 0) { ++ if (!(this.wasmTerm.hasMouseTracking() && !g.shiftKey) && g.button === 0) { + A.parentElement && A.parentElement.focus(); + const I = this.pixelToCell(g.offsetX, g.offsetY); + this.hasSelection() && this.clearSelection(); +@@ -2408,35 +2408,37 @@ + } + } + }, document.addEventListener("mouseup", this.boundMouseUpHandler), A.addEventListener("click", (g) => { +- if (g.detail === 2) { +- const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.getWordAtCell(I.col, I.row); +- if (C) { +- const Q = this.viewportRowToAbsolute(I.row); +- this.selectionStart = { col: C.startCol, absoluteRow: Q }, this.selectionEnd = { col: C.endCol, absoluteRow: Q }, this.requestRender(); +- const E = this.getSelection(); +- E && (this.copyToClipboard(E), this.selectionChangedEmitter.fire()); +- } +- } else if (g.detail >= 3) { +- const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.viewportRowToAbsolute(I.row), Q = this.wasmTerm.getScrollbackLength(); +- let E = null; +- if (C < Q) +- E = this.wasmTerm.getScrollbackLine(C); +- else { +- const i = C - Q; +- E = this.wasmTerm.getLine(i); +- } +- let D = -1; +- if (E) { +- for (let i = E.length - 1; i >= 0; i--) +- if (E[i] && E[i].codepoint !== 0 && E[i].codepoint !== 32) { +- D = i; +- break; +- } +- } +- if (D >= 0) { +- this.selectionStart = { col: 0, absoluteRow: C }, this.selectionEnd = { col: D, absoluteRow: C }, this.requestRender(); +- const i = this.getSelection(); +- i && (this.copyToClipboard(i), this.selectionChangedEmitter.fire()); ++ if (!(this.wasmTerm.hasMouseTracking() && !g.shiftKey)) { ++ if (g.detail === 2) { ++ const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.getWordAtCell(I.col, I.row); ++ if (C) { ++ const Q = this.viewportRowToAbsolute(I.row); ++ this.selectionStart = { col: C.startCol, absoluteRow: Q }, this.selectionEnd = { col: C.endCol, absoluteRow: Q }, this.requestRender(); ++ const E = this.getSelection(); ++ E && (this.copyToClipboard(E), this.selectionChangedEmitter.fire()); ++ } ++ } else if (g.detail >= 3) { ++ const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.viewportRowToAbsolute(I.row), Q = this.wasmTerm.getScrollbackLength(); ++ let E = null; ++ if (C < Q) ++ E = this.wasmTerm.getScrollbackLine(C); ++ else { ++ const i = C - Q; ++ E = this.wasmTerm.getLine(i); ++ } ++ let D = -1; ++ if (E) { ++ for (let i = E.length - 1; i >= 0; i--) ++ if (E[i] && E[i].codepoint !== 0 && E[i].codepoint !== 32) { ++ D = i; ++ break; ++ } ++ } ++ if (D >= 0) { ++ this.selectionStart = { col: 0, absoluteRow: C }, this.selectionEnd = { col: D, absoluteRow: C }, this.requestRender(); ++ const i = this.getSelection(); ++ i && (this.copyToClipboard(i), this.selectionChangedEmitter.fire()); ++ } + } + } + }), this.boundContextMenuHandler = (g) => { +@@ -2676,23 +2678,27 @@ + const M = await this.linkDetector.getLinkAt(Q, o); + M && (M.activate(I), (I.ctrlKey || I.metaKey) && I.preventDefault()); + }, this.handleWheel = (I) => { +- var Q, E, D; +- if (I.preventDefault(), I.stopPropagation(), this.customWheelEventHandler && this.customWheelEventHandler(I)) ++ var Q, E, D, i; ++ if (I.preventDefault(), this.customWheelEventHandler && this.customWheelEventHandler(I)) { ++ I.stopPropagation(); ++ return; ++ } ++ if ((Q = this.wasmTerm) != null && Q.hasMouseTracking() && !I.shiftKey) + return; +- if (((Q = this.wasmTerm) == null ? void 0 : Q.isAlternateScreen()) ?? !1) { +- const i = I.deltaY > 0 ? "down" : "up", o = Math.min(Math.abs(Math.round(I.deltaY / 33)), 5); +- for (let s = 0; s < o; s++) +- i === "up" ? this.dataEmitter.fire("\x1B[A") : this.dataEmitter.fire("\x1B[B"); ++ if (I.stopPropagation(), ((E = this.wasmTerm) == null ? void 0 : E.isAlternateScreen()) ?? !1) { ++ const o = I.deltaY > 0 ? "down" : "up", s = Math.min(Math.abs(Math.round(I.deltaY / 33)), 5); ++ for (let G = 0; G < s; G++) ++ o === "up" ? this.dataEmitter.fire("\x1B[A") : this.dataEmitter.fire("\x1B[B"); + } else { +- let i; ++ let o; + if (I.deltaMode === WheelEvent.DOM_DELTA_PIXEL) { +- const o = ((D = (E = this.renderer) == null ? void 0 : E.getMetrics()) == null ? void 0 : D.height) ?? 20; +- i = I.deltaY / o; ++ const s = ((i = (D = this.renderer) == null ? void 0 : D.getMetrics()) == null ? void 0 : i.height) ?? 20; ++ o = I.deltaY / s; + } else +- I.deltaMode === WheelEvent.DOM_DELTA_LINE ? i = I.deltaY : I.deltaMode === WheelEvent.DOM_DELTA_PAGE ? i = I.deltaY * this.rows : i = I.deltaY / 33; +- if (i !== 0) { +- const o = this.viewportY - i; +- this.smoothScrollTo(o); ++ I.deltaMode === WheelEvent.DOM_DELTA_LINE ? o = I.deltaY : I.deltaMode === WheelEvent.DOM_DELTA_PAGE ? o = I.deltaY * this.rows : o = I.deltaY / 33; ++ if (o !== 0) { ++ const s = this.viewportY - o; ++ this.smoothScrollTo(s); + } + } + }, this.handleMouseDown = (I) => { +@@ -3254,7 +3260,7 @@ + * Clean up components (called on dispose or error) + */ + cleanupComponents() { +- this.selectionManager && (this.selectionManager.dispose(), this.selectionManager = void 0), this.inputHandler && (this.inputHandler.dispose(), this.inputHandler = void 0), this.renderer && (this.renderer.dispose(), this.renderer = void 0), this.canvas && this.canvas.parentNode && (this.canvas.parentNode.removeChild(this.canvas), this.canvas = void 0), this.textarea && this.textarea.parentNode && (this.textarea.parentNode.removeChild(this.textarea), this.textarea = void 0), this.element && (this.element.removeEventListener("wheel", this.handleWheel), this.element.removeEventListener("mousedown", this.handleMouseDown, { capture: !0 }), this.element.removeEventListener("mousemove", this.handleMouseMove), this.element.removeEventListener("mouseleave", this.handleMouseLeave), this.element.removeEventListener("click", this.handleClick), this.element.removeAttribute("contenteditable"), this.element.removeAttribute("role"), this.element.removeAttribute("aria-label"), this.element.removeAttribute("aria-multiline")), this.isOpen && typeof document < "u" && document.removeEventListener("mouseup", this.handleMouseUp), this.scrollbarHideTimeout && (window.clearTimeout(this.scrollbarHideTimeout), this.scrollbarHideTimeout = void 0), this.linkDetector && (this.linkDetector.dispose(), this.linkDetector = void 0), this.wasmTerm && (this.wasmTerm.free(), this.wasmTerm = void 0), this.ghostty = void 0, this.element = void 0, this.textarea = void 0; ++ this.selectionManager && (this.selectionManager.dispose(), this.selectionManager = void 0), this.inputHandler && (this.inputHandler.dispose(), this.inputHandler = void 0), this.renderer && (this.renderer.dispose(), this.renderer = void 0), this.canvas && this.canvas.parentNode && (this.canvas.parentNode.removeChild(this.canvas), this.canvas = void 0), this.textarea && this.textarea.parentNode && (this.textarea.parentNode.removeChild(this.textarea), this.textarea = void 0), this.element && (this.element.removeEventListener("wheel", this.handleWheel, { capture: !0 }), this.element.removeEventListener("mousedown", this.handleMouseDown, { capture: !0 }), this.element.removeEventListener("mousemove", this.handleMouseMove), this.element.removeEventListener("mouseleave", this.handleMouseLeave), this.element.removeEventListener("click", this.handleClick), this.element.removeAttribute("contenteditable"), this.element.removeAttribute("role"), this.element.removeAttribute("aria-label"), this.element.removeAttribute("aria-multiline")), this.isOpen && typeof document < "u" && document.removeEventListener("mouseup", this.handleMouseUp), this.scrollbarHideTimeout && (window.clearTimeout(this.scrollbarHideTimeout), this.scrollbarHideTimeout = void 0), this.linkDetector && (this.linkDetector.dispose(), this.linkDetector = void 0), this.wasmTerm && (this.wasmTerm.free(), this.wasmTerm = void 0), this.ghostty = void 0, this.element = void 0, this.textarea = void 0; + } + /** + * Assert terminal is open (throw if not)