Compare commits

...
Author SHA1 Message Date
an 96020ab773 fix(terminal): forward native touch gestures to mouse-aware apps
Fullscreen applications request wheel reports, while native touch drags
previously moved only local scrollback. Route gestures through the
existing terminal input path and preserve the requested modes on replay.

Co-authored-by: an <4361526@naver.com>
2026-10-24 04:52:34 +02:00
5 changed files with 555 additions and 32 deletions
@@ -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<TerminalInputModeState>({
@@ -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,
],
);
@@ -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<TerminalGestureReleaseRouteInput> = {},
): ReturnType<typeof routeTerminalGestureRelease> {
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" });
});
});
});
@@ -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<TerminalInputModeTracker, "encodeWheel">;
}
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<TerminalInputModeTracker, "encodeLeftClick">;
}
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) };
}
@@ -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();
+107 -7
View File
@@ -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";