authorgravatar for 269035359+t3-code[bot]@users.noreply.github.comt3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> 2026-08-21 18:08:18-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-21 18:08:18-04:00
log592c5983c14d248aa3cfddb8e6c7372f12cd1ab6
tree7f23c20802d96e6d443af00d070f990ff8c5a267
parent44e4a7071d84c95f4d273cc3c6c9682166502745
signature Signed by PGP key B5690EEEBB952194

perf(web): dedupe terminal mouse motion reports (#7845)

Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com>

2 files changed, 90 insertions(+), 7 deletions(-)

apps/web/src/terminal/ghostty/surface.test.ts+37
......@@ -16,6 +16,8 @@ import {
1616 isTerminalPasteShortcut,
1717 loadTerminalFontFamily,
1818 primeTerminalCopyInput,
19 resolveTerminalMouseData,
20 resolveTerminalMouseTrackingState,
1921 shouldBlinkTerminalCursor,
2022 shouldReportTerminalMouse,
2123 shouldShowTerminalLinkHover,
......@@ -402,6 +404,41 @@ describe("application mouse reporting", () => {
402404 expect([0, 1, 2, 3, 4, 5].map(ghosttyMouseButton)).toEqual([1, 3, 2, 4, 5, null]);
403405 });
404406
407 it("drops repeated motion reports until another mouse action resets the cell", () => {
408 const first = resolveTerminalMouseData("motion", "\u001b[<35;8;4M", "");
409 expect(first).toEqual({ send: true, nextMotionData: "\u001b[<35;8;4M" });
410
411 const duplicate = resolveTerminalMouseData("motion", "\u001b[<35;8;4M", first.nextMotionData);
412 expect(duplicate).toEqual({ send: false, nextMotionData: "\u001b[<35;8;4M" });
413
414 const press = resolveTerminalMouseData("press", "\u001b[<0;8;4M", duplicate.nextMotionData);
415 expect(press).toEqual({ send: true, nextMotionData: "" });
416
417 expect(resolveTerminalMouseData("motion", "\u001b[<35;8;4M", press.nextMotionData)).toEqual({
418 send: true,
419 nextMotionData: "\u001b[<35;8;4M",
420 });
421 });
422
423 it("clears the motion baseline when application mouse tracking changes", () => {
424 expect(resolveTerminalMouseTrackingState(true, false, "\u001b[<35;8;4M")).toEqual({
425 tracking: false,
426 motionData: "",
427 });
428 expect(resolveTerminalMouseTrackingState(false, true, "\u001b[<35;8;4M")).toEqual({
429 tracking: true,
430 motionData: "",
431 });
432 expect(resolveTerminalMouseTrackingState(true, true, "\u001b[<35;8;4M")).toEqual({
433 tracking: true,
434 motionData: "\u001b[<35;8;4M",
435 });
436 expect(resolveTerminalMouseTrackingState(false, false, "\u001b[<35;8;4M")).toEqual({
437 tracking: false,
438 motionData: "\u001b[<35;8;4M",
439 });
440 });
441
405442 it("only shows link hover during mouse tracking when the link modifier is held", () => {
406443 expect(shouldShowTerminalLinkHover(false, false)).toBe(true);
407444 expect(shouldShowTerminalLinkHover(false, true)).toBe(true);
apps/web/src/terminal/ghostty/surface.ts+53-7
......@@ -418,6 +418,31 @@ export function shouldReportTerminalMouse(
418418 return tracking && !event.shiftKey && !event.ctrlKey && !event.metaKey;
419419}
420420
421type TerminalMouseAction = "press" | "release" | "motion";
422
423export function resolveTerminalMouseData(
424 action: TerminalMouseAction,
425 data: string,
426 previousMotionData: string,
427): { readonly send: boolean; readonly nextMotionData: string } {
428 const nextMotionData = action === "motion" ? data : "";
429 return {
430 send: data.length > 0 && (action !== "motion" || data !== previousMotionData),
431 nextMotionData,
432 };
433}
434
435export function resolveTerminalMouseTrackingState(
436 previousTracking: boolean,
437 tracking: boolean,
438 motionData: string,
439): { readonly tracking: boolean; readonly motionData: string } {
440 return {
441 tracking,
442 motionData: previousTracking === tracking ? motionData : "",
443 };
444}
445
421446export function terminalWheelDeltaRows(
422447 event: Pick<WheelEvent, "deltaY" | "deltaMode">,
423448 cellHeight: number,
......@@ -593,6 +618,8 @@ export class GhosttyTerminalSurface {
593618 private clearSelectionAfterCopy = false;
594619 private primedCopySelection = "";
595620 private wheelRemainder = 0;
621 private lastMouseMotionData = "";
622 private mouseAnyEventTracking = false;
596623 private dprMedia: MediaQueryList | null = null;
597624 // Read live on every blink decision, and watched so that dropping the
598625 // preference restarts a blink cycle that has no timer left to notice it.
......@@ -619,6 +646,7 @@ export class GhosttyTerminalSurface {
619646 this.scrollbarThumb = scrollbarThumb;
620647 this.context = context;
621648 this.core = core;
649 this.mouseAnyEventTracking = core.isMouseAnyEventTracking();
622650 this.metrics = metrics;
623651 this.options = options;
624652 this.theme = options.theme;
......@@ -710,6 +738,7 @@ export class GhosttyTerminalSurface {
710738 write(data: string): void {
711739 if (this.disposed) return;
712740 this.core.write(data);
741 this.synchronizeMouseTrackingState();
713742 // Restart the blink cycle from the visible phase so the cursor never sits
714743 // invisible through a stream of output or a burst of typing echo.
715744 this.cursorOn = true;
......@@ -719,7 +748,9 @@ export class GhosttyTerminalSurface {
719748
720749 resetAndWrite(data: string): void {
721750 if (this.disposed) return;
751 this.lastMouseMotionData = "";
722752 this.core.resetAndWrite(data);
753 this.synchronizeMouseTrackingState();
723754 // A replayed session starts from the visible phase like any other write:
724755 // reattaching mid-blink must not open on an invisible cursor.
725756 this.cursorOn = true;
......@@ -1272,9 +1303,10 @@ export class GhosttyTerminalSurface {
12721303 if (this.linkActivationPointerId === event.pointerId) return;
12731304 // Hover motion is only reportable in any-event tracking (DEC 1003); normal and
12741305 // button-event tracking never report motion without a captured pressed button.
1306 const anyEventTracking = this.synchronizeMouseTrackingState();
12751307 if (
12761308 this.mouseReportingPointerId === event.pointerId ||
1277 shouldReportTerminalMouse(this.core.isMouseAnyEventTracking(), event)
1309 shouldReportTerminalMouse(anyEventTracking, event)
12781310 ) {
12791311 event.preventDefault();
12801312 this.hoverPointer = { x: event.clientX, y: event.clientY };
......@@ -1286,6 +1318,7 @@ export class GhosttyTerminalSurface {
12861318 this.sendMouse("motion", this.buttonFromButtons(event.buttons), event);
12871319 return;
12881320 }
1321 this.lastMouseMotionData = "";
12891322 if (!this.selectionAnchorScreen || !this.canvas.hasPointerCapture(event.pointerId)) {
12901323 this.updateHoverCursor(event);
12911324 return;
......@@ -1364,6 +1397,7 @@ export class GhosttyTerminalSurface {
13641397 }
13651398
13661399 private readonly onPointerLeave = () => {
1400 this.lastMouseMotionData = "";
13671401 this.clearHoveredLink();
13681402 };
13691403
......@@ -1822,11 +1856,7 @@ export class GhosttyTerminalSurface {
18221856 return terminalLinkAtPositionWithRange(this.snapshot.rowData, cell.y, cell.x);
18231857 }
18241858
1825 private sendMouse(
1826 action: "press" | "release" | "motion",
1827 button: number | null,
1828 event: MouseEvent,
1829 ): void {
1859 private sendMouse(action: TerminalMouseAction, button: number | null, event: MouseEvent): void {
18301860 const bounds = this.canvas.getBoundingClientRect();
18311861 const data = this.core.encodeMouse({
18321862 action,
......@@ -1848,7 +1878,23 @@ export class GhosttyTerminalSurface {
18481878 paddingBottom: Math.max(0, bounds.height - this.originY - this.rows * this.metrics.height),
18491879 anyButtonPressed: event.buttons !== 0,
18501880 });
1851 if (data.length > 0) this.options.onData(data);
1881 const resolution = resolveTerminalMouseData(action, data, this.lastMouseMotionData);
1882 this.lastMouseMotionData = resolution.nextMotionData;
1883 if (resolution.send) this.options.onData(data);
1884 }
1885
1886 private synchronizeMouseTrackingState(): boolean {
1887 // Output writes can toggle DEC 1003 without moving the pointer. Keep the
1888 // previous mode so the next same-cell motion starts a fresh tracking session.
1889 const tracking = this.core.isMouseAnyEventTracking();
1890 const state = resolveTerminalMouseTrackingState(
1891 this.mouseAnyEventTracking,
1892 tracking,
1893 this.lastMouseMotionData,
1894 );
1895 this.mouseAnyEventTracking = state.tracking;
1896 this.lastMouseMotionData = state.motionData;
1897 return tracking;
18521898 }
18531899
18541900 private buttonFromButtons(buttons: number): number | null {