refactor(tui): split keyboard enhancement into enter/exit pair

Renamed the buffer-switch helper to keyboardEnhancementEnterSequence and added a sibling keyboardEnhancementExitSequence so callers can balance push/pop generically instead of hard-coding the kitty pop.

ProcessTerminal returns the matching kitty pop on exit when kitty is active, and null when only the xterm modifyOtherKeys fallback is active: modifyOtherKeys is a global terminal flag with no per-screen stack, so emitting >4;0m on overlay exit would clear it on the normal screen and break composer typing between overlays. terminal.stop() and the emergency-restore path still disable it globally on graceful teardown.

TUI alt-screen entry/exit and resize alt-screen helpers now route through the new pair with fallbacks to kittyEnableSequence for custom Terminal implementations predating the new properties.

Fixes #3705
This commit is contained in:
roboomp
2026-06-28 06:25:26 +00:00
parent 78c4a29cbe
commit f761171f68
7 changed files with 101 additions and 22 deletions
+19 -5
View File
@@ -336,10 +336,15 @@ export interface Terminal {
// so the TUI re-pushes this after entering the alternate screen.
get kittyEnableSequence(): string | null;
// The active modified-key reporting sequence to reassert after terminal buffer
// switches, or null when no enhanced keyboard mode is active. Optional so
// custom Terminals built against older pi-tui versions keep working.
readonly keyboardEnhancementSequence?: string | null;
// The active modified-key reporting sequence to reassert on alternate-screen
// entry, or null when no enhanced keyboard mode is active. Optional so custom
// Terminals built against older pi-tui versions keep working.
readonly keyboardEnhancementEnterSequence?: string | null;
// The sequence that cleanly disables the active enhanced keyboard mode on
// alternate-screen exit, or null when no exit handshake is required. Optional
// so custom Terminals built against older pi-tui versions keep working.
readonly keyboardEnhancementExitSequence?: string | null;
// Cursor positioning (relative to current position)
moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
@@ -477,11 +482,20 @@ export class ProcessTerminal implements Terminal {
return this.#kittyProtocolActive ? this.#kittyEnableSeq : null;
}
get keyboardEnhancementSequence(): string | null {
get keyboardEnhancementEnterSequence(): string | null {
if (this.#kittyProtocolActive) return this.#kittyEnableSeq;
return this.#modifyOtherKeysActive ? "\x1b[>4;2m" : null;
}
get keyboardEnhancementExitSequence(): string | null {
// kitty is a stack push (per-screen), so the matching pop balances alt-screen
// entry. xterm modifyOtherKeys is a single global flag with no per-screen
// stack — emitting `>4;0m` here would clear it on the normal screen too,
// breaking the composer between overlays. terminal.stop() still disables it
// globally on graceful exit; the emergency-restore path mirrors that.
return this.#kittyProtocolActive ? "\x1b[<u" : null;
}
get appearance(): TerminalAppearance | undefined {
return this.#appearance;
}
+26 -11
View File
@@ -1745,8 +1745,8 @@ export class TUI extends Container {
this.terminal.write(this.#leaveResizeAltSequence());
}
if (this.#altActive) {
const kittyPop = this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
this.terminal.write(`${MOUSE_TRACKING_OFF}${kittyPop}\x1b[?1049l`);
const enhancementExit = this.#keyboardEnhancementExit();
this.terminal.write(`${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`);
setAltScreenActive(false);
this.#altActive = false;
this.#altPreviousLines = [];
@@ -2492,7 +2492,7 @@ export class TUI extends Container {
// modified-key reporting sequence on the freshly entered alternate
// screen, or Esc/modified keys revert to legacy encoding inside
// fullscreen overlays (Ghostty/kitty/iTerm2).
this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementSequence()}${MOUSE_TRACKING_ON}`);
this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${MOUSE_TRACKING_ON}`);
setAltScreenActive(true);
this.terminal.hideCursor();
this.#forgetHardwareCursorState();
@@ -2502,8 +2502,8 @@ export class TUI extends Container {
this.#altEnterWidth = width;
this.#altEnterHeight = height;
} else if (!wantAlt && this.#altActive) {
const kittyPop = this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
this.terminal.write(`${MOUSE_TRACKING_OFF}${kittyPop}\x1b[?1049l`);
const enhancementExit = this.#keyboardEnhancementExit();
this.terminal.write(`${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`);
setAltScreenActive(false);
this.#forgetHardwareCursorState();
this.#altActive = false;
@@ -3339,9 +3339,24 @@ export class TUI extends Container {
return { window: this.#prepareLinesArray(window, width), contentRows: count };
}
/** Enter or leave the alternate screen borrowed for transient resize frames. */
#keyboardEnhancementSequence(): string {
return this.terminal.keyboardEnhancementSequence ?? this.terminal.kittyEnableSequence ?? "";
/**
* Resolve the active keyboard-enhancement enter sequence. Falls back to the
* legacy `kittyEnableSequence` when a custom Terminal predates the
* `keyboardEnhancementEnterSequence` property.
*/
#keyboardEnhancementEnter(): string {
return this.terminal.keyboardEnhancementEnterSequence ?? this.terminal.kittyEnableSequence ?? "";
}
/**
* Resolve the active keyboard-enhancement exit sequence. Falls back to popping
* kitty whenever a custom Terminal exposes its push sequence but predates the
* `keyboardEnhancementExitSequence` property.
*/
#keyboardEnhancementExit(): string {
const exit = this.terminal.keyboardEnhancementExitSequence;
if (exit !== undefined) return exit ?? "";
return this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
}
#enterResizeAltSequence(): string {
@@ -3350,16 +3365,16 @@ export class TUI extends Container {
setAltScreenActive(true);
this.#forgetHardwareCursorState();
this.#recordHardwareCursorHidden();
return `${ALT_SCREEN_ENTER}${this.#keyboardEnhancementSequence()}`;
return `${ALT_SCREEN_ENTER}${this.#keyboardEnhancementEnter()}`;
}
#leaveResizeAltSequence(): string {
if (!this.#resizeAltActive) return "";
const kittyPop = this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
const enhancementExit = this.#keyboardEnhancementExit();
this.#resizeAltActive = false;
setAltScreenActive(false);
this.#forgetHardwareCursorState();
return `${kittyPop}${ALT_SCREEN_EXIT}`;
return `${enhancementExit}${ALT_SCREEN_EXIT}`;
}
/**
+5 -1
View File
@@ -28,7 +28,11 @@ class CaptureTerminal implements Terminal {
return null;
}
get keyboardEnhancementSequence(): string | null {
get keyboardEnhancementEnterSequence(): string | null {
return null;
}
get keyboardEnhancementExitSequence(): string | null {
return null;
}
@@ -92,8 +92,45 @@ describe("ProcessTerminal kitty keyboard progressive-enhancement ordering", () =
});
await harness.settle();
const out = harness.writes.join("");
expect(out).toContain("\x1b[?1049h\x1b[>4;2m");
const enterOut = harness.writes.join("");
expect(enterOut).toContain("\x1b[?1049h\x1b[>4;2m");
harness.writes.length = 0;
overlay.hide();
await harness.settle();
const exitOut = harness.writes.join("");
// xterm modifyOtherKeys is a single global flag (no per-screen stack),
// so the overlay exit must NOT emit `>4;0m` — that would clear it on the
// normal screen and break the composer between overlays. Only the kitty
// pop is per-screen and safe to emit on exit.
expect(exitOut).toContain("\x1b[?1049l");
expect(exitOut).not.toContain("\x1b[>4;0m");
});
it("pops the kitty keyboard frame on fullscreen overlay exit", async () => {
harness = createProcessTerminalRenderHarness(100, 30);
await harness.settle();
await harness.feed("\x1b[?0u", "\x1b[?1;2c");
expect(harness.terminal.kittyProtocolActive).toBe(true);
harness.writes.length = 0;
const overlay = harness.tui.showOverlay(new ModalProbe(), {
fullscreen: true,
width: "100%",
maxHeight: "100%",
margin: 0,
});
await harness.settle();
expect(harness.writes.join("")).toContain("\x1b[?1049h\x1b[>1u");
harness.writes.length = 0;
overlay.hide();
await harness.settle();
const exitOut = harness.writes.join("");
// Kitty keyboard flags are per-screen, so the matching pop must precede
// the alt-screen exit to balance the push from overlay entry.
expect(exitOut).toContain("\x1b[<u\x1b[?1049l");
});
});
+2 -1
View File
@@ -7,7 +7,8 @@ class MinimalTerminal implements Terminal {
rows = 24;
kittyProtocolActive = false;
kittyEnableSequence: string | null = null;
keyboardEnhancementSequence: string | null = null;
keyboardEnhancementEnterSequence: string | null = null;
keyboardEnhancementExitSequence: string | null = null;
appearance: TerminalAppearance | undefined;
#onInput: ((data: string) => void) | undefined;
#onResize: (() => void) | undefined;
+5 -1
View File
@@ -118,7 +118,11 @@ class CountingViewportTerminal extends VirtualTerminal {
}
class LegacyKeyboardVirtualTerminal extends VirtualTerminal {
get keyboardEnhancementSequence(): string | null {
get keyboardEnhancementEnterSequence(): string | null {
return undefined as unknown as string | null;
}
get keyboardEnhancementExitSequence(): string | null {
return undefined as unknown as string | null;
}
}
+5 -1
View File
@@ -208,10 +208,14 @@ export class VirtualTerminal implements Terminal {
return "\x1b[>1u";
}
get keyboardEnhancementSequence(): string | null {
get keyboardEnhancementEnterSequence(): string | null {
return "\x1b[>1u";
}
get keyboardEnhancementExitSequence(): string | null {
return "\x1b[<u";
}
get appearance(): TerminalAppearance | undefined {
return undefined;
}