From 5bcb285fca7b22eb7bb82dc3b97aec86a9cd2dc7 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:26:45 +0000 Subject: [PATCH 1/2] perf(web): dedupe terminal mouse motion reports Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/terminal/ghostty/surface.test.ts | 17 ++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 26 ++++++++++++++----- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 1102d1b0bada..80a23dfc9a1f 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -16,6 +16,7 @@ import { isTerminalPasteShortcut, loadTerminalFontFamily, primeTerminalCopyInput, + resolveTerminalMouseData, shouldBlinkTerminalCursor, shouldReportTerminalMouse, shouldShowTerminalLinkHover, @@ -402,6 +403,22 @@ describe("application mouse reporting", () => { expect([0, 1, 2, 3, 4, 5].map(ghosttyMouseButton)).toEqual([1, 3, 2, 4, 5, null]); }); + it("drops repeated motion reports until another mouse action resets the cell", () => { + const first = resolveTerminalMouseData("motion", "\u001b[<35;8;4M", ""); + expect(first).toEqual({ send: true, nextMotionData: "\u001b[<35;8;4M" }); + + const duplicate = resolveTerminalMouseData("motion", "\u001b[<35;8;4M", first.nextMotionData); + expect(duplicate).toEqual({ send: false, nextMotionData: "\u001b[<35;8;4M" }); + + const press = resolveTerminalMouseData("press", "\u001b[<0;8;4M", duplicate.nextMotionData); + expect(press).toEqual({ send: true, nextMotionData: "" }); + + expect(resolveTerminalMouseData("motion", "\u001b[<35;8;4M", press.nextMotionData)).toEqual({ + send: true, + nextMotionData: "\u001b[<35;8;4M", + }); + }); + it("only shows link hover during mouse tracking when the link modifier is held", () => { expect(shouldShowTerminalLinkHover(false, false)).toBe(true); expect(shouldShowTerminalLinkHover(false, true)).toBe(true); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 8ee4b633fcb8..75321ff84b2d 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -418,6 +418,20 @@ export function shouldReportTerminalMouse( return tracking && !event.shiftKey && !event.ctrlKey && !event.metaKey; } +type TerminalMouseAction = "press" | "release" | "motion"; + +export function resolveTerminalMouseData( + action: TerminalMouseAction, + data: string, + previousMotionData: string, +): { readonly send: boolean; readonly nextMotionData: string } { + const nextMotionData = action === "motion" ? data : ""; + return { + send: data.length > 0 && (action !== "motion" || data !== previousMotionData), + nextMotionData, + }; +} + export function terminalWheelDeltaRows( event: Pick, cellHeight: number, @@ -593,6 +607,7 @@ export class GhosttyTerminalSurface { private clearSelectionAfterCopy = false; private primedCopySelection = ""; private wheelRemainder = 0; + private lastMouseMotionData = ""; private dprMedia: MediaQueryList | null = null; // Read live on every blink decision, and watched so that dropping the // preference restarts a blink cycle that has no timer left to notice it. @@ -1364,6 +1379,7 @@ export class GhosttyTerminalSurface { } private readonly onPointerLeave = () => { + this.lastMouseMotionData = ""; this.clearHoveredLink(); }; @@ -1822,11 +1838,7 @@ export class GhosttyTerminalSurface { return terminalLinkAtPositionWithRange(this.snapshot.rowData, cell.y, cell.x); } - private sendMouse( - action: "press" | "release" | "motion", - button: number | null, - event: MouseEvent, - ): void { + private sendMouse(action: TerminalMouseAction, button: number | null, event: MouseEvent): void { const bounds = this.canvas.getBoundingClientRect(); const data = this.core.encodeMouse({ action, @@ -1848,7 +1860,9 @@ export class GhosttyTerminalSurface { paddingBottom: Math.max(0, bounds.height - this.originY - this.rows * this.metrics.height), anyButtonPressed: event.buttons !== 0, }); - if (data.length > 0) this.options.onData(data); + const resolution = resolveTerminalMouseData(action, data, this.lastMouseMotionData); + this.lastMouseMotionData = resolution.nextMotionData; + if (resolution.send) this.options.onData(data); } private buttonFromButtons(buttons: number): number | null { From 9782d0f56cc563c26dde8dd4d97624267ebd5bbb Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:45:34 +0000 Subject: [PATCH 2/2] fix(web): reset terminal motion dedupe state Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/terminal/ghostty/surface.test.ts | 20 +++++++++++ apps/web/src/terminal/ghostty/surface.ts | 34 ++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 80a23dfc9a1f..45177a43a5be 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -17,6 +17,7 @@ import { loadTerminalFontFamily, primeTerminalCopyInput, resolveTerminalMouseData, + resolveTerminalMouseTrackingState, shouldBlinkTerminalCursor, shouldReportTerminalMouse, shouldShowTerminalLinkHover, @@ -419,6 +420,25 @@ describe("application mouse reporting", () => { }); }); + it("clears the motion baseline when application mouse tracking changes", () => { + expect(resolveTerminalMouseTrackingState(true, false, "\u001b[<35;8;4M")).toEqual({ + tracking: false, + motionData: "", + }); + expect(resolveTerminalMouseTrackingState(false, true, "\u001b[<35;8;4M")).toEqual({ + tracking: true, + motionData: "", + }); + expect(resolveTerminalMouseTrackingState(true, true, "\u001b[<35;8;4M")).toEqual({ + tracking: true, + motionData: "\u001b[<35;8;4M", + }); + expect(resolveTerminalMouseTrackingState(false, false, "\u001b[<35;8;4M")).toEqual({ + tracking: false, + motionData: "\u001b[<35;8;4M", + }); + }); + it("only shows link hover during mouse tracking when the link modifier is held", () => { expect(shouldShowTerminalLinkHover(false, false)).toBe(true); expect(shouldShowTerminalLinkHover(false, true)).toBe(true); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 75321ff84b2d..f390947c4210 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -432,6 +432,17 @@ export function resolveTerminalMouseData( }; } +export function resolveTerminalMouseTrackingState( + previousTracking: boolean, + tracking: boolean, + motionData: string, +): { readonly tracking: boolean; readonly motionData: string } { + return { + tracking, + motionData: previousTracking === tracking ? motionData : "", + }; +} + export function terminalWheelDeltaRows( event: Pick, cellHeight: number, @@ -608,6 +619,7 @@ export class GhosttyTerminalSurface { private primedCopySelection = ""; private wheelRemainder = 0; private lastMouseMotionData = ""; + private mouseAnyEventTracking = false; private dprMedia: MediaQueryList | null = null; // Read live on every blink decision, and watched so that dropping the // preference restarts a blink cycle that has no timer left to notice it. @@ -634,6 +646,7 @@ export class GhosttyTerminalSurface { this.scrollbarThumb = scrollbarThumb; this.context = context; this.core = core; + this.mouseAnyEventTracking = core.isMouseAnyEventTracking(); this.metrics = metrics; this.options = options; this.theme = options.theme; @@ -725,6 +738,7 @@ export class GhosttyTerminalSurface { write(data: string): void { if (this.disposed) return; this.core.write(data); + this.synchronizeMouseTrackingState(); // Restart the blink cycle from the visible phase so the cursor never sits // invisible through a stream of output or a burst of typing echo. this.cursorOn = true; @@ -734,7 +748,9 @@ export class GhosttyTerminalSurface { resetAndWrite(data: string): void { if (this.disposed) return; + this.lastMouseMotionData = ""; this.core.resetAndWrite(data); + this.synchronizeMouseTrackingState(); // A replayed session starts from the visible phase like any other write: // reattaching mid-blink must not open on an invisible cursor. this.cursorOn = true; @@ -1287,9 +1303,10 @@ export class GhosttyTerminalSurface { if (this.linkActivationPointerId === event.pointerId) return; // Hover motion is only reportable in any-event tracking (DEC 1003); normal and // button-event tracking never report motion without a captured pressed button. + const anyEventTracking = this.synchronizeMouseTrackingState(); if ( this.mouseReportingPointerId === event.pointerId || - shouldReportTerminalMouse(this.core.isMouseAnyEventTracking(), event) + shouldReportTerminalMouse(anyEventTracking, event) ) { event.preventDefault(); this.hoverPointer = { x: event.clientX, y: event.clientY }; @@ -1301,6 +1318,7 @@ export class GhosttyTerminalSurface { this.sendMouse("motion", this.buttonFromButtons(event.buttons), event); return; } + this.lastMouseMotionData = ""; if (!this.selectionAnchorScreen || !this.canvas.hasPointerCapture(event.pointerId)) { this.updateHoverCursor(event); return; @@ -1865,6 +1883,20 @@ export class GhosttyTerminalSurface { if (resolution.send) this.options.onData(data); } + private synchronizeMouseTrackingState(): boolean { + // Output writes can toggle DEC 1003 without moving the pointer. Keep the + // previous mode so the next same-cell motion starts a fresh tracking session. + const tracking = this.core.isMouseAnyEventTracking(); + const state = resolveTerminalMouseTrackingState( + this.mouseAnyEventTracking, + tracking, + this.lastMouseMotionData, + ); + this.mouseAnyEventTracking = state.tracking; + this.lastMouseMotionData = state.motionData; + return tracking; + } + private buttonFromButtons(buttons: number): number | null { if ((buttons & 1) !== 0) return 1; if ((buttons & 4) !== 0) return 3;