diff --git a/ts/packages/cli/src/commands/connect.ts b/ts/packages/cli/src/commands/connect.ts index 34ec0bf87..2760616e4 100644 --- a/ts/packages/cli/src/commands/connect.ts +++ b/ts/packages/cli/src/commands/connect.ts @@ -12,6 +12,7 @@ import { withEnhancedConsoleClientIO, applyQueueSnapshot, clearRecentSubmissions, + setPendingExitMessage, } from "../enhancedConsole.js"; import { setConversationCommandContext, @@ -191,7 +192,10 @@ export default class Connect extends Command { const url = `ws://localhost:${flags.port}`; const onDisconnect = () => { - console.error("Disconnected from dispatcher"); + // Print on the restored main buffer (after the alt screen + // is torn down by the exit handler) so the user sees why + // the CLI exited rather than a blank shell prompt. + setPendingExitMessage("Disconnected from dispatcher"); process.exit(1); }; diff --git a/ts/packages/cli/src/enhancedConsole.ts b/ts/packages/cli/src/enhancedConsole.ts index eea783287..2beccbf99 100644 --- a/ts/packages/cli/src/enhancedConsole.ts +++ b/ts/packages/cli/src/enhancedConsole.ts @@ -69,6 +69,92 @@ let currentSpinner: EnhancedSpinner | null = null; // Wire up the debug interceptor's spinner access setSpinnerAccessor(() => currentSpinner); +// ── Terminal lifecycle (alt screen buffer + exit cleanup) ───────────────── +// +// The interactive CLI runs inside the terminal's alternate screen buffer +// (the same mode used by vim, less, htop, etc.). All session output, the +// scroll region, and the fixed prompt area live in the alt buffer; on +// exit the terminal automatically restores whatever was on the main +// buffer before the CLI started. +// +// This avoids the previous behavior where the CLI's scroll region, fixed +// prompt frame, and in-flight async writes were left scattered across the +// parent shell's terminal after exit (regardless of which exit path — +// /exit, Ctrl+C, server disconnect, /shutdown, etc. — was taken). + +let altScreenActive = false; +let exitHandlerRegistered = false; +let pendingExitMessage: string | undefined; + +function isInteractiveTty(): boolean { + return Boolean(process.stdout.isTTY && process.stdin.isTTY); +} + +function enterAltScreen(): void { + if (altScreenActive || !isInteractiveTty()) return; + altScreenActive = true; + // \x1b[?1049h — save cursor, switch to alt screen, clear it. + process.stdout.write("\x1b[?1049h"); + if (!exitHandlerRegistered) { + exitHandlerRegistered = true; + // Fires on normal exit and on process.exit(). Synchronous: any + // writes here are flushed before the process actually exits. + process.on("exit", exitAltScreen); + } +} + +function exitAltScreen(): void { + if (!altScreenActive) return; + altScreenActive = false; + try { + if (currentSpinner) { + try { + currentSpinner.stop(); + } catch { + // ignore + } + currentSpinner = null; + } + if (terminalLayout?.isActive) { + try { + terminalLayout.cleanup(); + } catch { + // ignore + } + } + // Reset scroll region, show cursor, leave alt screen (restores + // the main buffer to its pre-CLI state and pops the saved cursor). + process.stdout.write("\x1b[r\x1b[?25h\x1b[?1049l"); + if (process.stdin.isTTY) { + try { + process.stdin.setRawMode(false); + } catch { + // ignore + } + } + if (pendingExitMessage) { + try { + process.stderr.write(pendingExitMessage + "\n"); + } catch { + // ignore + } + pendingExitMessage = undefined; + } + } catch { + // Exit handlers must never throw. + } +} + +/** + * Queue a message to be printed on the main terminal buffer immediately + * after the CLI exits its alternate screen. Useful for explaining why + * the CLI exited (e.g., "Disconnected from dispatcher") without having + * the message lost when the alt buffer is torn down. + */ +export function setPendingExitMessage(message: string): void { + pendingExitMessage = message; +} + // Pending choice promise — main loop awaits this before showing next prompt let pendingChoicePromise: Promise | null = null; @@ -1664,6 +1750,15 @@ async function questionWithCompletion( const inputRows = Math.max(1, Math.ceil(inputLineWidth / width)); const totalRows = inputRows + EXTRA_ROWS; + // Hide the cursor for the duration of the redraw. Each drawFixed() + // call positions the cursor and writes text, leaving the cursor at + // the end of the written region (e.g., the right edge of the top + // separator). Without hiding, the cursor visibly jumps through + // every draw target before settling at the input position via + // moveCursorToFixed() below. ANSI.showCursor at the end re-reveals + // it at the final, correct location. + stdout.write(ANSI.hideCursor); + // Update scroll region if prompt height changed layout.setPromptRows(totalRows); @@ -1699,6 +1794,18 @@ async function questionWithCompletion( inputLine += chalk.dim(suggestion + counter); } } + // Pre-clear wrap continuation rows. drawFixed(1, ...) only clears + // row 1 itself via \x1b[2K; when the input is long enough to wrap to + // additional visual rows, the terminal-driven wrap only writes from + // col 1 up to the overflow length on each continuation row, leaving + // the trailing columns populated with stale characters from + // previous frames (e.g., the bottom rule that used to occupy this + // row before inputRows grew, or a longer prior input that has since + // been shortened). Clearing here removes that visual fluff while + // keeping the actual input string untouched. + for (let r = 2; r <= inputRows; r++) { + layout.drawFixed(r, ""); + } layout.drawFixed(1, inputLine); // Bottom rule @@ -2210,6 +2317,13 @@ export async function withEnhancedConsoleClientIO( } usingEnhancedConsole = true; + // Run the entire interactive session inside the terminal's alternate + // screen buffer. The exit handler registered here restores the main + // buffer on every exit path (normal /exit, Ctrl+C, server disconnect, + // /shutdown, etc.), so the parent shell never sees the CLI's leftover + // scroll region, fixed prompt frame, or post-cleanup async writes. + enterAltScreen(); + try { const dispatcherRef: { current?: Dispatcher } = {}; initializeEnhancedConsole(rl, dispatcherRef);