feat(agents+chat): agents feature + live chat UX #57
@@ -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`);
|
process.stderr.write(styleStats(`(${String(words)}w · ${(words / sec).toFixed(1)} w/s · ${sec.toFixed(1)}s)`) + ` thread:${res.threadId}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const finalThread = await streamOnce(deps, agent, message, threadId, overrides);
|
const bar = installStatusBar();
|
||||||
|
try {
|
||||||
|
const finalThread = await streamOnce(deps, agent, message, threadId, overrides, bar);
|
||||||
process.stderr.write(`\n(thread: ${finalThread})\n`);
|
process.stderr.write(`\n(thread: ${finalThread})\n`);
|
||||||
|
} finally {
|
||||||
|
bar?.teardown();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runRepl(
|
async function runRepl(
|
||||||
@@ -165,11 +170,17 @@ async function runRepl(
|
|||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
const ask = (q: string): Promise<string> => 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`);
|
process.stderr.write(`Chat with agent '${agent}'. Slash commands: /set /system /tools /clear /save /quit. Ctrl-D to exit.\n`);
|
||||||
if (threadId !== undefined) {
|
if (threadId !== undefined) {
|
||||||
process.stderr.write(`(resuming thread ${threadId})\n`);
|
process.stderr.write(`(resuming thread ${threadId})\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
let line: string;
|
let line: string;
|
||||||
try {
|
try {
|
||||||
@@ -192,7 +203,7 @@ async function runRepl(
|
|||||||
threadId = res.threadId;
|
threadId = res.threadId;
|
||||||
process.stdout.write(`${res.assistant}\n`);
|
process.stdout.write(`${res.assistant}\n`);
|
||||||
} else {
|
} else {
|
||||||
threadId = await streamOnce(deps, agent, line, threadId, overrides);
|
threadId = await streamOnce(deps, agent, line, threadId, overrides, bar);
|
||||||
process.stdout.write('\n');
|
process.stdout.write('\n');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -200,6 +211,9 @@ async function runRepl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
rl.close();
|
rl.close();
|
||||||
|
} finally {
|
||||||
|
bar?.teardown();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSlash(
|
async function handleSlash(
|
||||||
@@ -364,41 +378,26 @@ async function streamOnce(
|
|||||||
message: string,
|
message: string,
|
||||||
threadId: string | undefined,
|
threadId: string | undefined,
|
||||||
overrides: Overrides,
|
overrides: Overrides,
|
||||||
|
bar: StatusBar | null = null,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const url = new URL(`${deps.baseUrl}/api/v1/agents/${encodeURIComponent(agent)}/chat`);
|
const url = new URL(`${deps.baseUrl}/api/v1/agents/${encodeURIComponent(agent)}/chat`);
|
||||||
const body = JSON.stringify({ message, threadId, stream: true, ...overrides });
|
const body = JSON.stringify({ message, threadId, stream: true, ...overrides });
|
||||||
|
|
||||||
// Per-response counters. Updated on every text/thinking delta, surfaced
|
// 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() };
|
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;
|
const TICK_MS = 250;
|
||||||
let tickerTimer: NodeJS.Timeout | null = null;
|
let timer: NodeJS.Timeout | null = null;
|
||||||
let tickerActive = false;
|
function startTicker(): void {
|
||||||
function drawTicker(): void {
|
if (timer !== null || bar === null) return;
|
||||||
if (!STDERR_IS_TTY) return;
|
timer = setInterval(() => bar.update(formatStats(stats, true)), TICK_MS);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
function stopTicker(): void {
|
function stopTicker(): void {
|
||||||
if (tickerTimer !== null) {
|
if (timer !== null) {
|
||||||
clearInterval(tickerTimer);
|
clearInterval(timer);
|
||||||
tickerTimer = null;
|
timer = null;
|
||||||
}
|
}
|
||||||
clearTicker();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
@@ -441,9 +440,7 @@ async function streamOnce(
|
|||||||
if (typeof evt.delta === 'string') {
|
if (typeof evt.delta === 'string') {
|
||||||
recordDelta(stats.content, evt.delta);
|
recordDelta(stats.content, evt.delta);
|
||||||
process.stdout.write(evt.delta);
|
process.stdout.write(evt.delta);
|
||||||
if (tickerTimer === null && STDERR_IS_TTY) {
|
startTicker();
|
||||||
tickerTimer = setInterval(drawTicker, TICK_MS);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
@@ -454,9 +451,7 @@ async function streamOnce(
|
|||||||
if (typeof evt.delta === 'string') {
|
if (typeof evt.delta === 'string') {
|
||||||
recordDelta(stats.thinking, evt.delta);
|
recordDelta(stats.thinking, evt.delta);
|
||||||
process.stderr.write(styleThinking(evt.delta));
|
process.stderr.write(styleThinking(evt.delta));
|
||||||
if (tickerTimer === null && STDERR_IS_TTY) {
|
startTicker();
|
||||||
tickerTimer = setInterval(drawTicker, TICK_MS);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'tool_call':
|
case 'tool_call':
|
||||||
@@ -481,11 +476,16 @@ async function streamOnce(
|
|||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
stopTicker();
|
stopTicker();
|
||||||
const final = formatStats(stats, false);
|
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) {
|
if (final !== '' && STDERR_IS_TTY) {
|
||||||
process.stderr.write(`\n${styleStats(`(${final})`)}`);
|
process.stderr.write(`\n${styleStats(`(${final})`)}`);
|
||||||
} else if (final !== '') {
|
} else if (final !== '') {
|
||||||
process.stderr.write(`\n(${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);
|
resolve(resolvedThread);
|
||||||
});
|
});
|
||||||
res.on('error', (err) => { stopTicker(); reject(err); });
|
res.on('error', (err) => { stopTicker(); reject(err); });
|
||||||
@@ -558,6 +558,108 @@ function formatStats(s: { thinking: PhaseStats; content: PhaseStats }, partial:
|
|||||||
return `${prefix}${parts.join(' | ')}`;
|
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[<top>;<bot>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[] {
|
function collect(value: string, prev: string[]): string[] {
|
||||||
return [...prev, value];
|
return [...prev, value];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user