From f761171f68a0ccf045d76de8a2e43d6da00e2bae Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 28 Jun 2026 06:25:26 +0000 Subject: [PATCH] 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 --- packages/tui/src/terminal.ts | 24 ++++++++--- packages/tui/src/tui.ts | 37 ++++++++++++----- packages/tui/test/issue-2045-repro.test.ts | 6 ++- .../test/kitty-keyboard-da1-ordering.test.ts | 41 ++++++++++++++++++- packages/tui/test/overlay-focus.test.ts | 3 +- packages/tui/test/render-regressions.test.ts | 6 ++- packages/tui/test/virtual-terminal.ts | 6 ++- 7 files changed, 101 insertions(+), 22 deletions(-) diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 2465c76e6..935f907f5 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -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[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[ void) | undefined; #onResize: (() => void) | undefined; diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index e9e127225..83f8a0aa4 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -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; } } diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index 34f19f72d..64d0b4b18 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -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[