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.
This commit is contained in:
@@ -31,16 +31,16 @@ describe("terminal binary frames", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips resize payloads", () => {
|
||||
const payload = encodeTerminalResizePayload({
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
it.each(["claim", "update"] as const)("round-trips %s resize payloads", (intent) => {
|
||||
const payload = encodeTerminalResizePayload({ rows: 24, cols: 80, intent });
|
||||
|
||||
expect(decodeTerminalResizePayload(payload)).toEqual({
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
expect(decodeTerminalResizePayload(payload)).toEqual({ rows: 24, cols: 80, intent });
|
||||
});
|
||||
|
||||
it("accepts legacy resize payloads without an intent", () => {
|
||||
const payload = encodeTerminalResizePayload({ rows: 24, cols: 80 });
|
||||
|
||||
expect(decodeTerminalResizePayload(payload)).toEqual({ rows: 24, cols: 80 });
|
||||
});
|
||||
|
||||
it("round-trips snapshot payloads", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TerminalStateSchema } from "../messages.js";
|
||||
export const TerminalStreamResizeSchema = z.object({
|
||||
rows: z.number().int().positive(),
|
||||
cols: z.number().int().positive(),
|
||||
intent: z.enum(["claim", "update"]).optional(),
|
||||
});
|
||||
|
||||
export const TerminalStreamOpcode = {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TerminalInputSchema } from "./messages.js";
|
||||
|
||||
describe("terminal resize messages", () => {
|
||||
it.each(["claim", "update"] as const)("accepts the optional %s intent", (intent) => {
|
||||
const result = TerminalInputSchema.safeParse({
|
||||
type: "terminal_input",
|
||||
terminalId: "terminal-1",
|
||||
message: { type: "resize", rows: 24, cols: 80, intent },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
message: { type: "resize", rows: 24, cols: 80, intent },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts legacy resize messages without an intent", () => {
|
||||
const result = TerminalInputSchema.safeParse({
|
||||
type: "terminal_input",
|
||||
terminalId: "terminal-1",
|
||||
message: { type: "resize", rows: 24, cols: 80 },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
message: { type: "resize", rows: 24, cols: 80 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,4 +58,28 @@ describe("terminal restore schemas", () => {
|
||||
}).features?.["terminal-restore-modes"],
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps terminal input mode replay metadata optional for older daemons", () => {
|
||||
const parsed = ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "server-1",
|
||||
features: {
|
||||
"terminal-restore-modes": true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.features?.["terminal-input-mode-replay"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("accepts terminal input mode replay feature metadata", () => {
|
||||
expect(
|
||||
ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "server-1",
|
||||
features: {
|
||||
"terminal-input-mode-replay": true,
|
||||
},
|
||||
}).features?.["terminal-input-mode-replay"],
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2434,7 +2434,12 @@ export const UnsubscribeTerminalRequestSchema = z.object({
|
||||
|
||||
const TerminalClientMessageSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("input"), data: z.string() }),
|
||||
z.object({ type: z.literal("resize"), rows: z.number(), cols: z.number() }),
|
||||
z.object({
|
||||
type: z.literal("resize"),
|
||||
rows: z.number(),
|
||||
cols: z.number(),
|
||||
intent: z.enum(["claim", "update"]).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("mouse"),
|
||||
row: z.number(),
|
||||
@@ -2839,6 +2844,10 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
relayConfig: z.boolean().optional(),
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
"terminal-restore-modes": z.boolean().optional(),
|
||||
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
|
||||
"terminal-input-mode-replay": z.boolean().optional(),
|
||||
// COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02.
|
||||
"terminal-size-ownership": z.boolean().optional(),
|
||||
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
|
||||
rewind: z.boolean().optional(),
|
||||
// COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X.
|
||||
|
||||
@@ -51,6 +51,8 @@ describe("TerminalInputModeTracker", () => {
|
||||
expect(tracker.getState()).toEqual({
|
||||
kittyKeyboardFlags: 0,
|
||||
win32InputMode: true,
|
||||
applicationCursorKeys: false,
|
||||
bracketedPaste: false,
|
||||
});
|
||||
expect(tracker.supportsModifiedEnter()).toBe(true);
|
||||
expect(tracker.getPreamble()).toBe("\x1b[?9001h");
|
||||
@@ -67,10 +69,55 @@ describe("TerminalInputModeTracker", () => {
|
||||
expect(tracker.getState()).toEqual({
|
||||
kittyKeyboardFlags: 7,
|
||||
win32InputMode: true,
|
||||
applicationCursorKeys: false,
|
||||
bracketedPaste: false,
|
||||
});
|
||||
expect(tracker.getPreamble()).toBe("\x1b[=7;1u\x1b[?9001h");
|
||||
});
|
||||
|
||||
it("tracks application cursor keys mode independently", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
|
||||
expect(tracker.feed("\x1b[?1h").changed).toBe(true);
|
||||
expect(tracker.getState()).toEqual({
|
||||
kittyKeyboardFlags: 0,
|
||||
win32InputMode: false,
|
||||
applicationCursorKeys: true,
|
||||
bracketedPaste: false,
|
||||
});
|
||||
expect(tracker.getPreamble()).toBe("\x1b[?1h");
|
||||
|
||||
expect(tracker.feed("\x1b[?1l").changed).toBe(true);
|
||||
expect(tracker.getState()).toEqual({
|
||||
kittyKeyboardFlags: 0,
|
||||
win32InputMode: false,
|
||||
applicationCursorKeys: false,
|
||||
bracketedPaste: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks bracketed paste mode independently", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
|
||||
expect(tracker.feed("\x1b[?2004h").changed).toBe(true);
|
||||
expect(tracker.getState()).toEqual({
|
||||
kittyKeyboardFlags: 0,
|
||||
win32InputMode: false,
|
||||
applicationCursorKeys: false,
|
||||
bracketedPaste: true,
|
||||
});
|
||||
expect(tracker.getPreamble()).toBe("\x1b[?2004h");
|
||||
|
||||
expect(tracker.feed("\x1b[?2004l").changed).toBe(true);
|
||||
expect(tracker.getState()).toEqual({
|
||||
kittyKeyboardFlags: 0,
|
||||
win32InputMode: false,
|
||||
applicationCursorKeys: false,
|
||||
bracketedPaste: false,
|
||||
});
|
||||
expect(tracker.getPreamble()).toBe("");
|
||||
});
|
||||
|
||||
it("ignores encoded key input sequences", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
|
||||
|
||||
@@ -6,15 +6,21 @@ export interface TerminalInputModeFeedResult {
|
||||
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",
|
||||
@@ -57,13 +63,17 @@ export function terminalInputModeStatesEqual(
|
||||
): boolean {
|
||||
return (
|
||||
left.kittyKeyboardFlags === right.kittyKeyboardFlags &&
|
||||
left.win32InputMode === right.win32InputMode
|
||||
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 = "";
|
||||
|
||||
@@ -112,6 +122,8 @@ export class TerminalInputModeTracker {
|
||||
reset(): void {
|
||||
this.kittyKeyboardFlags = 0;
|
||||
this.win32InputMode = false;
|
||||
this.applicationCursorKeys = false;
|
||||
this.bracketedPaste = false;
|
||||
this.kittyKeyboardStack.length = 0;
|
||||
this.pending = "";
|
||||
}
|
||||
@@ -120,6 +132,8 @@ export class TerminalInputModeTracker {
|
||||
return {
|
||||
kittyKeyboardFlags: this.kittyKeyboardFlags,
|
||||
win32InputMode: this.win32InputMode,
|
||||
applicationCursorKeys: this.applicationCursorKeys,
|
||||
bracketedPaste: this.bracketedPaste,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,6 +153,12 @@ export class TerminalInputModeTracker {
|
||||
if (this.win32InputMode) {
|
||||
parts.push("\x1b[?9001h");
|
||||
}
|
||||
if (this.applicationCursorKeys) {
|
||||
parts.push("\x1b[?1h");
|
||||
}
|
||||
if (this.bracketedPaste) {
|
||||
parts.push("\x1b[?2004h");
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
@@ -183,12 +203,26 @@ export class TerminalInputModeTracker {
|
||||
|
||||
private applyPrivateModeSequence(params: string, final: string): boolean {
|
||||
const modes = parsePrivateModeParams(params);
|
||||
if (!modes.has(WIN32_INPUT_MODE)) {
|
||||
return false;
|
||||
let changed = false;
|
||||
|
||||
if (modes.has(WIN32_INPUT_MODE)) {
|
||||
const previous = this.win32InputMode;
|
||||
this.win32InputMode = final === "h";
|
||||
changed = this.win32InputMode !== previous || changed;
|
||||
}
|
||||
|
||||
const previous = this.win32InputMode;
|
||||
this.win32InputMode = final === "h";
|
||||
return this.win32InputMode !== previous;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,58 @@ import { describe, expect, it } from "vitest";
|
||||
import { encodeTerminalKeyInput } from "./terminal-key-input.js";
|
||||
|
||||
describe("encodeTerminalKeyInput", () => {
|
||||
it("encodes plain arrows as CSI when application cursor mode is inactive", () => {
|
||||
const options = { inputMode: { kittyKeyboardFlags: 0, win32InputMode: false } };
|
||||
|
||||
expect({
|
||||
up: encodeTerminalKeyInput({ key: "ArrowUp" }, options),
|
||||
down: encodeTerminalKeyInput({ key: "ArrowDown" }, options),
|
||||
left: encodeTerminalKeyInput({ key: "ArrowLeft" }, options),
|
||||
right: encodeTerminalKeyInput({ key: "ArrowRight" }, options),
|
||||
}).toEqual({
|
||||
up: "\x1b[A",
|
||||
down: "\x1b[B",
|
||||
left: "\x1b[D",
|
||||
right: "\x1b[C",
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes plain arrows as SS3 when application cursor mode is active", () => {
|
||||
const options = {
|
||||
inputMode: { kittyKeyboardFlags: 0, win32InputMode: false, applicationCursorKeys: true },
|
||||
};
|
||||
|
||||
expect({
|
||||
up: encodeTerminalKeyInput({ key: "ArrowUp" }, options),
|
||||
down: encodeTerminalKeyInput({ key: "ArrowDown" }, options),
|
||||
left: encodeTerminalKeyInput({ key: "ArrowLeft" }, options),
|
||||
right: encodeTerminalKeyInput({ key: "ArrowRight" }, options),
|
||||
}).toEqual({
|
||||
up: "\x1bOA",
|
||||
down: "\x1bOB",
|
||||
left: "\x1bOD",
|
||||
right: "\x1bOC",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps modified arrows on CSI modifier sequences in application cursor mode", () => {
|
||||
const options = {
|
||||
inputMode: { kittyKeyboardFlags: 0, win32InputMode: false, applicationCursorKeys: true },
|
||||
};
|
||||
|
||||
expect({
|
||||
shiftLeft: encodeTerminalKeyInput({ key: "ArrowLeft", shift: true }, options),
|
||||
altDown: encodeTerminalKeyInput({ key: "ArrowDown", alt: true }, options),
|
||||
ctrlRight: encodeTerminalKeyInput({ key: "ArrowRight", ctrl: true }, options),
|
||||
metaUp: encodeTerminalKeyInput({ key: "ArrowUp", meta: true }, options),
|
||||
}).toEqual({
|
||||
shiftLeft: "\x1b[1;2D",
|
||||
altDown: "\x1b[1;3B",
|
||||
ctrlRight: "\x1b[1;5C",
|
||||
metaUp: "\x1b[1;9A",
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes ctrl+b for tmux prefix", () => {
|
||||
expect(encodeTerminalKeyInput({ key: "b", ctrl: true })).toBe("\x02");
|
||||
});
|
||||
|
||||
@@ -125,6 +125,10 @@ function csiWithModifier(finalByte: string, input: TerminalKeyInput): string {
|
||||
return mod === 1 ? `\x1b[${finalByte}` : `\x1b[1;${mod}${finalByte}`;
|
||||
}
|
||||
|
||||
function ss3WithModifier(finalByte: string, input: TerminalKeyInput): string {
|
||||
return modifierParam(input) === 1 ? `\x1bO${finalByte}` : csiWithModifier(finalByte, input);
|
||||
}
|
||||
|
||||
function csiTilde(base: number, input: TerminalKeyInput): string {
|
||||
const mod = modifierParam(input);
|
||||
return mod === 1 ? `\x1b[${base}~` : `\x1b[${base};${mod}~`;
|
||||
@@ -161,16 +165,31 @@ function encodeFunctionKey(key: string, input: TerminalKeyInput): string | null
|
||||
}
|
||||
}
|
||||
|
||||
function encodeNavigationKey(key: string, input: TerminalKeyInput): string | null {
|
||||
function encodeArrowKey(
|
||||
finalByte: string,
|
||||
input: TerminalKeyInput,
|
||||
options: TerminalKeyInputEncodingOptions,
|
||||
): string {
|
||||
if (options.inputMode?.applicationCursorKeys) {
|
||||
return ss3WithModifier(finalByte, input);
|
||||
}
|
||||
return csiWithModifier(finalByte, input);
|
||||
}
|
||||
|
||||
function encodeNavigationKey(
|
||||
key: string,
|
||||
input: TerminalKeyInput,
|
||||
options: TerminalKeyInputEncodingOptions,
|
||||
): string | null {
|
||||
switch (key) {
|
||||
case "ArrowUp":
|
||||
return csiWithModifier("A", input);
|
||||
return encodeArrowKey("A", input, options);
|
||||
case "ArrowDown":
|
||||
return csiWithModifier("B", input);
|
||||
return encodeArrowKey("B", input, options);
|
||||
case "ArrowRight":
|
||||
return csiWithModifier("C", input);
|
||||
return encodeArrowKey("C", input, options);
|
||||
case "ArrowLeft":
|
||||
return csiWithModifier("D", input);
|
||||
return encodeArrowKey("D", input, options);
|
||||
case "Home":
|
||||
return csiWithModifier("H", input);
|
||||
case "End":
|
||||
@@ -225,7 +244,7 @@ export function encodeTerminalKeyInput(
|
||||
break;
|
||||
}
|
||||
|
||||
const nav = encodeNavigationKey(key, input);
|
||||
const nav = encodeNavigationKey(key, input, options);
|
||||
if (nav !== null) return nav;
|
||||
const fn = encodeFunctionKey(key, input);
|
||||
if (fn !== null) return fn;
|
||||
|
||||
Reference in New Issue
Block a user