diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index d0e48aa92..4616d14b7 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -312,6 +312,7 @@ export function emergencyTerminalRestore(): void { process.stdout.write( "\x1b[?2026l" + // End synchronized output "\x1b[?7h" + // Restore autowrap + "\x1b[?1l\x1b>" + // Restore normal cursor-key + keypad mode (rmkx, #6374) "\x1b[?2004l" + // Disable bracketed paste "\x1b[?2031l" + // Disable Mode 2031 appearance notifications "\x1b[?2048l" + // Disable in-band resize notifications @@ -625,6 +626,15 @@ export class ProcessTerminal implements Terminal { // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~ this.#safeWrite("\x1b[?2004h"); + // Force normal cursor-key (DECCKM) and numeric-keypad mode (terminfo + // `rmkx` = "\x1b[?1l\x1b>"). omp decodes both CSI ("\x1b[A") and SS3 + // ("\x1bOA") arrow encodings, so it never enables application mode + // itself — but a prior program that left the TTY in application-cursor- + // keys mode makes arrows arrive as SS3. Normalizing on entry keeps input + // in the predictable default state; stop() restores the same on exit. + // See #6374. + this.#safeWrite("\x1b[?1l\x1b>"); + // Set up resize handler immediately. The OS refreshes process.stdout // dimensions before firing `resize`, so it is authoritative for geometry: // reconcile any stale cached DEC 2048 report before notifying the renderer. @@ -1376,6 +1386,13 @@ export class ProcessTerminal implements Terminal { // begin/end halves of a frame. Safe no-ops on terminals that ignored them. this.#safeWrite("\x1b[?2026l\x1b[?7h"); + // Restore normal cursor-key (DECCKM) and numeric-keypad mode (terminfo + // `rmkx`). Symmetric with the normalize in start(): a TTY-sharing child + // can leave the terminal in application-cursor-keys mode, and without + // this reset the parent shell inherits SS3 arrows so Up/Down history + // navigation stays broken after omp exits (#6374). + this.#safeWrite("\x1b[?1l\x1b>"); + // Disable bracketed paste mode this.#safeWrite("\x1b[?2004l"); this.#safeWrite("\x1b[?5522l"); diff --git a/packages/tui/test/process-terminal-headless.test.ts b/packages/tui/test/process-terminal-headless.test.ts index d7acd9126..34f925a3a 100644 --- a/packages/tui/test/process-terminal-headless.test.ts +++ b/packages/tui/test/process-terminal-headless.test.ts @@ -93,4 +93,30 @@ describe("ProcessTerminal headless suppression", () => { setTerminalHeadless(previous); } }); + + // #6374: arrows stopped working inside omp and stayed broken in the shell + // after exit — a missing cursor-key/keypad reset. omp owns the TTY and emits + // a full private-mode reset menu, but never restored normal cursor-key + // (DECCKM) / numeric-keypad mode (terminfo `rmkx` = "\x1b[?1l\x1b>"). If the + // terminal was left in application-cursor-keys mode, arrows arrived as SS3 + // and the parent shell's Up/Down history navigation broke. start() must + // normalize the state and stop() must restore it. + it("emits rmkx on start and stop to normalize/restore cursor-key + keypad mode (#6374)", () => { + const previous = setTerminalHeadless(false); + const terminal = new ProcessTerminal(); + try { + terminal.start( + () => {}, + () => {}, + ); + expect(writes.join("")).toContain("\x1b[?1l\x1b>"); + + writes.length = 0; + terminal.stop(); + expect(writes.join("")).toContain("\x1b[?1l\x1b>"); + } finally { + terminal.stop(); + setTerminalHeadless(previous); + } + }); });