diff --git a/packages/app/src/components/terminal-emulator-native-grid.native.tsx b/packages/app/src/components/terminal-emulator-native-grid.native.tsx index dcda257116..95761bd55e 100644 --- a/packages/app/src/components/terminal-emulator-native-grid.native.tsx +++ b/packages/app/src/components/terminal-emulator-native-grid.native.tsx @@ -50,9 +50,9 @@ import { import { TERMINAL_GESTURE_LONG_PRESS_MS, TERMINAL_GESTURE_TAP_TOLERANCE_PX, - advanceTerminalScrollGesture, classifyTerminalGestureIntent, - resolveTerminalGestureReleaseAction, + routeTerminalGestureRelease, + routeTerminalScrollGesture, type TerminalGestureIntent, } from "../terminal/native-renderer/terminal-selection-gesture"; import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy"; @@ -95,6 +95,7 @@ interface NativeTerminalPanState { didNavigate: boolean; movedBeyondTapTolerance: boolean; intent: TerminalGestureIntent | null; + origin: { x: number; y: number }; } interface NativeTerminalSelectionPress { @@ -214,6 +215,7 @@ function NativeTerminalEmulator({ didNavigate: false, movedBeyondTapTolerance: false, intent: null, + origin: { x: 0, y: 0 }, }); const inputModeTrackerRef = useRef(new TerminalInputModeTracker()); const lastInputModeStateRef = useRef({ @@ -775,31 +777,33 @@ function NativeTerminalEmulator({ ); const handlePanResponderScroll = useCallback( - (currentDy: number) => { - const cellHeight = metricsRef.current?.cellHeight; - if (!cellHeight || cellHeight <= 0) { + (gesture: PanResponderGestureState) => { + const metrics = metricsRef.current; + const viewport = resolveSelectionViewport(); + if (!metrics || metrics.cellHeight <= 0 || !viewport) { return; } const panState = panStateRef.current; - const update = advanceTerminalScrollGesture({ + const { state, route } = routeTerminalScrollGesture({ state: panState, - currentDy, - cellHeight, + currentDy: gesture.dy, + point: { x: panState.origin.x + gesture.dx, y: panState.origin.y + gesture.dy }, + metrics, + viewport, + inputMode: inputModeTrackerRef.current, }); - panState.lastDy = update.state.lastDy; - panState.rowRemainder = update.state.rowRemainder; - panState.didScroll = update.state.didScroll; - const { rowDelta } = update; - if (rowDelta === 0) { + panState.lastDy = state.lastDy; + panState.rowRemainder = state.rowRemainder; + panState.didScroll = state.didScroll; + if (route.kind === "scrollback") { + applyScroll(route.direction, route.rows); return; } - if (rowDelta > 0) { - applyScroll("up", rowDelta); - return; + if (route.kind === "input") { + callbacksRef.current.onInput?.(route.data); } - applyScroll("down", Math.abs(rowDelta)); }, - [applyScroll], + [applyScroll, resolveSelectionViewport], ); const panResponder = useMemo( @@ -828,6 +832,10 @@ function NativeTerminalEmulator({ return intent === "scroll" || intent === "select"; }, onPanResponderGrant: (event) => { + const point = { + x: event.nativeEvent.locationX, + y: event.nativeEvent.locationY, + }; panStateRef.current = { lastDy: 0, rowRemainder: 0, @@ -835,10 +843,7 @@ function NativeTerminalEmulator({ didNavigate: false, movedBeyondTapTolerance: false, intent: null, - }; - const point = { - x: event.nativeEvent.locationX, - y: event.nativeEvent.locationY, + origin: point, }; const pressStatus = selectionRangeRef.current ? "selecting" : "pressing"; selectionPressRef.current = { @@ -875,7 +880,7 @@ function NativeTerminalEmulator({ } if (panState.intent === "scroll") { - handlePanResponderScroll(gesture.dy); + handlePanResponderScroll(gesture); return; } if (panState.didNavigate) { @@ -903,7 +908,7 @@ function NativeTerminalEmulator({ if (intent === "scroll") { selectionPressRef.current = null; - handlePanResponderScroll(gesture.dy); + handlePanResponderScroll(gesture); return; } @@ -932,8 +937,9 @@ function NativeTerminalEmulator({ didNavigate: false, movedBeyondTapTolerance: false, intent: null, + origin: { x: 0, y: 0 }, }; - const releaseAction = resolveTerminalGestureReleaseAction({ + const { action: releaseAction, mouseInput } = routeTerminalGestureRelease({ status: selectionPress?.status ?? "idle", startedWithSelection: selectionPress?.startedWithSelection, didScroll, @@ -942,11 +948,18 @@ function NativeTerminalEmulator({ pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0, longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, scrollMode: terminalScreenRef.current?.scroll.mode ?? "following", + point: { x: selectionPress?.startX ?? 0, y: selectionPress?.startY ?? 0 }, + metrics: metricsRef.current, + viewport: resolveSelectionViewport(), + inputMode: inputModeTrackerRef.current, }); if (releaseAction === "select" && selectionPress) { beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY }); return; } + if (mouseInput !== null) { + callbacksRef.current.onInput?.(mouseInput); + } if (releaseAction === "focus" || releaseAction === "clear") { focusTerminalFromTap(); } @@ -961,6 +974,7 @@ function NativeTerminalEmulator({ didNavigate: false, movedBeyondTapTolerance: false, intent: null, + origin: { x: 0, y: 0 }, }; }, }), @@ -969,6 +983,7 @@ function NativeTerminalEmulator({ clearSelectionLongPressTimeout, focusTerminalFromTap, handlePanResponderScroll, + resolveSelectionViewport, updateSelectionAtPoint, ], ); diff --git a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts b/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts index aed6857d5c..6df69a0368 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode"; import { TERMINAL_GESTURE_LONG_PRESS_MS, @@ -7,8 +8,64 @@ import { advanceTerminalScrollGesture, classifyTerminalGestureIntent, resolveTerminalGestureReleaseAction, + routeTerminalGestureRelease, + routeTerminalScrollGesture, + type TerminalGestureReleaseRouteInput, + type TerminalScrollGestureRoute, } from "./terminal-selection-gesture"; +const PI_FULLSCREEN_MOUSE_ENABLE = + "\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h"; +const PI_FULLSCREEN_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l"; + +function trackerFromOutput(output: string): TerminalInputModeTracker { + const tracker = new TerminalInputModeTracker(); + tracker.feed(output); + return tracker; +} + +function dragRoutes(input: { + inputMode: TerminalInputModeTracker; + dys: number[]; + point?: { x: number; y: number }; +}): TerminalScrollGestureRoute[] { + let state = { lastDy: 0, rowRemainder: 0, didScroll: false }; + const routes: TerminalScrollGestureRoute[] = []; + for (const currentDy of input.dys) { + const update = routeTerminalScrollGesture({ + state, + currentDy, + point: input.point ?? { x: 25, y: 50 }, + metrics: { cellWidth: 8, cellHeight: 14 }, + viewport: { rows: 30, cols: 40 }, + inputMode: input.inputMode, + }); + state = update.state; + routes.push(update.route); + } + return routes; +} + +function tapRelease( + overrides: Partial = {}, +): ReturnType { + return routeTerminalGestureRelease({ + status: "pressing", + startedWithSelection: false, + didScroll: false, + didNavigate: false, + movedBeyondTapTolerance: false, + pressDurationMs: 80, + longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, + scrollMode: "following", + point: { x: 25, y: 50 }, + metrics: { cellWidth: 8, cellHeight: 14 }, + viewport: { rows: 30, cols: 40 }, + inputMode: trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE), + ...overrides, + }); +} + describe("native terminal gesture policy", () => { describe("advanceTerminalScrollGesture", () => { it("keeps consuming a slow drag after scroll intent arms below one row", () => { @@ -351,4 +408,143 @@ describe("native terminal gesture policy", () => { ).toEqual("none"); }); }); + describe("routeTerminalScrollGesture", () => { + it("sends SGR wheel reports at the touched cell while a fullscreen app tracks the mouse", () => { + const inputMode = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE); + + expect(dragRoutes({ inputMode, dys: [13, 28, 42, 14] })).toEqual([ + { kind: "none" }, + { kind: "input", data: "\x1b[<64;4;4M\x1b[<64;4;4M" }, + { kind: "input", data: "\x1b[<64;4;4M" }, + { kind: "input", data: "\x1b[<65;4;4M\x1b[<65;4;4M" }, + ]); + }); + + it("keeps ordinary shell drags in local scrollback", () => { + const inputMode = trackerFromOutput("\x1b[?2004h$ ls\r\n"); + + expect(dragRoutes({ inputMode, dys: [13, 28, 14] })).toEqual([ + { kind: "none" }, + { kind: "scrollback", direction: "up", rows: 2 }, + { kind: "scrollback", direction: "down", rows: 1 }, + ]); + }); + + it("returns drags to local scrollback after the app disables mouse tracking", () => { + const inputMode = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE); + inputMode.feed(PI_FULLSCREEN_MOUSE_DISABLE); + + expect(dragRoutes({ inputMode, dys: [28] })).toEqual([ + { kind: "scrollback", direction: "up", rows: 2 }, + ]); + }); + + it("keeps forwarding wheel input after a reconnect replays the daemon preamble", () => { + const daemon = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE); + const client = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE); + client.reset(); + client.feed(daemon.getPreamble()); + + expect(dragRoutes({ inputMode: client, dys: [14] })).toEqual([ + { kind: "input", data: "\x1b[<64;4;4M" }, + ]); + }); + + it("keeps pixel reports integral at fractional cell boundaries", () => { + const inputMode = trackerFromOutput("\x1b[?1003h\x1b[?1016h"); + const { route } = routeTerminalScrollGesture({ + state: { lastDy: 0, rowRemainder: 0, didScroll: false }, + currentDy: 15, + point: { x: 999, y: 999 }, + metrics: { cellWidth: 8.25, cellHeight: 14.25 }, + viewport: { rows: 3, cols: 3 }, + inputMode, + }); + expect(route).toEqual({ kind: "input", data: "\x1b[<64;25;43M" }); + }); + + it("clamps wheel reports to the visible grid", () => { + const inputMode = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE); + + expect(dragRoutes({ inputMode, dys: [-14], point: { x: 999, y: -5 } })).toEqual([ + { kind: "input", data: "\x1b[<65;40;1M" }, + ]); + }); + }); + + describe("routeTerminalGestureRelease", () => { + it("clicks the tapped cell and still focuses while a fullscreen app tracks the mouse", () => { + expect(tapRelease()).toEqual({ + action: "focus", + mouseInput: "\x1b[<0;4;4M\x1b[<0;4;4m", + }); + }); + + it("only focuses on an ordinary shell tap", () => { + expect(tapRelease({ inputMode: trackerFromOutput("\x1b[?2004h$ ls\r\n") })).toEqual({ + action: "focus", + mouseInput: null, + }); + }); + + it("sends only the press for X10 tracking", () => { + expect(tapRelease({ inputMode: trackerFromOutput("\x1b[?9h") })).toEqual({ + action: "focus", + mouseInput: "\x1b[M $$", + }); + }); + + it("keeps a long press as local selection without clicking", () => { + expect(tapRelease({ pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50 })).toEqual({ + action: "select", + mouseInput: null, + }); + }); + + it.each([ + ["moved beyond tap tolerance", { movedBeyondTapTolerance: true }], + ["scrolled", { didScroll: true }], + ])("never clicks after a drag that %s", (_name, overrides) => { + expect(tapRelease(overrides)).toEqual({ action: "none", mouseInput: null }); + }); + + it("clears an existing selection without clicking", () => { + expect(tapRelease({ status: "selecting", startedWithSelection: true })).toEqual({ + action: "clear", + mouseInput: null, + }); + }); + + it("does not send live mouse input from a scrolled-back viewport", () => { + expect(tapRelease({ scrollMode: "scrolled" })).toEqual({ + action: "focus", + mouseInput: null, + }); + }); + + it("does not click before the grid is measured", () => { + expect(tapRelease({ metrics: null, viewport: null })).toEqual({ + action: "focus", + mouseInput: null, + }); + }); + + it("clamps clicks to the visible grid", () => { + expect(tapRelease({ point: { x: 999, y: -5 } })).toEqual({ + action: "focus", + mouseInput: "\x1b[<0;40;1M\x1b[<0;40;1m", + }); + }); + + it("keeps pixel clicks integral at fractional cell boundaries", () => { + expect( + tapRelease({ + inputMode: trackerFromOutput("\x1b[?1000h\x1b[?1016h"), + point: { x: 999, y: 999 }, + metrics: { cellWidth: 8.25, cellHeight: 14.25 }, + viewport: { rows: 3, cols: 3 }, + }), + ).toEqual({ action: "focus", mouseInput: "\x1b[<0;25;43M\x1b[<0;25;43m" }); + }); + }); }); diff --git a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts b/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts index 07b5267e87..15af429681 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts @@ -1,3 +1,9 @@ +import type { + TerminalInputModeTracker, + TerminalMousePosition, +} from "@getpaseo/protocol/terminal-input-mode"; +import type { TerminalGridCellMetrics } from "./terminal-grid-metrics"; + export type TerminalGestureStatus = "idle" | "pressing" | "selecting"; export type TerminalGestureIntent = "tap" | "pending" | "select" | "scroll"; export type TerminalGestureScrollMode = "following" | "scrolled"; @@ -33,8 +39,39 @@ export interface TerminalScrollGestureUpdate { rowDelta: number; } +export type TerminalScrollGestureRoute = + | { kind: "none" } + | { kind: "scrollback"; direction: "up" | "down"; rows: number } + | { kind: "input"; data: string }; + +export interface TerminalScrollGestureRouteInput { + state: TerminalScrollGestureState; + currentDy: number; + point: { x: number; y: number }; + metrics: TerminalGridCellMetrics; + viewport: { rows: number; cols: number }; + inputMode: Pick; +} + +export interface TerminalScrollGestureRouteUpdate { + state: TerminalScrollGestureState; + route: TerminalScrollGestureRoute; +} + export type TerminalGestureAction = "none" | "focus" | "select" | "clear"; +export interface TerminalGestureReleaseRouteInput extends TerminalGestureReleaseInput { + point: { x: number; y: number }; + metrics: TerminalGridCellMetrics | null; + viewport: { rows: number; cols: number } | null; + inputMode: Pick; +} + +export interface TerminalGestureReleaseRoute { + action: TerminalGestureAction; + mouseInput: string | null; +} + export const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8; export const TERMINAL_GESTURE_LONG_PRESS_MS = 450; export const TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX = 12; @@ -78,6 +115,60 @@ export function advanceTerminalScrollGesture(input: { }; } +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +/** Clamps a touch point to the visible grid; pixel reports stay integral at fractional cell sizes. */ +function resolveTerminalMousePosition(input: { + point: { x: number; y: number }; + metrics: TerminalGridCellMetrics; + viewport: { rows: number; cols: number }; +}): TerminalMousePosition { + const { cellWidth, cellHeight } = input.metrics; + const pixelX = clamp( + Math.floor(input.point.x), + 0, + Math.ceil(input.viewport.cols * cellWidth) - 1, + ); + const pixelY = clamp( + Math.floor(input.point.y), + 0, + Math.ceil(input.viewport.rows * cellHeight) - 1, + ); + return { + col: Math.floor(pixelX / cellWidth) + 1, + row: Math.floor(pixelY / cellHeight) + 1, + pixelX: pixelX + 1, + pixelY: pixelY + 1, + }; +} + +/** A drag scrolls local scrollback unless the foreground app asked for wheel reports. */ +export function routeTerminalScrollGesture( + input: TerminalScrollGestureRouteInput, +): TerminalScrollGestureRouteUpdate { + const { state, rowDelta } = advanceTerminalScrollGesture({ + state: input.state, + currentDy: input.currentDy, + cellHeight: input.metrics.cellHeight, + }); + if (rowDelta === 0) { + return { state, route: { kind: "none" } }; + } + + const direction = rowDelta > 0 ? "up" : "down"; + const rows = Math.abs(rowDelta); + const report = input.inputMode.encodeWheel({ + direction, + ...resolveTerminalMousePosition(input), + }); + if (report === null) { + return { state, route: { kind: "scrollback", direction, rows } }; + } + return { state, route: { kind: "input", data: report.repeat(rows) } }; +} + export function resolveTerminalGestureReleaseAction( input: TerminalGestureReleaseInput, ): TerminalGestureAction { @@ -106,3 +197,19 @@ export function resolveTerminalGestureReleaseAction( return "focus"; } + +/** + * Only a short tap on the live screen clicks; long press, drags and selection clears stay local. + * The caller sends the click before focusing, so the keyboard resize cannot move the target cell. + */ +export function routeTerminalGestureRelease( + input: TerminalGestureReleaseRouteInput, +): TerminalGestureReleaseRoute { + const action = resolveTerminalGestureReleaseAction(input); + const { metrics, viewport } = input; + if (action !== "focus" || input.scrollMode !== "following" || !metrics || !viewport) { + return { action, mouseInput: null }; + } + const position = resolveTerminalMousePosition({ point: input.point, metrics, viewport }); + return { action, mouseInput: input.inputMode.encodeLeftClick(position) }; +} diff --git a/packages/protocol/src/terminal-input-mode.test.ts b/packages/protocol/src/terminal-input-mode.test.ts index f17b6b7e7b..98af499b2f 100644 --- a/packages/protocol/src/terminal-input-mode.test.ts +++ b/packages/protocol/src/terminal-input-mode.test.ts @@ -118,6 +118,111 @@ describe("TerminalInputModeTracker", () => { expect(tracker.getPreamble()).toBe(""); }); + const PI_FULLSCREEN_MOUSE_ENABLE = + "\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h"; + const PI_FULLSCREEN_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l"; + const wheelUpAt3x4 = { direction: "up" as const, col: 3, row: 4, pixelX: 30, pixelY: 80 }; + + it("encodes SGR wheel reports while a fullscreen app tracks the mouse", () => { + const tracker = new TerminalInputModeTracker(); + expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull(); + + expect(tracker.feed(PI_FULLSCREEN_MOUSE_ENABLE).changed).toBe(true); + + expect(tracker.encodeWheel(wheelUpAt3x4)).toBe("\x1b[<64;3;4M"); + expect(tracker.encodeWheel({ ...wheelUpAt3x4, direction: "down" })).toBe("\x1b[<65;3;4M"); + }); + + it("stops encoding wheel reports once the app disables mouse tracking", () => { + const tracker = new TerminalInputModeTracker(); + tracker.feed(PI_FULLSCREEN_MOUSE_ENABLE); + + expect(tracker.feed(PI_FULLSCREEN_MOUSE_DISABLE).changed).toBe(true); + + expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull(); + expect(tracker.getPreamble()).toBe(""); + }); + + it("replays mouse tracking so a reconnected client keeps forwarding wheel input", () => { + const daemon = new TerminalInputModeTracker(); + daemon.feed(PI_FULLSCREEN_MOUSE_ENABLE); + const client = new TerminalInputModeTracker(); + client.feed(PI_FULLSCREEN_MOUSE_ENABLE); + + client.reset(); + expect(client.encodeWheel(wheelUpAt3x4)).toBeNull(); + expect(daemon.getPreamble()).toBe("\x1b[?1003h\x1b[?1006h"); + client.feed(daemon.getPreamble()); + + expect(client.encodeWheel(wheelUpAt3x4)).toBe("\x1b[<64;3;4M"); + }); + + it("encodes SGR pixel wheel reports with pixel coordinates", () => { + const tracker = new TerminalInputModeTracker(); + tracker.feed("\x1b[?1000h\x1b[?1016h"); + + expect(tracker.encodeWheel(wheelUpAt3x4)).toBe("\x1b[<64;30;80M"); + expect(tracker.getPreamble()).toBe("\x1b[?1000h\x1b[?1016h"); + }); + + it("encodes default wheel reports only while every byte stays ASCII", () => { + const tracker = new TerminalInputModeTracker(); + tracker.feed("\x1b[?1000h"); + + expect(tracker.encodeWheel(wheelUpAt3x4)).toBe("\x1b[M`#$"); + expect(tracker.encodeWheel({ ...wheelUpAt3x4, col: 96 })).toBeNull(); + expect(tracker.encodeWheel({ ...wheelUpAt3x4, row: 96 })).toBeNull(); + }); + + it("does not encode wheel reports for X10 press-only tracking", () => { + const tracker = new TerminalInputModeTracker(); + tracker.feed("\x1b[?9h\x1b[?1006h"); + + expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull(); + }); + + const tapAt3x4 = { col: 3, row: 4, pixelX: 30, pixelY: 80 }; + + it.each([ + ["no tracking", "", null], + ["SGR press/release", PI_FULLSCREEN_MOUSE_ENABLE, "\x1b[<0;3;4M\x1b[<0;3;4m"], + ["SGR pixel press/release", "\x1b[?1000h\x1b[?1016h", "\x1b[<0;30;80M\x1b[<0;30;80m"], + ["default press and button-3 release", "\x1b[?1000h", "\x1b[M #$\x1b[M##$"], + ["X10 default press only", "\x1b[?9h", "\x1b[M #$"], + ["X10 SGR press only", "\x1b[?9h\x1b[?1006h", "\x1b[<0;3;4M"], + [ + "tracking disabled again", + `${PI_FULLSCREEN_MOUSE_ENABLE}${PI_FULLSCREEN_MOUSE_DISABLE}`, + null, + ], + ])("encodes a left click with %s", (_name, output, expected) => { + const tracker = new TerminalInputModeTracker(); + tracker.feed(output); + + expect(tracker.encodeLeftClick(tapAt3x4)).toBe(expected); + }); + + it("does not encode a default left click that leaves ASCII", () => { + const tracker = new TerminalInputModeTracker(); + tracker.feed("\x1b[?1000h"); + + expect(tracker.encodeLeftClick({ ...tapAt3x4, col: 96 })).toBeNull(); + }); + + it.each([["\x1bc"], ["\x1b", "c"]])( + "clears every tracked mode on a full reset split as %j", + (...chunks) => { + const tracker = new TerminalInputModeTracker(); + tracker.feed(`\x1b[>7u\x1b[?2004h${PI_FULLSCREEN_MOUSE_ENABLE}`); + + const results = chunks.map((chunk) => tracker.feed(chunk)); + + expect(results.at(-1)).toEqual({ changed: true, responses: [] }); + expect(tracker.getPreamble()).toBe(""); + expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull(); + }, + ); + it("ignores encoded key input sequences", () => { const tracker = new TerminalInputModeTracker(); diff --git a/packages/protocol/src/terminal-input-mode.ts b/packages/protocol/src/terminal-input-mode.ts index f35156eb5c..aef7d22f6c 100644 --- a/packages/protocol/src/terminal-input-mode.ts +++ b/packages/protocol/src/terminal-input-mode.ts @@ -10,6 +10,17 @@ export interface TerminalInputModeState { bracketedPaste?: boolean; } +export interface TerminalMousePosition { + col: number; + row: number; + pixelX: number; + pixelY: number; +} + +export interface TerminalWheelInput extends TerminalMousePosition { + direction: "up" | "down"; +} + export const DEFAULT_TERMINAL_INPUT_MODE_STATE: TerminalInputModeState = { kittyKeyboardFlags: 0, win32InputMode: false, @@ -21,8 +32,20 @@ const ESC = String.fromCharCode(0x1b); const APPLICATION_CURSOR_KEYS_MODE = 1; const WIN32_INPUT_MODE = 9001; const BRACKETED_PASTE_MODE = 2004; -const CSI_INPUT_MODE_SEQUENCE = new RegExp( - `${ESC}\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl]))`, +const X10_MOUSE_TRACKING_MODE = 9; +const MOUSE_TRACKING_MODES = new Set([X10_MOUSE_TRACKING_MODE, 1000, 1002, 1003]); +const SGR_MOUSE_ENCODING_MODE = 1006; +const SGR_PIXELS_MOUSE_ENCODING_MODE = 1016; +const MOUSE_ENCODING_MODES = new Set([SGR_MOUSE_ENCODING_MODE, SGR_PIXELS_MOUSE_ENCODING_MODE]); +const LEFT_BUTTON = 0; +const DEFAULT_RELEASE_BUTTON = 3; +const WHEEL_UP_BUTTON = 64; +const WHEEL_DOWN_BUTTON = 65; +const DEFAULT_MOUSE_ENCODING_OFFSET = 32; +const MAX_ASCII_CODE = 127; +const FULL_RESET = `${ESC}c`; +const INPUT_MODE_SEQUENCE = new RegExp( + `${ESC}(?:c|\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl])))`, "g", ); const INCOMPLETE_CSI_INPUT_MODE_SEQUENCE = new RegExp(`${ESC}\\[[<>=?]?[0-9;]*$`); @@ -74,6 +97,8 @@ export class TerminalInputModeTracker { private win32InputMode = false; private applicationCursorKeys = false; private bracketedPaste = false; + private mouseTrackingMode = 0; + private mouseEncodingMode = 0; private readonly kittyKeyboardStack: number[] = []; private pending = ""; @@ -89,13 +114,19 @@ export class TerminalInputModeTracker { const responses: string[] = []; let consumedUntil = 0; - CSI_INPUT_MODE_SEQUENCE.lastIndex = 0; + INPUT_MODE_SEQUENCE.lastIndex = 0; for (;;) { - const match = CSI_INPUT_MODE_SEQUENCE.exec(text); + const match = INPUT_MODE_SEQUENCE.exec(text); if (!match) { break; } - consumedUntil = CSI_INPUT_MODE_SEQUENCE.lastIndex; + consumedUntil = INPUT_MODE_SEQUENCE.lastIndex; + + if (match[0] === FULL_RESET) { + changed = this.getPreamble().length > 0 || changed; + this.reset(); + continue; + } if (match[4]) { changed = this.applyPrivateModeSequence(match[3] ?? "", match[4]) || changed; @@ -108,10 +139,10 @@ export class TerminalInputModeTracker { } const tail = text.slice(consumedUntil); - const pendingStart = tail.lastIndexOf(`${ESC}[`); + const pendingStart = tail.lastIndexOf(ESC); if (pendingStart >= 0) { const pending = tail.slice(pendingStart); - if (INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) { + if (pending === ESC || INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) { this.pending = pending; } } @@ -124,6 +155,8 @@ export class TerminalInputModeTracker { this.win32InputMode = false; this.applicationCursorKeys = false; this.bracketedPaste = false; + this.mouseTrackingMode = 0; + this.mouseEncodingMode = 0; this.kittyKeyboardStack.length = 0; this.pending = ""; } @@ -159,9 +192,64 @@ export class TerminalInputModeTracker { if (this.bracketedPaste) { parts.push("\x1b[?2004h"); } + // The alternate screen is never replayed here: re-entering it would clear the restored screen. + if (this.mouseTrackingMode > 0) { + parts.push(`\x1b[?${this.mouseTrackingMode}h`); + } + if (this.mouseEncodingMode > 0) { + parts.push(`\x1b[?${this.mouseEncodingMode}h`); + } return parts.join(""); } + /** Returns the wheel report the foreground app requested, or null when it does not track the wheel. */ + encodeWheel(input: TerminalWheelInput): string | null { + const isWheelReported = + this.mouseTrackingMode > 0 && this.mouseTrackingMode !== X10_MOUSE_TRACKING_MODE; + if (!isWheelReported) { + return null; + } + const button = input.direction === "up" ? WHEEL_UP_BUTTON : WHEEL_DOWN_BUTTON; + return this.encodeMouseReport(button, input, "M"); + } + + /** Returns a left press and release, press only under X10, or null when the app does not track the mouse. */ + encodeLeftClick(position: TerminalMousePosition): string | null { + if (this.mouseTrackingMode === 0) { + return null; + } + const press = this.encodeMouseReport(LEFT_BUTTON, position, "M"); + if (press === null || this.mouseTrackingMode === X10_MOUSE_TRACKING_MODE) { + return press; + } + const isSgr = this.mouseEncodingMode !== 0; + const release = isSgr + ? this.encodeMouseReport(LEFT_BUTTON, position, "m") + : this.encodeMouseReport(DEFAULT_RELEASE_BUTTON, position, "M"); + return release === null ? null : `${press}${release}`; + } + + private encodeMouseReport( + button: number, + position: TerminalMousePosition, + sgrFinal: "M" | "m", + ): string | null { + if (this.mouseEncodingMode === SGR_PIXELS_MOUSE_ENCODING_MODE) { + return `\x1b[<${button};${position.pixelX};${position.pixelY}${sgrFinal}`; + } + if (this.mouseEncodingMode === SGR_MOUSE_ENCODING_MODE) { + return `\x1b[<${button};${position.col};${position.row}${sgrFinal}`; + } + // Input travels as UTF-8 text, so a default-encoded byte above ASCII would be re-encoded. + const codes = [button, position.col, position.row].map( + (value) => value + DEFAULT_MOUSE_ENCODING_OFFSET, + ); + if (codes.some((code) => code > MAX_ASCII_CODE)) { + return null; + } + return `\x1b[M${String.fromCharCode(...codes)}`; + } + private applyKittyKeyboardSequence( prefix: string, params: string, @@ -205,6 +293,18 @@ export class TerminalInputModeTracker { const modes = parsePrivateModeParams(params); let changed = false; + for (const mode of modes) { + const next = final === "h" ? mode : 0; + if (MOUSE_TRACKING_MODES.has(mode)) { + changed = this.mouseTrackingMode !== next || changed; + this.mouseTrackingMode = next; + } + if (MOUSE_ENCODING_MODES.has(mode)) { + changed = this.mouseEncodingMode !== next || changed; + this.mouseEncodingMode = next; + } + } + if (modes.has(WIN32_INPUT_MODE)) { const previous = this.win32InputMode; this.win32InputMode = final === "h";