diff --git a/src/cli/src/commands/chat.ts b/src/cli/src/commands/chat.ts index 951954a..f7fb9da 100644 --- a/src/cli/src/commands/chat.ts +++ b/src/cli/src/commands/chat.ts @@ -149,8 +149,13 @@ async function runOneShot( process.stderr.write(styleStats(`(${String(words)}w · ${(words / sec).toFixed(1)} w/s · ${sec.toFixed(1)}s)`) + ` thread:${res.threadId}\n`); return; } - const finalThread = await streamOnce(deps, agent, message, threadId, overrides); - process.stderr.write(`\n(thread: ${finalThread})\n`); + const bar = installStatusBar(); + try { + const finalThread = await streamOnce(deps, agent, message, threadId, overrides, bar); + process.stderr.write(`\n(thread: ${finalThread})\n`); + } finally { + bar?.teardown(); + } } async function runRepl( @@ -165,41 +170,50 @@ async function runRepl( const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); + // The status bar persists across turns inside a REPL — it shows the last + // response's final rate between messages, then refreshes live during the + // next stream. Only enabled for streaming mode (no rate to show otherwise). + const bar = stream === false ? null : installStatusBar(); + process.stderr.write(`Chat with agent '${agent}'. Slash commands: /set /system /tools /clear /save /quit. Ctrl-D to exit.\n`); if (threadId !== undefined) { process.stderr.write(`(resuming thread ${threadId})\n`); } - while (true) { - let line: string; - try { - line = await ask('> '); - } catch { - break; - } - if (line === '') continue; - if (line.startsWith('/')) { - const handled = await handleSlash(line, deps, agent, overrides, () => { threadId = undefined; }); - if (handled === 'quit') break; - continue; - } - - try { - if (stream === false) { - const body: Record = { message: line, ...overrides }; - if (threadId !== undefined) body.threadId = threadId; - const res = await chatRequestNonStream(deps, agent, body); - threadId = res.threadId; - process.stdout.write(`${res.assistant}\n`); - } else { - threadId = await streamOnce(deps, agent, line, threadId, overrides); - process.stdout.write('\n'); + try { + while (true) { + let line: string; + try { + line = await ask('> '); + } catch { + break; + } + if (line === '') continue; + if (line.startsWith('/')) { + const handled = await handleSlash(line, deps, agent, overrides, () => { threadId = undefined; }); + if (handled === 'quit') break; + continue; + } + + try { + if (stream === false) { + const body: Record = { message: line, ...overrides }; + if (threadId !== undefined) body.threadId = threadId; + const res = await chatRequestNonStream(deps, agent, body); + threadId = res.threadId; + process.stdout.write(`${res.assistant}\n`); + } else { + threadId = await streamOnce(deps, agent, line, threadId, overrides, bar); + process.stdout.write('\n'); + } + } catch (err) { + process.stderr.write(`error: ${(err as Error).message}\n`); } - } catch (err) { - process.stderr.write(`error: ${(err as Error).message}\n`); } + rl.close(); + } finally { + bar?.teardown(); } - rl.close(); } async function handleSlash( @@ -364,41 +378,26 @@ async function streamOnce( message: string, threadId: string | undefined, overrides: Overrides, + bar: StatusBar | null = null, ): Promise { const url = new URL(`${deps.baseUrl}/api/v1/agents/${encodeURIComponent(agent)}/chat`); const body = JSON.stringify({ message, threadId, stream: true, ...overrides }); // Per-response counters. Updated on every text/thinking delta, surfaced - // via the live ticker (stderr) and the final stats footer. + // live through the bottom-row status bar and the final stats footer. const stats = { thinking: newPhase(), content: newPhase() }; - // Live ticker: every TICK_MS, draws a stats line on a ledger one row below - // the current cursor using ANSI save/restore. The ledger floats with the - // content as it grows (terminal scrolls take the saved position with them - // on modern emulators). Disabled when stderr isn't a TTY (pipes stay clean). const TICK_MS = 250; - let tickerTimer: NodeJS.Timeout | null = null; - let tickerActive = false; - function drawTicker(): void { - if (!STDERR_IS_TTY) return; - const text = formatStats(stats, true); - if (text === '') return; - // \x1b[s = save cursor, \n = down one (scrolls if at bottom), - // \x1b[K = clear line, write ticker, \x1b[u = restore. - process.stderr.write(`\x1b[s\n\x1b[K${styleStats(text)}\x1b[u`); - tickerActive = true; - } - function clearTicker(): void { - if (!STDERR_IS_TTY || !tickerActive) return; - process.stderr.write('\x1b[s\n\x1b[K\x1b[u'); - tickerActive = false; + let timer: NodeJS.Timeout | null = null; + function startTicker(): void { + if (timer !== null || bar === null) return; + timer = setInterval(() => bar.update(formatStats(stats, true)), TICK_MS); } function stopTicker(): void { - if (tickerTimer !== null) { - clearInterval(tickerTimer); - tickerTimer = null; + if (timer !== null) { + clearInterval(timer); + timer = null; } - clearTicker(); } return new Promise((resolve, reject) => { @@ -441,9 +440,7 @@ async function streamOnce( if (typeof evt.delta === 'string') { recordDelta(stats.content, evt.delta); process.stdout.write(evt.delta); - if (tickerTimer === null && STDERR_IS_TTY) { - tickerTimer = setInterval(drawTicker, TICK_MS); - } + startTicker(); } break; case 'thinking': @@ -454,9 +451,7 @@ async function streamOnce( if (typeof evt.delta === 'string') { recordDelta(stats.thinking, evt.delta); process.stderr.write(styleThinking(evt.delta)); - if (tickerTimer === null && STDERR_IS_TTY) { - tickerTimer = setInterval(drawTicker, TICK_MS); - } + startTicker(); } break; case 'tool_call': @@ -481,11 +476,16 @@ async function streamOnce( res.on('end', () => { stopTicker(); const final = formatStats(stats, false); + // Inline final-stats footer (permanent record): goes through the + // scroll region, so a copy-pasted transcript captures it. if (final !== '' && STDERR_IS_TTY) { process.stderr.write(`\n${styleStats(`(${final})`)}`); } else if (final !== '') { process.stderr.write(`\n(${final})`); } + // Live status bar: pin the final value so it stays visible between + // turns (answers "how fast was the last one?"). + if (bar !== null && final !== '') bar.update(final); resolve(resolvedThread); }); res.on('error', (err) => { stopTicker(); reject(err); }); @@ -558,6 +558,108 @@ function formatStats(s: { thinking: PhaseStats; content: PhaseStats }, partial: return `${prefix}${parts.join(' | ')}`; } +/** + * Bottom-row status bar via DECSTBM (terminal scroll region). The top of the + * terminal scrolls for content, the last row is locked and redrawn in place. + * + * Why this and not cursor save+restore: save+restore (`\x1b[s` / `\x1b[u`) + * is unreliable when content scrolls or wraps — the saved row drifts off the + * visible area and the restore lands inside content lines, smearing the + * status text into mid-word positions. DECSTBM gives us a region that + * scrolls independently of the locked status row, so streaming content can + * never overlap the live counter. + * + * Returns null when stdout isn't a TTY or the terminal is too small. Pipes + * (`mcpctl chat reviewer | tee log`) get plain text — no escape codes leak. + * + * Idempotent: install() once per chat-session lifecycle; teardown() can be + * called multiple times. Process-level signal handlers ensure we don't leave + * a foreign terminal in a half-locked state if Ctrl-C / uncaught exception + * fires mid-stream. + */ +interface StatusBar { + update(text: string): void; + teardown(): void; +} + +function installStatusBar(): StatusBar | null { + const out = process.stdout; + if (!out.isTTY) return null; + const initialRows = out.rows; + if (typeof initialRows !== 'number' || initialRows < 5) return null; + + let active = true; + let lastText = ''; + let currentRows = initialRows; + + function setScrollRegion(rows: number): void { + // \x1b[;r — set scroll region (1-indexed, inclusive). Reserve + // last row for status. Position cursor at top of the scrollable area. + out.write(`\x1b[1;${String(rows - 1)}r\x1b[${String(rows - 1)};1H`); + } + + function drawAt(rows: number, text: string): void { + if (text === '') return; + // \x1b 7 / \x1b 8 — DEC save/restore cursor (more portable across + // terminals than xterm's \x1b[s / \x1b[u). We move to the bottom row, + // clear it, write the dim status, restore cursor inside the scroll + // region where content/prompt naturally lives. + out.write(`\x1b7\x1b[${String(rows)};1H\x1b[K${styleStats(text)}\x1b8`); + } + + setScrollRegion(currentRows); + + function update(text: string): void { + if (!active) return; + lastText = text; + drawAt(currentRows, text); + } + + function onResize(): void { + if (!active) return; + const rows = out.rows; + if (typeof rows !== 'number' || rows < 5) return; + currentRows = rows; + setScrollRegion(currentRows); + drawAt(currentRows, lastText); + } + + function teardown(): void { + if (!active) return; + active = false; + out.removeListener('resize', onResize); + process.removeListener('exit', teardown); + process.removeListener('SIGINT', sigintHandler); + process.removeListener('SIGTERM', sigintHandler); + process.removeListener('uncaughtException', uncaughtHandler); + // Clear the status row, reset scroll region to full terminal, leave + // cursor at start of the (former) status row so the user's next shell + // prompt has a clean line. + out.write(`\x1b[${String(currentRows)};1H\x1b[K\x1b[r`); + } + + function sigintHandler(): void { + teardown(); + // Re-raise: process.exit with conventional 130 (Ctrl-C exit code). + process.exit(130); + } + + function uncaughtHandler(err: unknown): void { + teardown(); + // Print the original error to the now-restored terminal. + process.stderr.write(`\n${err instanceof Error ? err.stack ?? err.message : String(err)}\n`); + process.exit(1); + } + + out.on('resize', onResize); + process.on('exit', teardown); + process.on('SIGINT', sigintHandler); + process.on('SIGTERM', sigintHandler); + process.on('uncaughtException', uncaughtHandler); + + return { update, teardown }; +} + function collect(value: string, prev: string[]): string[] { return [...prev, value]; }