From ef934754178d79551d77810419cb50f3ccabd968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20Lappetel=C3=A4inen?= Date: Wed, 2 Sep 2026 13:02:23 +0300 Subject: [PATCH 1/7] fix(server): reset terminal modes a dead shell leaves in inherited history When the server goes away while a TUI is running in a terminal, the shell dies with it and no teardown output is written. The persisted history then ends with whatever the app had turned on: Codex CLI's Kitty keyboard push (`CSI > 7 u`), Claude Code's mouse and focus tracking (`CSI ? 1003 h`, `CSI ? 1004 h`), a hidden cursor, the alternate screen. On the next open the server spawns a fresh shell on top of that history and the client replays it into a new emulator, which then sends key releases (`CSI 112;1:3u`), mouse reports (`CSI < 35;66;1 M`), and focus reports to a shell that never asked. zsh echoes them as garbage and reports "command not found". When loading persisted history for a new session, append resets for every tracked DEC private mode the history leaves deviating from its power-on default, leaving the alternate screen first, and zero the Kitty flags with `CSI = 0 ; 1 u` when the replayed Kitty stack ends with flags set. RIS clears the tracking; DECSTR is ignored because libghostty-vt leaves all of these modes alone on DECSTR. Programs that want a mode again set their own. Verified against the vendored libghostty-vt that the Kitty reset silences releases without a full reset, which would also drop scrollback. Fable 5.1 via Claude Code. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/terminal/Manager.test.ts | 112 ++++++++++++++++++ apps/server/src/terminal/Manager.ts | 94 ++++++++++++++- .../src/terminal/ghostty/runtimeAbi.test.ts | 11 ++ 3 files changed, 216 insertions(+), 1 deletion(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index f631992e7ae3..7093291bc75d 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1425,6 +1425,118 @@ it.layer( }), ); + it.effect("clears Kitty keyboard flags a dead process left in inherited history", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + // Codex CLI pushes event-type reporting, then the shell dies before it + // can pop (server restart). The query is stripped; the push survives. + process.emitData("prompt % codex\n\u001b[>7u\u001b[?u"); + + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + assert.equal(reopened.history, "prompt % codex\n\u001b[>7u\u001b[=0;1u"); + }), + ); + + it.effect("resets mouse and focus modes a dead process left in inherited history", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + // Claude Code tracks mouse motion and focus; replaying that into a fresh + // shell would make the client send reports the shell echoes as junk. + process.emitData("prompt % claude\n\u001b[?1004h\u001b[?1003;1006h"); + + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + assert.equal( + reopened.history, + "prompt % claude\n\u001b[?1004h\u001b[?1003;1006h\u001b[?1004l\u001b[?1003l\u001b[?1006l", + ); + }), + ); + + it.effect("leaves the alternate screen before restoring the cursor in inherited history", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + // DECSTR leaves every tracked mode alone in libghostty-vt, so it must not + // be mistaken for a reset. + process.emitData("prompt % nvim\n\u001b[?25l\u001b[?1049h\u001b[!p"); + + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + assert.equal( + reopened.history, + "prompt % nvim\n\u001b[?25l\u001b[?1049h\u001b[!p\u001b[?1049l\u001b[?25h", + ); + }), + ); + + it.effect("leaves inherited history alone when its process restored the terminal", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + // A clean exit pops its flags and resets its modes; a later RIS wipes + // everything before it, including a dangling alternate screen. + process.emitData("\u001b[>7u\u001b[?1003h\u001b[7u\u001b[?1003h\u001b[ + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + // Two pushes with one pop, as a TUI that spawned a child and then died leaves. + process.emitData("\u001b[>1u\u001b[>7u\u001b[1u\u001b[>7u\u001b[ Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 174ed4206afc..72a9b905268b 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -994,6 +994,98 @@ export class BoundedTerminalHistory { } } +// A shell that dies mid-app (server restart or crash while Codex CLI, Claude +// Code, or another TUI runs) never gets to restore the terminal modes the app +// set. The next shell inherits that history on open, the client replays it +// into a fresh emulator, and the renderer then encodes key releases, mouse +// motion, or focus changes for a shell that never asked. Undo whatever the +// inherited history leaves deviating from power-on defaults; a program that +// wants a mode again sets its own. +// +// DEC private modes worth restoring, with their power-on defaults. Frame-scoped +// modes such as synchronized output (2026) are excluded: they never outlive +// the frame that opened them. +const INHERITED_DEC_MODE_DEFAULTS = new Map([ + [1, false], // application cursor keys + [6, false], // origin mode + [7, true], // autowrap + [9, false], // X10 mouse reporting + [25, true], // cursor visible + [47, false], // legacy alternate screen + [1000, false], // mouse press/release tracking + [1002, false], // mouse button-event tracking + [1003, false], // mouse any-event tracking + [1004, false], // focus reporting + [1005, false], // UTF-8 mouse encoding + [1006, false], // SGR mouse encoding + [1015, false], // urxvt mouse encoding + [1047, false], // alternate screen buffer + [1049, false], // alternate screen with cursor save + [2004, false], // bracketed paste +]); +// The three alternate-screen modes toggle one underlying screen. +const ALTERNATE_SCREEN_DEC_MODES = [47, 1047, 1049]; +// Kitty keyboard flags live on a stack: `CSI > flags u` pushes, `CSI < n u` +// pops, `CSI = flags ; mode u` rewrites the top. The encoder only reads the +// top, so zeroing it is enough; only RIS clears the stack. +const KITTY_KEYBOARD_CLEAR = "\u001b[=0;1u"; +// DEC mode set/reset, Kitty keyboard push/pop/set, and RIS (`ESC c`). RIS is +// the only reset that touches these in libghostty-vt; DECSTR (`CSI ! p`) +// leaves every one of them alone, so it is deliberately not tracked. +const INHERITED_MODE_PATTERN = + // eslint-disable-next-line no-control-regex -- matches ESC / CSI control sequences. + /(?:\u001b\[|\u009b)(?:\?([0-9;]+)([hl])|([<>=])([0-9]*)[0-9;]*u)|\u001b(c)/gu; + +function neutralizeInheritedHistory(history: string): string { + if (history.length === 0) return history; + const modes = new Map(); + const kittyStack = [0]; + for (const match of history.matchAll(INHERITED_MODE_PATTERN)) { + if (match[5] !== undefined) { + modes.clear(); + kittyStack.length = 1; + kittyStack[0] = 0; + continue; + } + if (match[3] !== undefined) { + const parameter = Number.parseInt(match[4] ?? "", 10); + if (match[3] === ">") { + kittyStack.push(Number.isNaN(parameter) ? 0 : parameter); + } else if (match[3] === "=") { + kittyStack[kittyStack.length - 1] = Number.isNaN(parameter) ? 0 : parameter; + } else { + const count = Number.isNaN(parameter) ? 1 : parameter; + kittyStack.length = Math.max(1, kittyStack.length - count); + } + continue; + } + const enabled = match[2] === "h"; + for (const parameter of (match[1] ?? "").split(";")) { + const mode = Number.parseInt(parameter, 10); + if (!INHERITED_DEC_MODE_DEFAULTS.has(mode)) continue; + if (ALTERNATE_SCREEN_DEC_MODES.includes(mode)) { + for (const alias of ALTERNATE_SCREEN_DEC_MODES) modes.delete(alias); + } + modes.set(mode, enabled); + } + } + const deviations = [...modes].filter( + ([mode, enabled]) => enabled !== INHERITED_DEC_MODE_DEFAULTS.get(mode), + ); + // Leave the alternate screen before the other resets: exiting it restores + // saved cursor state, which must not undo a cursor-show reset. + deviations.sort( + ([left], [right]) => + Number(!ALTERNATE_SCREEN_DEC_MODES.includes(left)) - + Number(!ALTERNATE_SCREEN_DEC_MODES.includes(right)), + ); + const decReset = deviations + .map(([mode]) => `\u001b[?${mode}${INHERITED_DEC_MODE_DEFAULTS.get(mode) ? "h" : "l"}`) + .join(""); + const kittyReset = kittyStack[kittyStack.length - 1] === 0 ? "" : KITTY_KEYBOARD_CLEAR; + return `${history}${decReset}${kittyReset}`; +} + function isCsiFinalByte(codePoint: number): boolean { return codePoint >= 0x40 && codePoint <= 0x7e; } @@ -2524,7 +2616,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const existing = yield* getSession(input.threadId, terminalId); if (Option.isNone(existing)) { yield* flushPersist(input.threadId, terminalId); - const history = yield* readHistory(input.threadId, terminalId); + const history = neutralizeInheritedHistory(yield* readHistory(input.threadId, terminalId)); const cols = input.cols ?? DEFAULT_OPEN_COLS; const rows = input.rows ?? DEFAULT_OPEN_ROWS; const session: TerminalSessionState = { diff --git a/apps/web/src/terminal/ghostty/runtimeAbi.test.ts b/apps/web/src/terminal/ghostty/runtimeAbi.test.ts index 7d4782b4b409..be462cfc2481 100644 --- a/apps/web/src/terminal/ghostty/runtimeAbi.test.ts +++ b/apps/web/src/terminal/ghostty/runtimeAbi.test.ts @@ -700,6 +700,17 @@ describe("vendored libghostty-vt WebAssembly", () => { free(releaseOutput, releaseSize); free(reportEventsPointer, reportEvents.length); + // Zeroing the active flags, which the server appends to history a dead + // process left enabled, silences releases again without a full reset. + const clearFlags = new TextEncoder().encode("\u001b[=0;1u"); + const clearFlagsPointer = alloc(clearFlags.length); + new Uint8Array(memory.buffer, clearFlagsPointer, clearFlags.length).set(clearFlags); + call("ghostty_terminal_vt_write", terminal, clearFlagsPointer, clearFlags.length); + call("ghostty_key_encoder_setopt_from_terminal", keyEncoder, terminal); + expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(0); + expect(new DataView(memory.buffer, written, 4).getUint32(0, true)).toBe(0); + free(clearFlagsPointer, clearFlags.length); + free(remappedOutput, remappedOutputSize); free(remappedTextPointer, remappedText.length); free(output, outputSize); From 4d60f3223025fcf8549416155e7796c2e1a814fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20Lappetel=C3=A4inen?= Date: Wed, 2 Sep 2026 14:55:24 +0300 Subject: [PATCH 2/7] fix(server): keep Kitty keyboard stacks per screen when neutralizing history libghostty-vt keeps one Kitty keyboard stack per screen, so an app that pushes on the main screen, enters the alternate screen, and pops there leaves the main screen's flags set after it returns. A single global stack netted that out to nothing and skipped the reset. Track the two stacks, switch with the alternate-screen modes, and decide the reset from the main stack, which is the active one once the appended alternate-screen exit has run. Measured against the vendored wasm; regression tests cover both the round trip and a push made only on the alternate screen. Fable 5.1 via Claude Code. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/terminal/Manager.test.ts | 39 ++++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 25 +++++++++------ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 7093291bc75d..dac1bf995339 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1513,6 +1513,45 @@ it.layer( }), ); + it.effect("keeps Kitty keyboard stacks per screen while neutralizing inherited history", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + // A push on the main screen survives an alternate-screen round trip even + // when the app pops while on the alternate screen (libghostty keeps one + // stack per screen), so the main screen still needs its reset. + process.emitData("\u001b[>7u\u001b[?1049h\u001b[7u\u001b[?1049h\u001b[ + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + // Dying inside the alternate screen with flags pushed there: leaving the + // screen makes the untouched main stack active, so no Kitty reset is due. + process.emitData("\u001b[?1049h\u001b[>7u"); + + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + assert.equal(reopened.history, "\u001b[?1049h\u001b[>7u\u001b[?1049l"); + }), + ); + it.effect("neutralizes inherited history once across repeated restarts", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 72a9b905268b..2c7ea5c0fcf8 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1026,8 +1026,9 @@ const INHERITED_DEC_MODE_DEFAULTS = new Map([ // The three alternate-screen modes toggle one underlying screen. const ALTERNATE_SCREEN_DEC_MODES = [47, 1047, 1049]; // Kitty keyboard flags live on a stack: `CSI > flags u` pushes, `CSI < n u` -// pops, `CSI = flags ; mode u` rewrites the top. The encoder only reads the -// top, so zeroing it is enough; only RIS clears the stack. +// pops, `CSI = flags ; mode u` rewrites the top. The main and alternate +// screens keep separate stacks, and the encoder only reads the active top, so +// zeroing it is enough; only RIS clears the stacks. const KITTY_KEYBOARD_CLEAR = "\u001b[=0;1u"; // DEC mode set/reset, Kitty keyboard push/pop/set, and RIS (`ESC c`). RIS is // the only reset that touches these in libghostty-vt; DECSTR (`CSI ! p`) @@ -1039,23 +1040,26 @@ const INHERITED_MODE_PATTERN = function neutralizeInheritedHistory(history: string): string { if (history.length === 0) return history; const modes = new Map(); - const kittyStack = [0]; + const kittyStacks = { main: [0], alternate: [0] }; + let screen: keyof typeof kittyStacks = "main"; for (const match of history.matchAll(INHERITED_MODE_PATTERN)) { if (match[5] !== undefined) { modes.clear(); - kittyStack.length = 1; - kittyStack[0] = 0; + kittyStacks.main = [0]; + kittyStacks.alternate = [0]; + screen = "main"; continue; } if (match[3] !== undefined) { + const stack = kittyStacks[screen]; const parameter = Number.parseInt(match[4] ?? "", 10); if (match[3] === ">") { - kittyStack.push(Number.isNaN(parameter) ? 0 : parameter); + stack.push(Number.isNaN(parameter) ? 0 : parameter); } else if (match[3] === "=") { - kittyStack[kittyStack.length - 1] = Number.isNaN(parameter) ? 0 : parameter; + stack[stack.length - 1] = Number.isNaN(parameter) ? 0 : parameter; } else { const count = Number.isNaN(parameter) ? 1 : parameter; - kittyStack.length = Math.max(1, kittyStack.length - count); + stack.length = Math.max(1, stack.length - count); } continue; } @@ -1065,6 +1069,7 @@ function neutralizeInheritedHistory(history: string): string { if (!INHERITED_DEC_MODE_DEFAULTS.has(mode)) continue; if (ALTERNATE_SCREEN_DEC_MODES.includes(mode)) { for (const alias of ALTERNATE_SCREEN_DEC_MODES) modes.delete(alias); + screen = enabled ? "alternate" : "main"; } modes.set(mode, enabled); } @@ -1082,7 +1087,9 @@ function neutralizeInheritedHistory(history: string): string { const decReset = deviations .map(([mode]) => `\u001b[?${mode}${INHERITED_DEC_MODE_DEFAULTS.get(mode) ? "h" : "l"}`) .join(""); - const kittyReset = kittyStack[kittyStack.length - 1] === 0 ? "" : KITTY_KEYBOARD_CLEAR; + // The alternate screen has been left by now, so the main stack is active. + const mainStack = kittyStacks.main; + const kittyReset = mainStack[mainStack.length - 1] === 0 ? "" : KITTY_KEYBOARD_CLEAR; return `${history}${decReset}${kittyReset}`; } From 6f8208db28bf220539b389be133b8cc714059d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20Lappetel=C3=A4inen?= Date: Wed, 2 Sep 2026 18:25:26 +0300 Subject: [PATCH 3/7] refactor(server): match inherited terminal sequences without a control-character regex Fold the 8-bit CSI byte into ESC `[`, split the history on ESC, and match each fragment's head with an anchored pattern made of printable characters only. Same sequences recognized, same group numbering, no lint suppression. Fable 5.1 via Claude Code. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/terminal/Manager.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 2c7ea5c0fcf8..72e71e35374c 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1030,19 +1030,25 @@ const ALTERNATE_SCREEN_DEC_MODES = [47, 1047, 1049]; // screens keep separate stacks, and the encoder only reads the active top, so // zeroing it is enough; only RIS clears the stacks. const KITTY_KEYBOARD_CLEAR = "\u001b[=0;1u"; -// DEC mode set/reset, Kitty keyboard push/pop/set, and RIS (`ESC c`). RIS is -// the only reset that touches these in libghostty-vt; DECSTR (`CSI ! p`) -// leaves every one of them alone, so it is deliberately not tracked. -const INHERITED_MODE_PATTERN = - // eslint-disable-next-line no-control-regex -- matches ESC / CSI control sequences. - /(?:\u001b\[|\u009b)(?:\?([0-9;]+)([hl])|([<>=])([0-9]*)[0-9;]*u)|\u001b(c)/gu; +// Every sequence of interest starts with ESC (after folding the 8-bit CSI +// byte into ESC `[`), so the history is split there and each fragment's head +// is matched: DEC mode set/reset, Kitty keyboard push/pop/set, or RIS (`c`). +// RIS is the only reset that touches these in libghostty-vt; DECSTR +// (`CSI ! p`) leaves every one of them alone, so it is deliberately not +// tracked. +const ESCAPE = "\u001b"; +const CSI_8BIT = "\u009b"; +const INHERITED_MODE_SEQUENCE = /^(?:\[(?:\?([0-9;]+)([hl])|([<>=])([0-9]*)[0-9;]*u)|(c))/u; function neutralizeInheritedHistory(history: string): string { if (history.length === 0) return history; const modes = new Map(); const kittyStacks = { main: [0], alternate: [0] }; let screen: keyof typeof kittyStacks = "main"; - for (const match of history.matchAll(INHERITED_MODE_PATTERN)) { + const fragments = history.replaceAll(CSI_8BIT, `${ESCAPE}[`).split(ESCAPE); + for (const fragment of fragments.slice(1)) { + const match = INHERITED_MODE_SEQUENCE.exec(fragment); + if (match === null) continue; if (match[5] !== undefined) { modes.clear(); kittyStacks.main = [0]; From 5c216bd8995ca6262619e79908177bd31f2312c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20Lappetel=C3=A4inen?= Date: Wed, 2 Sep 2026 18:29:56 +0300 Subject: [PATCH 4/7] fix(server): apply Kitty set modes when tracking inherited flags `CSI = flags ; mode u` replaces the top of the Kitty stack only in mode 1; mode 2 ORs the bits in and mode 3 clears them, and libghostty-vt honors all three. Treating every set as a replacement let `CSI > 7 u` followed by `CSI = 0 ; 3 u` read as cleared while the emulator still reported releases. Parse the mode parameter and apply it the same way. Regression test covers clearing none, clearing all, and OR-ing bits in. Fable 5.1 via Claude Code. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/terminal/Manager.test.ts | 27 ++++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 18 ++++++++++------ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index dac1bf995339..caefc56defd2 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1552,6 +1552,33 @@ it.layer( }), ); + it.effect("applies Kitty set modes when tracking inherited flags", () => + Effect.gen(function* () { + // Mode 3 clears only the named bits, so clearing none leaves 7 active. + const clearNone = yield* createManager(); + yield* clearNone.manager.open(openInput()); + clearNone.ptyAdapter.processes[0]!.emitData("\u001b[>7u\u001b[=0;3u"); + yield* clearNone.manager.close({ threadId: "thread-1" }); + const afterClearNone = yield* clearNone.manager.open(openInput()); + assert.equal(afterClearNone.history, "\u001b[>7u\u001b[=0;3u\u001b[=0;1u"); + + // Clearing every bit set by the push needs no reset; OR-ing bits in does. + const clearAll = yield* createManager(); + yield* clearAll.manager.open(openInput()); + clearAll.ptyAdapter.processes[0]!.emitData("\u001b[>7u\u001b[=7;3u\u001b[=2;2u"); + yield* clearAll.manager.close({ threadId: "thread-1" }); + const afterOr = yield* clearAll.manager.open(openInput()); + assert.equal(afterOr.history, "\u001b[>7u\u001b[=7;3u\u001b[=2;2u\u001b[=0;1u"); + + const cleared = yield* createManager(); + yield* cleared.manager.open(openInput()); + cleared.ptyAdapter.processes[0]!.emitData("\u001b[>7u\u001b[=7;3u"); + yield* cleared.manager.close({ threadId: "thread-1" }); + const afterCleared = yield* cleared.manager.open(openInput()); + assert.equal(afterCleared.history, "\u001b[>7u\u001b[=7;3u"); + }), + ); + it.effect("neutralizes inherited history once across repeated restarts", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 72e71e35374c..2ac804450852 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1026,9 +1026,10 @@ const INHERITED_DEC_MODE_DEFAULTS = new Map([ // The three alternate-screen modes toggle one underlying screen. const ALTERNATE_SCREEN_DEC_MODES = [47, 1047, 1049]; // Kitty keyboard flags live on a stack: `CSI > flags u` pushes, `CSI < n u` -// pops, `CSI = flags ; mode u` rewrites the top. The main and alternate -// screens keep separate stacks, and the encoder only reads the active top, so -// zeroing it is enough; only RIS clears the stacks. +// pops, `CSI = flags ; mode u` edits the top (mode 1 replaces, 2 sets bits, +// 3 clears bits). The main and alternate screens keep separate stacks, and +// the encoder only reads the active top, so zeroing it is enough; only RIS +// clears the stacks. const KITTY_KEYBOARD_CLEAR = "\u001b[=0;1u"; // Every sequence of interest starts with ESC (after folding the 8-bit CSI // byte into ESC `[`), so the history is split there and each fragment's head @@ -1038,7 +1039,8 @@ const KITTY_KEYBOARD_CLEAR = "\u001b[=0;1u"; // tracked. const ESCAPE = "\u001b"; const CSI_8BIT = "\u009b"; -const INHERITED_MODE_SEQUENCE = /^(?:\[(?:\?([0-9;]+)([hl])|([<>=])([0-9]*)[0-9;]*u)|(c))/u; +const INHERITED_MODE_SEQUENCE = + /^(?:\[(?:\?([0-9;]+)([hl])|([<>=])([0-9]*)(?:;([0-9]*))?[0-9;]*u)|(c))/u; function neutralizeInheritedHistory(history: string): string { if (history.length === 0) return history; @@ -1049,7 +1051,7 @@ function neutralizeInheritedHistory(history: string): string { for (const fragment of fragments.slice(1)) { const match = INHERITED_MODE_SEQUENCE.exec(fragment); if (match === null) continue; - if (match[5] !== undefined) { + if (match[6] !== undefined) { modes.clear(); kittyStacks.main = [0]; kittyStacks.alternate = [0]; @@ -1062,7 +1064,11 @@ function neutralizeInheritedHistory(history: string): string { if (match[3] === ">") { stack.push(Number.isNaN(parameter) ? 0 : parameter); } else if (match[3] === "=") { - stack[stack.length - 1] = Number.isNaN(parameter) ? 0 : parameter; + const flags = Number.isNaN(parameter) ? 0 : parameter; + const top = stack.length - 1; + const current = stack[top] ?? 0; + const setMode = Number.parseInt(match[5] ?? "", 10); + stack[top] = setMode === 2 ? current | flags : setMode === 3 ? current & ~flags : flags; } else { const count = Number.isNaN(parameter) ? 1 : parameter; stack.length = Math.max(1, stack.length - count); From 5ad8d06f2b706b9a13803dc0420e7e78ed067b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20Lappetel=C3=A4inen?= Date: Fri, 11 Sep 2026 13:18:13 +0300 Subject: [PATCH 5/7] fix(server): track only the Kitty and CSI input libghostty-vt acts on The scanner folded the 8-bit CSI byte into ESC `[` and treated any Kitty set mode as a replacement. libghostty-vt does neither: U+009B, whether raw or as the UTF-8 pair the history carries, is not a sequence introducer, and set modes other than 1 (replace), 2 (set bits), and 3 (clear bits) are ignored. Both let the tracker record flags as zero while the replayed emulator kept release reporting on. Match the emulator: split only on ESC and skip unsupported set modes. Regression tests cover both histories. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/terminal/Manager.test.ts | 27 ++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 29 ++++++++++++------------ 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index caefc56defd2..166e56f73b16 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1576,6 +1576,33 @@ it.layer( yield* cleared.manager.close({ threadId: "thread-1" }); const afterCleared = yield* cleared.manager.open(openInput()); assert.equal(afterCleared.history, "\u001b[>7u\u001b[=7;3u"); + + // libghostty-vt ignores set modes other than 1, 2, and 3, so the flags stay. + const unsupported = yield* createManager(); + yield* unsupported.manager.open(openInput()); + unsupported.ptyAdapter.processes[0]!.emitData("\u001b[>7u\u001b[=0;4u"); + yield* unsupported.manager.close({ threadId: "thread-1" }); + const afterUnsupported = yield* unsupported.manager.open(openInput()); + assert.equal(afterUnsupported.history, "\u001b[>7u\u001b[=0;4u\u001b[=0;1u"); + }), + ); + + it.effect("ignores the 8-bit CSI byte like the client's replay parser does", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + // A U+009B introducer is not a sequence to libghostty-vt, so a "clear" + // written that way never happened and the push still needs its reset. + process.emitData("\u001b[>7u\u009b=0;1u"); + + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + assert.equal(reopened.history, "\u001b[>7u\u009b=0;1u\u001b[=0;1u"); }), ); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 2ac804450852..14555d843115 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1027,18 +1027,17 @@ const INHERITED_DEC_MODE_DEFAULTS = new Map([ const ALTERNATE_SCREEN_DEC_MODES = [47, 1047, 1049]; // Kitty keyboard flags live on a stack: `CSI > flags u` pushes, `CSI < n u` // pops, `CSI = flags ; mode u` edits the top (mode 1 replaces, 2 sets bits, -// 3 clears bits). The main and alternate screens keep separate stacks, and -// the encoder only reads the active top, so zeroing it is enough; only RIS -// clears the stacks. +// 3 clears bits; libghostty-vt ignores any other mode). The main and +// alternate screens keep separate stacks, and the encoder only reads the +// active top, so zeroing it is enough; only RIS clears the stacks. const KITTY_KEYBOARD_CLEAR = "\u001b[=0;1u"; -// Every sequence of interest starts with ESC (after folding the 8-bit CSI -// byte into ESC `[`), so the history is split there and each fragment's head -// is matched: DEC mode set/reset, Kitty keyboard push/pop/set, or RIS (`c`). -// RIS is the only reset that touches these in libghostty-vt; DECSTR -// (`CSI ! p`) leaves every one of them alone, so it is deliberately not -// tracked. +// Every sequence of interest starts with ESC, so the history is split there +// and each fragment's head is matched: DEC mode set/reset, Kitty keyboard +// push/pop/set, or RIS (`c`). The scanner tracks only what the client's +// replay parser acts on: libghostty-vt ignores the 8-bit CSI byte (U+009B, +// which the UTF-8 history would carry as C2 9B) and leaves every tracked +// mode alone on DECSTR (`CSI ! p`), so neither is treated as a sequence. const ESCAPE = "\u001b"; -const CSI_8BIT = "\u009b"; const INHERITED_MODE_SEQUENCE = /^(?:\[(?:\?([0-9;]+)([hl])|([<>=])([0-9]*)(?:;([0-9]*))?[0-9;]*u)|(c))/u; @@ -1047,8 +1046,7 @@ function neutralizeInheritedHistory(history: string): string { const modes = new Map(); const kittyStacks = { main: [0], alternate: [0] }; let screen: keyof typeof kittyStacks = "main"; - const fragments = history.replaceAll(CSI_8BIT, `${ESCAPE}[`).split(ESCAPE); - for (const fragment of fragments.slice(1)) { + for (const fragment of history.split(ESCAPE).slice(1)) { const match = INHERITED_MODE_SEQUENCE.exec(fragment); if (match === null) continue; if (match[6] !== undefined) { @@ -1067,8 +1065,11 @@ function neutralizeInheritedHistory(history: string): string { const flags = Number.isNaN(parameter) ? 0 : parameter; const top = stack.length - 1; const current = stack[top] ?? 0; - const setMode = Number.parseInt(match[5] ?? "", 10); - stack[top] = setMode === 2 ? current | flags : setMode === 3 ? current & ~flags : flags; + const setMode = + match[5] === undefined || match[5] === "" ? 1 : Number.parseInt(match[5], 10); + if (setMode === 1) stack[top] = flags; + else if (setMode === 2) stack[top] = current | flags; + else if (setMode === 3) stack[top] = current & ~flags; } else { const count = Number.isNaN(parameter) ? 1 : parameter; stack.length = Math.max(1, stack.length - count); From ac78e6074e9b60b9071dab3ab99bd3d584424bb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20Lappetel=C3=A4inen?= Date: Fri, 11 Sep 2026 13:22:45 +0300 Subject: [PATCH 6/7] fix(server): neutralize inherited history before it enters the bounded buffer main now loads persisted history straight into BoundedTerminalHistory, so the neutralizer receives the buffer's text and the session gets a buffer rebuilt from the neutralized result. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/terminal/Manager.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 14555d843115..e652860bd36f 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -2636,7 +2636,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const existing = yield* getSession(input.threadId, terminalId); if (Option.isNone(existing)) { yield* flushPersist(input.threadId, terminalId); - const history = neutralizeInheritedHistory(yield* readHistory(input.threadId, terminalId)); + const inherited = yield* readHistory(input.threadId, terminalId); + const history = new BoundedTerminalHistory( + historyLineLimit, + neutralizeInheritedHistory(inherited.value()), + historyByteLimit, + ); const cols = input.cols ?? DEFAULT_OPEN_COLS; const rows = input.rows ?? DEFAULT_OPEN_ROWS; const session: TerminalSessionState = { From 579f0355cc8bb1ec8963eca87ccc743fd04274ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20Lappetel=C3=A4inen?= Date: Wed, 16 Sep 2026 16:16:02 +0300 Subject: [PATCH 7/7] fix(server): reset keypad and key encoding modes in inherited history libghostty-vt stores DEC modes 66, 67, 1035, and 1036 and the key encoder reads them when the client syncs it, so a dead process that left them set changes backspace, alt, and keypad encoding for the new shell. Track them like the other modes, including DECKPAM/DECKPNM (`ESC =` / `ESC >`) which terminfo smkx/rmkx use for mode 66. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/terminal/Manager.test.ts | 22 ++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 19 ++++++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 166e56f73b16..729edcfa80d5 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1467,6 +1467,28 @@ it.layer( }), ); + it.effect("resets keypad and key encoding modes a dead process left in inherited history", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + // terminfo smkx sends DECKPAM as a plain escape, not a CSI mode, and the + // key encoder reads modes 66, 67, and 1036 when the client syncs it. + process.emitData("prompt % vim\n\u001b[?1h\u001b=\u001b[?67h\u001b[?1036l"); + + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + assert.equal( + reopened.history, + "prompt % vim\n\u001b[?1h\u001b=\u001b[?67h\u001b[?1036l\u001b[?1l\u001b[?66l\u001b[?67l\u001b[?1036h", + ); + }), + ); + it.effect("leaves the alternate screen before restoring the cursor in inherited history", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index e652860bd36f..f1cc8f5fef1e 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1012,6 +1012,8 @@ const INHERITED_DEC_MODE_DEFAULTS = new Map([ [9, false], // X10 mouse reporting [25, true], // cursor visible [47, false], // legacy alternate screen + [66, false], // application keypad (also set by DECKPAM, `ESC =`) + [67, false], // backarrow key sends BS [1000, false], // mouse press/release tracking [1002, false], // mouse button-event tracking [1003, false], // mouse any-event tracking @@ -1019,6 +1021,8 @@ const INHERITED_DEC_MODE_DEFAULTS = new Map([ [1005, false], // UTF-8 mouse encoding [1006, false], // SGR mouse encoding [1015, false], // urxvt mouse encoding + [1035, true], // numlock overrides application keypad + [1036, true], // alt sends ESC prefix [1047, false], // alternate screen buffer [1049, false], // alternate screen with cursor save [2004, false], // bracketed paste @@ -1033,13 +1037,14 @@ const ALTERNATE_SCREEN_DEC_MODES = [47, 1047, 1049]; const KITTY_KEYBOARD_CLEAR = "\u001b[=0;1u"; // Every sequence of interest starts with ESC, so the history is split there // and each fragment's head is matched: DEC mode set/reset, Kitty keyboard -// push/pop/set, or RIS (`c`). The scanner tracks only what the client's -// replay parser acts on: libghostty-vt ignores the 8-bit CSI byte (U+009B, -// which the UTF-8 history would carry as C2 9B) and leaves every tracked -// mode alone on DECSTR (`CSI ! p`), so neither is treated as a sequence. +// push/pop/set, DECKPAM/DECKPNM (`ESC =` / `ESC >`, which toggle mode 66), +// or RIS (`c`). The scanner tracks only what the client's replay parser acts +// on: libghostty-vt ignores the 8-bit CSI byte (U+009B, which the UTF-8 +// history would carry as C2 9B) and leaves every tracked mode alone on DECSTR +// (`CSI ! p`), so neither is treated as a sequence. const ESCAPE = "\u001b"; const INHERITED_MODE_SEQUENCE = - /^(?:\[(?:\?([0-9;]+)([hl])|([<>=])([0-9]*)(?:;([0-9]*))?[0-9;]*u)|(c))/u; + /^(?:\[(?:\?([0-9;]+)([hl])|([<>=])([0-9]*)(?:;([0-9]*))?[0-9;]*u)|(c)|([=>]))/u; function neutralizeInheritedHistory(history: string): string { if (history.length === 0) return history; @@ -1056,6 +1061,10 @@ function neutralizeInheritedHistory(history: string): string { screen = "main"; continue; } + if (match[7] !== undefined) { + modes.set(66, match[7] === "="); + continue; + } if (match[3] !== undefined) { const stack = kittyStacks[screen]; const parameter = Number.parseInt(match[4] ?? "", 10);