diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 1102d1b0bada..45177a43a5be 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -16,6 +16,8 @@ import { isTerminalPasteShortcut, loadTerminalFontFamily, primeTerminalCopyInput, + resolveTerminalMouseData, + resolveTerminalMouseTrackingState, shouldBlinkTerminalCursor, shouldReportTerminalMouse, shouldShowTerminalLinkHover, @@ -402,6 +404,41 @@ 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("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 8ee4b633fcb8..f390947c4210 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -418,6 +418,31 @@ 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 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, @@ -593,6 +618,8 @@ export class GhosttyTerminalSurface { private clearSelectionAfterCopy = false; 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. @@ -619,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; @@ -710,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; @@ -719,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; @@ -1272,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 }; @@ -1286,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; @@ -1364,6 +1397,7 @@ export class GhosttyTerminalSurface { } private readonly onPointerLeave = () => { + this.lastMouseMotionData = ""; this.clearHoveredLink(); }; @@ -1822,11 +1856,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 +1878,23 @@ 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 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 {