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 {...@@ -16,6 +16,8 @@ import {
16 isTerminalPasteShortcut,16 isTerminalPasteShortcut,
17 loadTerminalFontFamily,17 loadTerminalFontFamily,
18 primeTerminalCopyInput,18 primeTerminalCopyInput,
19 resolveTerminalMouseData,
20 resolveTerminalMouseTrackingState,
19 shouldBlinkTerminalCursor,21 shouldBlinkTerminalCursor,
20 shouldReportTerminalMouse,22 shouldReportTerminalMouse,
21 shouldShowTerminalLinkHover,23 shouldShowTerminalLinkHover,
...@@ -402,6 +404,41 @@ describe("application mouse reporting", () => {...@@ -402,6 +404,41 @@ describe("application mouse reporting", () => {
402 expect([0, 1, 2, 3, 4, 5].map(ghosttyMouseButton)).toEqual([1, 3, 2, 4, 5, null]);404 expect([0, 1, 2, 3, 4, 5].map(ghosttyMouseButton)).toEqual([1, 3, 2, 4, 5, null]);
403 });405 });
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
405 it("only shows link hover during mouse tracking when the link modifier is held", () => {442 it("only shows link hover during mouse tracking when the link modifier is held", () => {
406 expect(shouldShowTerminalLinkHover(false, false)).toBe(true);443 expect(shouldShowTerminalLinkHover(false, false)).toBe(true);
407 expect(shouldShowTerminalLinkHover(false, true)).toBe(true);444 expect(shouldShowTerminalLinkHover(false, true)).toBe(true);
apps/web/src/terminal/ghostty/surface.ts+53-7
...@@ -418,6 +418,31 @@ export function shouldReportTerminalMouse(...@@ -418,6 +418,31 @@ export function shouldReportTerminalMouse(
418 return tracking && !event.shiftKey && !event.ctrlKey && !event.metaKey;418 return tracking && !event.shiftKey && !event.ctrlKey && !event.metaKey;
419}419}
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
421export function terminalWheelDeltaRows(446export function terminalWheelDeltaRows(
422 event: Pick<WheelEvent, "deltaY" | "deltaMode">,447 event: Pick<WheelEvent, "deltaY" | "deltaMode">,
423 cellHeight: number,448 cellHeight: number,
...@@ -593,6 +618,8 @@ export class GhosttyTerminalSurface {...@@ -593,6 +618,8 @@ export class GhosttyTerminalSurface {
593 private clearSelectionAfterCopy = false;618 private clearSelectionAfterCopy = false;
594 private primedCopySelection = "";619 private primedCopySelection = "";
595 private wheelRemainder = 0;620 private wheelRemainder = 0;
621 private lastMouseMotionData = "";
622 private mouseAnyEventTracking = false;
596 private dprMedia: MediaQueryList | null = null;623 private dprMedia: MediaQueryList | null = null;
597 // Read live on every blink decision, and watched so that dropping the624 // Read live on every blink decision, and watched so that dropping the
598 // preference restarts a blink cycle that has no timer left to notice it.625 // preference restarts a blink cycle that has no timer left to notice it.
...@@ -619,6 +646,7 @@ export class GhosttyTerminalSurface {...@@ -619,6 +646,7 @@ export class GhosttyTerminalSurface {
619 this.scrollbarThumb = scrollbarThumb;646 this.scrollbarThumb = scrollbarThumb;
620 this.context = context;647 this.context = context;
621 this.core = core;648 this.core = core;
649 this.mouseAnyEventTracking = core.isMouseAnyEventTracking();
622 this.metrics = metrics;650 this.metrics = metrics;
623 this.options = options;651 this.options = options;
624 this.theme = options.theme;652 this.theme = options.theme;
...@@ -710,6 +738,7 @@ export class GhosttyTerminalSurface {...@@ -710,6 +738,7 @@ export class GhosttyTerminalSurface {
710 write(data: string): void {738 write(data: string): void {
711 if (this.disposed) return;739 if (this.disposed) return;
712 this.core.write(data);740 this.core.write(data);
741 this.synchronizeMouseTrackingState();
713 // Restart the blink cycle from the visible phase so the cursor never sits742 // Restart the blink cycle from the visible phase so the cursor never sits
714 // invisible through a stream of output or a burst of typing echo.743 // invisible through a stream of output or a burst of typing echo.
715 this.cursorOn = true;744 this.cursorOn = true;
...@@ -719,7 +748,9 @@ export class GhosttyTerminalSurface {...@@ -719,7 +748,9 @@ export class GhosttyTerminalSurface {
719748
720 resetAndWrite(data: string): void {749 resetAndWrite(data: string): void {
721 if (this.disposed) return;750 if (this.disposed) return;
751 this.lastMouseMotionData = "";
722 this.core.resetAndWrite(data);752 this.core.resetAndWrite(data);
753 this.synchronizeMouseTrackingState();
723 // A replayed session starts from the visible phase like any other write:754 // A replayed session starts from the visible phase like any other write:
724 // reattaching mid-blink must not open on an invisible cursor.755 // reattaching mid-blink must not open on an invisible cursor.
725 this.cursorOn = true;756 this.cursorOn = true;
...@@ -1272,9 +1303,10 @@ export class GhosttyTerminalSurface {...@@ -1272,9 +1303,10 @@ export class GhosttyTerminalSurface {
1272 if (this.linkActivationPointerId === event.pointerId) return;1303 if (this.linkActivationPointerId === event.pointerId) return;
1273 // Hover motion is only reportable in any-event tracking (DEC 1003); normal and1304 // Hover motion is only reportable in any-event tracking (DEC 1003); normal and
1274 // button-event tracking never report motion without a captured pressed button.1305 // button-event tracking never report motion without a captured pressed button.
1306 const anyEventTracking = this.synchronizeMouseTrackingState();
1275 if (1307 if (
1276 this.mouseReportingPointerId === event.pointerId ||1308 this.mouseReportingPointerId === event.pointerId ||
1277 shouldReportTerminalMouse(this.core.isMouseAnyEventTracking(), event)1309 shouldReportTerminalMouse(anyEventTracking, event)
1278 ) {1310 ) {
1279 event.preventDefault();1311 event.preventDefault();
1280 this.hoverPointer = { x: event.clientX, y: event.clientY };1312 this.hoverPointer = { x: event.clientX, y: event.clientY };
...@@ -1286,6 +1318,7 @@ export class GhosttyTerminalSurface {...@@ -1286,6 +1318,7 @@ export class GhosttyTerminalSurface {
1286 this.sendMouse("motion", this.buttonFromButtons(event.buttons), event);1318 this.sendMouse("motion", this.buttonFromButtons(event.buttons), event);
1287 return;1319 return;
1288 }1320 }
1321 this.lastMouseMotionData = "";
1289 if (!this.selectionAnchorScreen || !this.canvas.hasPointerCapture(event.pointerId)) {1322 if (!this.selectionAnchorScreen || !this.canvas.hasPointerCapture(event.pointerId)) {
1290 this.updateHoverCursor(event);1323 this.updateHoverCursor(event);
1291 return;1324 return;
...@@ -1364,6 +1397,7 @@ export class GhosttyTerminalSurface {...@@ -1364,6 +1397,7 @@ export class GhosttyTerminalSurface {
1364 }1397 }
13651398
1366 private readonly onPointerLeave = () => {1399 private readonly onPointerLeave = () => {
1400 this.lastMouseMotionData = "";
1367 this.clearHoveredLink();1401 this.clearHoveredLink();
1368 };1402 };
13691403
...@@ -1822,11 +1856,7 @@ export class GhosttyTerminalSurface {...@@ -1822,11 +1856,7 @@ export class GhosttyTerminalSurface {
1822 return terminalLinkAtPositionWithRange(this.snapshot.rowData, cell.y, cell.x);1856 return terminalLinkAtPositionWithRange(this.snapshot.rowData, cell.y, cell.x);
1823 }1857 }
18241858
1825 private sendMouse(1859 private sendMouse(action: TerminalMouseAction, button: number | null, event: MouseEvent): void {
1826 action: "press" | "release" | "motion",
1827 button: number | null,
1828 event: MouseEvent,
1829 ): void {
1830 const bounds = this.canvas.getBoundingClientRect();1860 const bounds = this.canvas.getBoundingClientRect();
1831 const data = this.core.encodeMouse({1861 const data = this.core.encodeMouse({
1832 action,1862 action,
...@@ -1848,7 +1878,23 @@ export class GhosttyTerminalSurface {...@@ -1848,7 +1878,23 @@ export class GhosttyTerminalSurface {
1848 paddingBottom: Math.max(0, bounds.height - this.originY - this.rows * this.metrics.height),1878 paddingBottom: Math.max(0, bounds.height - this.originY - this.rows * this.metrics.height),
1849 anyButtonPressed: event.buttons !== 0,1879 anyButtonPressed: event.buttons !== 0,
1850 });1880 });
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;
1852 }1898 }
18531899
1854 private buttonFromButtons(buttons: number): number | null {1900 private buttonFromButtons(buttons: number): number | null {