Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] df67f21403 test(terminal): reproduce missing mouse reports on initial attach
A raw-input terminal enables SGR mouse reporting before the web client attaches. A trusted finger drag should reach its stdin, as it does when the same mode is enabled after attachment, but no report arrives. This diagnostic intentionally remains red; it changes no production behavior.
2026-11-04 11:31:58 +00:00
paseo-bot[bot] 1c35da2bdb fix(app): let xterm own terminal touch input
Remove the duplicate gesture implementation so history scrolls once and terminal applications retain arrow-key and mouse-report input. Cover live application modes with trusted touch gestures and a real raw-stdin process.
2026-11-04 11:25:47 +00:00
paseo-bot[bot]andsino a1cd62fbc9 fix(app): handle terminal finger scrolling once
Keep the vertical gesture in the existing terminal handler so xterm does not also scroll it at document level. Retain only the confirmed propagation correction from the contributor patch and cover the actual phone-sized app with trusted touch input.

Co-authored-by: sino <sino2322@gmail.com>
2026-11-04 11:09:38 +00:00
sino 78669b1f5b fix(app): scroll web terminal history with a finger swipe
A phone browser claims the vertical drag and drops it, because the canvas covers the scrollable viewport and the page itself does not scroll. Keep the gesture in the touch handler and move one line per cell.
2026-11-04 10:58:56 +00:00
2 changed files with 173 additions and 125 deletions
@@ -1,3 +1,4 @@
import { getServerId } from "../support/helpers/server-id";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { spawnSync } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
@@ -487,3 +488,175 @@ test("keeps the shared Find controls usable in a compact dark browser", async ({
await page.getByRole("button", { name: "Close Find" }).click();
await expect(input(page)).toBeFocused();
});
// A phone browser installs xterm's document-level touch gesture listener too.
// Keep this journey touch-enabled so it catches duplicate scroll handling.
test.describe("terminal history with a finger", () => {
test.use({ isMobile: true, hasTouch: true, viewport: { width: 390, height: 844 } });
test("scrolls history once per finger drag", async ({ page }) => {
await openPhoneTerminalHistory(page);
await expectSingleFingerScroll(page, 24);
await expectSingleFingerScroll(page, 1);
await expectMouseWheelScrollsHistory(page);
});
for (const mode of ["alternate", "mouse"] as const) {
test(`preserves finger navigation in ${mode} input mode`, async ({ page }) => {
await openPhoneInputCapture(page, mode);
await expectFingerNavigationInput(page, mode);
});
}
});
async function expectFingerNavigationInput(page: Page, mode: "alternate" | "mouse") {
await dragTerminalWithFinger(page, 24, async () => {
const prefix = mode === "alternate" ? "TOUCH_BYTES:1b4f41" : "TOUCH_BYTES:1b5b3c3634";
await expect.poll(() => getTerminalBufferText(page)).toContain(prefix);
});
}
async function openPhoneTerminalHistory(page: Page) {
await harness.createTerminal({
name: "Finger scroll history",
command: "bash",
args: [
"--noprofile",
"--norc",
"-c",
"for i in $(seq 1 150); do echo HISTORY_$i; done; exec sleep 300",
],
});
await openPhoneTerminal(page);
await expect.poll(() => getTerminalBufferText(page)).toContain("HISTORY_150");
}
async function openPhoneInputCapture(page: Page, mode: "alternate" | "mouse") {
const escapes = mode === "alternate" ? "\x1b[?1049h\x1b[?1h" : "\x1b[?1000h\x1b[?1006h";
await harness.createTerminal({
name: "Touch application input",
command: process.execPath,
args: [
"-e",
`
process.stdin.setRawMode(true);
process.stdin.once("data", () => {
process.stdin.on("data", chunk => {
process.stdout.write("\\r\\nTOUCH_BYTES:" + chunk.toString("hex") + ":END\\r\\n");
});
process.stdout.write(${JSON.stringify(escapes)} + "TOUCH_INPUT_READY\\r\\n");
});
process.stdout.write("TOUCH_START_READY\\r\\n");
`,
],
});
await openPhoneTerminal(page);
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_START_READY");
await input(page).press("x");
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_INPUT_READY");
await expect
.poll(() => page.evaluate(() => window.__paseoTerminal!.buffer.active.type))
.toBe(mode === "alternate" ? "alternate" : "normal");
await expect
.poll(() => page.evaluate(() => window.__paseoTerminal!.modes.mouseTrackingMode))
.toBe(mode === "mouse" ? "vt200" : "none");
}
async function openPhoneTerminal(page: Page) {
await page.goto("/");
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${harness.workspaceId}`).click();
await expect(page.getByRole("textbox", { name: "Terminal input" })).toBeVisible();
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toBeVisible();
}
async function expectMouseWheelScrollsHistory(page: Page) {
const beforeWheel = (await viewport(page))!;
await page.mouse.move(200, 300);
await page.mouse.wheel(0, -120);
await expect.poll(() => viewport(page)).toBeLessThan(beforeWheel);
}
async function expectSingleFingerScroll(page: Page, moves: number) {
await dragTerminalWithFinger(page, moves, async ({ lineHeight, before }) => {
await expect.poll(async () => before - (await viewport(page))!).toBeGreaterThan(0);
const moved = before - (await viewport(page))!;
await test.info().attach("finger-scroll", {
body: JSON.stringify({ moves, distancePx: 120, lineHeight, rowsMoved: moved }),
contentType: "application/json",
});
expect(moved, "one gesture must not scroll twice its physical distance").toBeLessThanOrEqual(
Math.ceil(120 / lineHeight),
);
});
}
async function dragTerminalWithFinger(
page: Page,
moves: number,
inspect: (state: { lineHeight: number; before: number }) => Promise<void>,
) {
await test.step(`Drag history with ${moves} touch moves`, async () => {
const screen = page
.getByTestId("terminal-surface")
.filter({ visible: true })
.locator(".xterm-screen");
const box = await screen.boundingBox();
if (!box) throw new Error("Terminal screen is not visible");
const rows = await page.evaluate(() => window.__paseoTerminal!.rows);
const lineHeight = box.height / rows;
const before = (await viewport(page))!;
const session = await page.context().newCDPSession(page);
try {
const start = { x: box.x + box.width / 2, y: box.y + box.height / 3 };
await session.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ ...start, id: 1 }],
});
for (let step = 1; step <= moves; step++) {
await session.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: start.x, y: start.y + (120 * step) / moves, id: 1 }],
});
}
// Check before lifting the finger to exclude inertial scrolling.
await inspect({ lineHeight, before });
} finally {
await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await session.detach();
}
});
}
// Diagnostic-only: application modes emitted before the first attachment.
// The existing live-mode tests above are the control for this failure.
test.describe("mouse mode enabled before terminal attachment", () => {
test.use({ isMobile: true, hasTouch: true, viewport: { width: 390, height: 844 } });
test("preserves SGR wheel reports when opening a running terminal", async ({ page }) => {
await openPreAttachedMouseCapture(page);
await expectFingerNavigationInput(page, "mouse");
});
});
async function openPreAttachedMouseCapture(page: Page) {
await harness.createTerminal({
name: "Mouse mode before attachment",
command: process.execPath,
args: [
"-e",
`
process.stdin.setRawMode(true);
process.stdin.on("data", chunk => {
process.stdout.write("\\r\\nTOUCH_BYTES:" + chunk.toString("hex") + ":END\\r\\n");
});
process.stdout.write("\\x1b[?1000h\\x1b[?1006hTOUCH_INPUT_READY\\r\\n");
`,
],
});
await openPhoneTerminal(page);
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_INPUT_READY");
await expect
.poll(() => page.evaluate(() => window.__paseoTerminal!.modes.mouseTrackingMode))
.toBe("vt200");
}
@@ -123,7 +123,6 @@ interface TerminalEmulatorRuntimeDisposables {
removeVisualViewportResize: () => void;
clearFitTimeouts: () => void;
removeFontListeners: () => void;
removeTouchListeners: () => void;
removeFilePasteListener: () => void;
restoreDocumentStyles: () => void;
restoreViewportStyles: () => void;
@@ -155,7 +154,6 @@ const isAppleHandheld =
maxTouchPoints: navigator.maxTouchPoints,
});
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0);
@@ -764,11 +762,6 @@ export class TerminalEmulatorRuntime {
};
input.host.addEventListener("paste", filePasteHandler, true);
const removeTouchListeners = this.setupTouchScrollHandlers({
root: input.root,
host: input.host,
terminal,
});
const resizeObserver = new ResizeObserver(() => {
fitAndEmitResize({ shouldClaim: false });
});
@@ -848,7 +841,6 @@ export class TerminalEmulatorRuntime {
removeFontListeners: () => {
fontSet?.removeEventListener?.("loadingdone", fontReadyHandler);
},
removeTouchListeners,
removeFilePasteListener: () => {
input.host.removeEventListener("paste", filePasteHandler, true);
},
@@ -880,7 +872,6 @@ export class TerminalEmulatorRuntime {
disposables.removeVisualViewportResize();
disposables.clearFitTimeouts();
disposables.removeFontListeners();
disposables.removeTouchListeners();
disposables.removeFilePasteListener();
disposables.disposeFitAddon();
disposables.disposeWebglAddon();
@@ -1420,120 +1411,4 @@ export class TerminalEmulatorRuntime {
}
};
}
private setupTouchScrollHandlers(input: {
root: HTMLDivElement;
host: HTMLDivElement;
terminal: Terminal;
}): () => void {
let touchScrollRemainderPx = 0;
const measuredLineHeight =
input.host.querySelector<HTMLElement>(".xterm-rows > div")?.getBoundingClientRect().height ??
0;
const touchScrollLineHeightPx =
measuredLineHeight > 0 ? measuredLineHeight : DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX;
const activeTouch = {
identifier: -1,
startX: 0,
startY: 0,
lastX: 0,
lastY: 0,
mode: null as "vertical" | "horizontal" | null,
};
const touchStartHandler = (event: TouchEvent) => {
if (event.touches.length !== 1) {
touchScrollRemainderPx = 0;
activeTouch.identifier = -1;
activeTouch.mode = null;
return;
}
const touch = event.touches[0];
if (!touch) {
touchScrollRemainderPx = 0;
activeTouch.identifier = -1;
activeTouch.mode = null;
return;
}
activeTouch.identifier = touch.identifier;
activeTouch.startX = touch.clientX;
activeTouch.startY = touch.clientY;
activeTouch.lastX = touch.clientX;
activeTouch.lastY = touch.clientY;
activeTouch.mode = null;
touchScrollRemainderPx = 0;
};
const touchMoveHandler = (event: TouchEvent) => {
if (event.touches.length !== 1) {
return;
}
const touch = Array.from(event.touches).find(
(candidate) => candidate.identifier === activeTouch.identifier,
);
if (!touch) {
return;
}
const totalDeltaX = touch.clientX - activeTouch.startX;
const totalDeltaY = touch.clientY - activeTouch.startY;
if (activeTouch.mode === null) {
const absX = Math.abs(totalDeltaX);
const absY = Math.abs(totalDeltaY);
if (absX > 8 || absY > 8) {
activeTouch.mode = absY >= absX ? "vertical" : "horizontal";
}
}
const deltaY = touch.clientY - activeTouch.lastY;
activeTouch.lastX = touch.clientX;
activeTouch.lastY = touch.clientY;
if (activeTouch.mode !== "vertical") {
return;
}
touchScrollRemainderPx += deltaY;
const lineDelta = Math.trunc(touchScrollRemainderPx / touchScrollLineHeightPx);
if (lineDelta !== 0) {
input.terminal.scrollLines(-lineDelta);
touchScrollRemainderPx -= lineDelta * touchScrollLineHeightPx;
}
event.preventDefault();
};
const touchEndHandler = (event: TouchEvent) => {
const activeTouchEnded = Array.from(event.changedTouches).some(
(touch) => touch.identifier === activeTouch.identifier,
);
if (activeTouchEnded || event.touches.length === 0) {
touchScrollRemainderPx = 0;
activeTouch.identifier = -1;
activeTouch.mode = null;
}
};
const touchCancelHandler = () => {
touchScrollRemainderPx = 0;
activeTouch.identifier = -1;
activeTouch.mode = null;
};
input.root.addEventListener("touchstart", touchStartHandler, { passive: true });
input.root.addEventListener("touchmove", touchMoveHandler, { passive: false });
input.root.addEventListener("touchend", touchEndHandler, { passive: true });
input.root.addEventListener("touchcancel", touchCancelHandler, { passive: true });
return () => {
input.root.removeEventListener("touchstart", touchStartHandler);
input.root.removeEventListener("touchmove", touchMoveHandler);
input.root.removeEventListener("touchend", touchEndHandler);
input.root.removeEventListener("touchcancel", touchCancelHandler);
};
}
}