Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion ts/packages/cli/src/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
withEnhancedConsoleClientIO,
applyQueueSnapshot,
clearRecentSubmissions,
setPendingExitMessage,
} from "../enhancedConsole.js";
import {
setConversationCommandContext,
Expand Down Expand Up @@ -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);
};

Expand Down
114 changes: 114 additions & 0 deletions ts/packages/cli/src/enhancedConsole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | null = null;

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down