Files
paseo/packages/protocol/src/terminal-input-mode.ts
T
Mohamed BoudraandGitHub 04fe30a3e2 Improve mobile terminal input, selection, and resizing (#1607)
* Improve mobile terminal typing and selection

* Address mobile terminal review feedback

* Remove dead native terminal input branch

* Replay bracketed paste in terminal preamble

* Stop per-key terminal resize claims

* Stabilize terminal resize browser test

* Use tracked terminal mode for native paste

* Restore terminal toolbar focus handling

* Adapt native terminal to active visibility state

* Stabilize native mobile terminal interactions

* Add native terminal fallback boundary

* test(app): expect explicit terminal resize reclaim

* fix(app): stabilize terminal buffer coordinates

* fix(app): restrict terminal resize ownership

* fix(terminal): gate restored input modes

* fix(app): recover stalled terminal WebViews

* chore(app): remove obsolete terminal harnesses

* fix(app): make terminal resize claims explicit

* test(app): stabilize native terminal device flows

* test(app): track absolute scrollback coordinates

* fix(app): isolate terminal WebView streams

* fix(terminal): arbitrate size ownership across clients

The daemon now distinguishes explicit ownership claims from passive geometry updates. This keeps the active client authoritative while allowing its split, viewport, and keyboard changes to resize the PTY without idle clients stealing it.

* test(server): make worker resize assertion portable

Query the worker's authoritative terminal snapshot instead of invoking a Unix-only shell command, so the resize regression exercises the same boundary on Windows and Unix.
2026-08-03 19:01:34 +08:00

229 lines
6.2 KiB
TypeScript

export interface TerminalInputModeFeedResult {
changed: boolean;
responses: string[];
}
export interface TerminalInputModeState {
kittyKeyboardFlags: number;
win32InputMode: boolean;
applicationCursorKeys?: boolean;
bracketedPaste?: boolean;
}
export const DEFAULT_TERMINAL_INPUT_MODE_STATE: TerminalInputModeState = {
kittyKeyboardFlags: 0,
win32InputMode: false,
applicationCursorKeys: false,
bracketedPaste: false,
};
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]))`,
"g",
);
const INCOMPLETE_CSI_INPUT_MODE_SEQUENCE = new RegExp(`${ESC}\\[[<>=?]?[0-9;]*$`);
function parseFirstParam(params: string): number | null {
const first = params.split(";")[0];
if (!first || !/^\d+$/.test(first)) {
return null;
}
return Number(first);
}
function parseSecondParam(params: string): number | null {
const second = params.split(";")[1];
if (!second || !/^\d+$/.test(second)) {
return null;
}
return Number(second);
}
function parsePrivateModeParams(params: string): Set<number> {
const modes = new Set<number>();
for (const param of params.split(";")) {
if (/^\d+$/.test(param)) {
modes.add(Number(param));
}
}
return modes;
}
export function terminalInputModeSupportsModifiedEnter(state: TerminalInputModeState): boolean {
return state.kittyKeyboardFlags > 0 || state.win32InputMode;
}
export function terminalInputModeStatesEqual(
left: TerminalInputModeState,
right: TerminalInputModeState,
): boolean {
return (
left.kittyKeyboardFlags === right.kittyKeyboardFlags &&
left.win32InputMode === right.win32InputMode &&
Boolean(left.applicationCursorKeys) === Boolean(right.applicationCursorKeys) &&
Boolean(left.bracketedPaste) === Boolean(right.bracketedPaste)
);
}
export class TerminalInputModeTracker {
private kittyKeyboardFlags = 0;
private win32InputMode = false;
private applicationCursorKeys = false;
private bracketedPaste = false;
private readonly kittyKeyboardStack: number[] = [];
private pending = "";
feed(data: string): TerminalInputModeFeedResult {
if (data.length === 0) {
return { changed: false, responses: [] };
}
const text = `${this.pending}${data}`;
this.pending = "";
let changed = false;
const responses: string[] = [];
let consumedUntil = 0;
CSI_INPUT_MODE_SEQUENCE.lastIndex = 0;
for (;;) {
const match = CSI_INPUT_MODE_SEQUENCE.exec(text);
if (!match) {
break;
}
consumedUntil = CSI_INPUT_MODE_SEQUENCE.lastIndex;
if (match[4]) {
changed = this.applyPrivateModeSequence(match[3] ?? "", match[4]) || changed;
continue;
}
const result = this.applyKittyKeyboardSequence(match[1] ?? "", match[2] ?? "");
changed = changed || result.changed;
responses.push(...result.responses);
}
const tail = text.slice(consumedUntil);
const pendingStart = tail.lastIndexOf(`${ESC}[`);
if (pendingStart >= 0) {
const pending = tail.slice(pendingStart);
if (INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) {
this.pending = pending;
}
}
return { changed, responses };
}
reset(): void {
this.kittyKeyboardFlags = 0;
this.win32InputMode = false;
this.applicationCursorKeys = false;
this.bracketedPaste = false;
this.kittyKeyboardStack.length = 0;
this.pending = "";
}
getState(): TerminalInputModeState {
return {
kittyKeyboardFlags: this.kittyKeyboardFlags,
win32InputMode: this.win32InputMode,
applicationCursorKeys: this.applicationCursorKeys,
bracketedPaste: this.bracketedPaste,
};
}
getKittyKeyboardFlags(): number {
return this.kittyKeyboardFlags;
}
supportsModifiedEnter(): boolean {
return terminalInputModeSupportsModifiedEnter(this.getState());
}
getPreamble(): string {
const parts: string[] = [];
if (this.kittyKeyboardFlags > 0) {
parts.push(`\x1b[=${this.kittyKeyboardFlags};1u`);
}
if (this.win32InputMode) {
parts.push("\x1b[?9001h");
}
if (this.applicationCursorKeys) {
parts.push("\x1b[?1h");
}
if (this.bracketedPaste) {
parts.push("\x1b[?2004h");
}
return parts.join("");
}
private applyKittyKeyboardSequence(
prefix: string,
params: string,
): { changed: boolean; responses: string[] } {
const previousFlags = this.kittyKeyboardFlags;
switch (prefix) {
case ">": {
this.kittyKeyboardStack.push(this.kittyKeyboardFlags);
this.kittyKeyboardFlags = parseFirstParam(params) ?? 1;
break;
}
case "=": {
const mode = parseSecondParam(params) ?? 1;
this.kittyKeyboardFlags = mode === 0 ? 0 : (parseFirstParam(params) ?? 0);
break;
}
case "<": {
const count = Math.max(1, parseFirstParam(params) ?? 1);
for (let index = 0; index < count; index += 1) {
this.kittyKeyboardFlags = this.kittyKeyboardStack.pop() ?? 0;
}
break;
}
case "?":
return {
changed: false,
responses: [`\x1b[?${this.kittyKeyboardFlags}u`],
};
default:
return { changed: false, responses: [] };
}
return {
changed: this.kittyKeyboardFlags !== previousFlags,
responses: [],
};
}
private applyPrivateModeSequence(params: string, final: string): boolean {
const modes = parsePrivateModeParams(params);
let changed = false;
if (modes.has(WIN32_INPUT_MODE)) {
const previous = this.win32InputMode;
this.win32InputMode = final === "h";
changed = this.win32InputMode !== previous || changed;
}
if (modes.has(APPLICATION_CURSOR_KEYS_MODE)) {
const previous = this.applicationCursorKeys;
this.applicationCursorKeys = final === "h";
changed = this.applicationCursorKeys !== previous || changed;
}
if (modes.has(BRACKETED_PASTE_MODE)) {
const previous = this.bracketedPaste;
this.bracketedPaste = final === "h";
changed = this.bracketedPaste !== previous || changed;
}
return changed;
}
}