authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-10-14 08:12:21+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 08:12:21+05:30
log1ce8089ebd3317dad220f49deb541654c2f05fb5
tree51d64e789a2114cb208f3df9e775cf290b9259d7
parent9c9ae43d80fa0e7a697257dc4cc9be950e534747
signature Signed by PGP key B5690EEEBB952194

fix(web): scroll terminal output with touch gestures (#11305)


3 files changed, 173 insertions(+), 7 deletions(-)

apps/web/src/components/ThreadTerminalDrawer.tsx+1-1
......@@ -1700,7 +1700,7 @@ export default function ThreadTerminalDrawer({
17001700 ? "border-border"
17011701 : "border-border/70"
17021702 }`}
1703 onMouseDown={() => {
1703 onPointerDown={() => {
17041704 if (terminalId !== resolvedActiveTerminalId) {
17051705 onActiveTerminalChange(terminalId);
17061706 }
apps/web/src/terminal/ghostty/surface.test.ts+99
......@@ -228,6 +228,20 @@ describe("GhosttyTerminalSurface visibility", () => {
228228 }),
229229 );
230230 },
231 touch(type: string, clientY: number, pointerId = 2) {
232 canvas.dispatchEvent(
233 Object.assign(new Event(type, { cancelable: true }), {
234 clientX: 40,
235 clientY,
236 pointerId,
237 pointerType: "touch",
238 isPrimary: pointerId === 2,
239 button: 0,
240 buttons: type === "pointerup" ? 0 : 1,
241 shiftKey: false,
242 }),
243 );
244 },
231245 async create(options: Partial<GhosttyTerminalSurfaceOptions> = {}) {
232246 const surface = await GhosttyTerminalSurface.create(mount as unknown as HTMLElement, {
233247 theme: {
......@@ -580,6 +594,91 @@ describe("GhosttyTerminalSurface visibility", () => {
580594 expect(harness.frames.size).toBe(0);
581595 });
582596
597 it("scrolls touch drags in both directions without selecting or sending input", async () => {
598 const harness = createHarness();
599 const surface = await harness.create();
600 surface.write(Array.from({ length: 50 }, (_, i) => `row ${i}\r\n`).join(""));
601 harness.flushFrame();
602 const bottom = harness.renderedSnapshot.rowData[0]?.text;
603 harness.touch("pointerdown", 20);
604 for (let y = 22; y <= 68; y += 2) harness.touch("pointermove", y);
605 harness.flushFrame();
606 expect(harness.renderedSnapshot.rowData[0]?.text).not.toBe(bottom);
607 expect(surface.getSelection()).toBe("");
608 harness.touch("pointermove", 20);
609 harness.touch("pointerup", 20);
610 harness.flushFrame();
611 expect(harness.renderedSnapshot.rowData[0]?.text).toBe(bottom);
612 expect(harness.onData).not.toHaveBeenCalled();
613 });
614
615 it("sends touch scrolling to alternate-screen applications", async () => {
616 const harness = createHarness();
617 const surface = await harness.create();
618 surface.write("\x1b[?1049h\x1b[?1h");
619 harness.touch("pointerdown", 20);
620 harness.touch("pointermove", 52);
621 harness.touch("pointerup", 52);
622 expect(harness.onData.mock.calls).toEqual([["\x1bOA\x1bOA"]]);
623 });
624
625 it("reports touch scrolling and taps to mouse-tracking applications", async () => {
626 const harness = createHarness();
627 const surface = await harness.create();
628 surface.write("\x1b[?1000h\x1b[?1006h");
629 harness.touch("pointerdown", 20);
630 harness.touch("pointermove", 52);
631 harness.touch("pointerup", 52);
632 expect(harness.onData.mock.calls.map(([data]) => data)).toEqual([
633 "\x1b[<64;5;4M",
634 "\x1b[<64;5;4M",
635 ]);
636 harness.onData.mockClear();
637 harness.touch("pointerdown", 20);
638 harness.touch("pointerup", 20);
639 expect(harness.onData.mock.calls.map(([data]) => data)).toEqual([
640 "\x1b[<0;5;2M",
641 "\x1b[<0;5;2m",
642 ]);
643 });
644
645 it("opens a link on touch tap but not on drag or cancellation", async () => {
646 const harness = createHarness();
647 const onLinkActivate = vi.fn();
648 const surface = await harness.create({ onLinkActivate });
649 surface.write("https://example.com");
650 harness.flushFrame();
651 harness.touch("pointerdown", 5);
652 harness.touch("pointerup", 5);
653 expect(onLinkActivate).toHaveBeenCalledTimes(1);
654 harness.touch("pointerdown", 5);
655 harness.touch("pointermove", 37);
656 harness.touch("pointerup", 37);
657 harness.touch("pointerdown", 5);
658 harness.touch("pointercancel", 5);
659 expect(onLinkActivate).toHaveBeenCalledTimes(1);
660 });
661
662 it("ends a canceled touch drag and ignores a second finger", async () => {
663 const harness = createHarness();
664 const surface = await harness.create();
665 surface.write(Array.from({ length: 50 }, (_, i) => `row ${i}\r\n`).join(""));
666 harness.flushFrame();
667 const bottom = harness.renderedSnapshot.rowData[0]?.text;
668 harness.touch("pointerdown", 20);
669 harness.touch("pointerdown", 20, 3);
670 harness.touch("pointermove", 68, 3);
671 harness.touch("pointercancel", 20);
672 harness.touch("pointermove", 68);
673 harness.flushFrame();
674 expect(harness.renderedSnapshot.rowData[0]?.text).toBe(bottom);
675 harness.touch("pointerdown", 20);
676 harness.touch("pointermove", 68);
677 harness.touch("pointerup", 68);
678 harness.flushFrame();
679 expect(harness.renderedSnapshot.rowData[0]?.text).not.toBe(bottom);
680 });
681
583682 it("keeps the selection on reveal and applies a hidden selection clear", async () => {
584683 const harness = createHarness();
585684 const surface = await harness.create();
apps/web/src/terminal/ghostty/surface.ts+73-6
......@@ -631,6 +631,14 @@ export class GhosttyTerminalSurface {
631631 private clearSelectionAfterCopy = false;
632632 private primedCopySelection = "";
633633 private wheelRemainder = 0;
634 private touchScroll: {
635 pointerId: number;
636 startX: number;
637 startY: number;
638 lastY: number;
639 remainder: number;
640 moved: boolean;
641 } | null = null;
634642 private lastMouseMotionData = "";
635643 private mouseAnyEventTracking = false;
636644 private dprMedia: MediaQueryList | null = null;
......@@ -680,6 +688,7 @@ export class GhosttyTerminalSurface {
680688 ): Promise<GhosttyTerminalSurface> {
681689 const canvas = document.createElement("canvas");
682690 canvas.className = "block size-full cursor-text";
691 canvas.style.touchAction = "pan-x pinch-zoom";
683692 canvas.setAttribute("aria-hidden", "true");
684693
685694 const input = document.createElement("textarea");
......@@ -1361,6 +1370,21 @@ export class GhosttyTerminalSurface {
13611370 }
13621371
13631372 private readonly onPointerDown = (event: PointerEvent) => {
1373 if (event.pointerType === "touch") {
1374 event.preventDefault();
1375 if (!event.isPrimary || this.touchScroll !== null) return;
1376 this.touchScroll = {
1377 pointerId: event.pointerId,
1378 startX: event.clientX,
1379 startY: event.clientY,
1380 lastY: event.clientY,
1381 remainder: 0,
1382 moved: false,
1383 };
1384 this.clearHoveredLink();
1385 this.canvas.setPointerCapture(event.pointerId);
1386 return;
1387 }
13641388 this.focus();
13651389 if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) {
13661390 const button = ghosttyMouseButton(event.button);
......@@ -1445,6 +1469,25 @@ export class GhosttyTerminalSurface {
14451469 }
14461470
14471471 private readonly onPointerMove = (event: PointerEvent) => {
1472 if (event.pointerType === "touch") {
1473 const touch = this.touchScroll;
1474 if (touch?.pointerId !== event.pointerId) return;
1475 event.preventDefault();
1476 touch.moved ||=
1477 Math.hypot(event.clientX - touch.startX, event.clientY - touch.startY) >
1478 TERMINAL_LINK_DRAG_THRESHOLD_PX;
1479 if (!touch.moved) return;
1480 const delta = terminalWheelDeltaRows(
1481 { deltaY: touch.lastY - event.clientY, deltaMode: 0 },
1482 this.metrics.height,
1483 this.rows,
1484 touch.remainder,
1485 );
1486 touch.lastY = event.clientY;
1487 touch.remainder = delta.remainder;
1488 this.scrollRows(delta.rows, event);
1489 return;
1490 }
14481491 if (this.linkActivationPointerId === event.pointerId) {
14491492 const origin = this.linkActivationOrigin;
14501493 if (
......@@ -1585,6 +1628,26 @@ export class GhosttyTerminalSurface {
15851628 }
15861629
15871630 private readonly onPointerUp = (event: PointerEvent) => {
1631 if (event.pointerType === "touch") {
1632 const touch = this.touchScroll;
1633 if (touch?.pointerId !== event.pointerId) return;
1634 this.touchScroll = null;
1635 event.preventDefault();
1636 if (this.canvas.hasPointerCapture(event.pointerId)) {
1637 this.canvas.releasePointerCapture(event.pointerId);
1638 }
1639 if (event.type === "pointercancel" || touch.moved) return;
1640 this.focus();
1641 if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) {
1642 this.sendMouse("press", ghosttyMouseButton(0), event);
1643 this.sendMouse("release", ghosttyMouseButton(0), event);
1644 } else {
1645 const link = this.linkAt(event.clientX, event.clientY);
1646 if (link) this.options.onLinkActivate(link.text, event);
1647 else this.clearSelection();
1648 }
1649 return;
1650 }
15881651 this.setSelectionAutoscroll(0);
15891652 if (this.linkActivationPointerId === event.pointerId) {
15901653 event.preventDefault();
......@@ -1644,10 +1707,14 @@ export class GhosttyTerminalSurface {
16441707 this.wheelRemainder,
16451708 );
16461709 this.wheelRemainder = delta.remainder;
1647 if (delta.rows === 0) return;
1648 const magnitude = Math.abs(delta.rows);
1710 this.scrollRows(delta.rows, event);
1711 };
1712
1713 private scrollRows(rows: number, event: MouseEvent): void {
1714 if (rows === 0) return;
1715 const magnitude = Math.abs(rows);
16491716 if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) {
1650 const button = delta.rows < 0 ? 4 : 5;
1717 const button = rows < 0 ? 4 : 5;
16511718 for (let index = 0; index < magnitude; index += 1) {
16521719 this.sendMouse("press", button, event);
16531720 }
......@@ -1656,11 +1723,11 @@ export class GhosttyTerminalSurface {
16561723 if (this.core.isAlternateScreen()) {
16571724 // The alternate screen has no scrollback: translate wheel motion into
16581725 // arrow keys so full-screen apps like vim and less scroll, matching xterm.
1659 this.options.onData(terminalWheelArrowData(delta.rows, this.core.isApplicationCursorKeys()));
1726 this.options.onData(terminalWheelArrowData(rows, this.core.isApplicationCursorKeys()));
16601727 return;
16611728 }
1662 this.scrollViewport(delta.rows);
1663 };
1729 this.scrollViewport(rows);
1730 }
16641731
16651732 private readonly onMouseDown = (event: MouseEvent) => {
16661733 // Cancelling the middle button here stops autoscroll while still letting