// ─── CLI Renderer ─────────────────────────────────────────────────────────── // Beautiful terminal output: streaming, panels, events, spinners, markdown. import type { HarnessEvent } from '../../core/events/index.js'; // ─── ANSI color helpers (no dependency needed for basic colors) ────────────── const esc = (code: string) => `\x1b[${code}m`; const reset = esc('0'); const bold = (s: string) => `${esc('1')}${s}${reset}`; const dim = (s: string) => `${esc('2')}${s}${reset}`; const green = (s: string) => `${esc('32')}${s}${reset}`; const yellow = (s: string) => `${esc('33')}${s}${reset}`; const blue = (s: string) => `${esc('34')}${s}${reset}`; const magenta = (s: string) => `${esc('35')}${s}${reset}`; const cyan = (s: string) => `${esc('36')}${s}${reset}`; const red = (s: string) => `${esc('31')}${s}${reset}`; const gray = (s: string) => `${esc('90')}${s}${reset}`; // ─── Box Drawing ──────────────────────────────────────────────────────────── const BOX = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' }; function box(title: string, content: string, color: (s: string) => string = cyan, width = 72): string { const innerW = width - 4; const titleStr = ` ${title} `; const topLen = Math.max(0, innerW - titleStr.length); const top = color(`${BOX.tl}${BOX.h}${titleStr}${'─'.repeat(topLen)}${BOX.tr}`); const bot = color(`${BOX.bl}${'─'.repeat(innerW + 2)}${BOX.br}`); const lines = content.split('\n').map((l) => { const trimmed = l.slice(0, innerW); return `${color(BOX.v)} ${trimmed}${' '.repeat(Math.max(0, innerW - stripAnsi(trimmed).length))} ${color(BOX.v)}`; }); return [top, ...lines, bot].join('\n'); } function stripAnsi(s: string): string { return s.replace(/\x1b\[[0-9;]*m/g, ''); } // ─── Spinner ──────────────────────────────────────────────────────────────── const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; export class Spinner { private frame = 0; private interval: ReturnType | null = null; private message = ''; start(message: string): void { this.message = message; this.interval = setInterval(() => { this.frame = (this.frame + 1) % SPINNER_FRAMES.length; process.stdout.write(`\r${cyan(SPINNER_FRAMES[this.frame]!)} ${this.message}`); }, 80); } update(message: string): void { this.message = message; } stop(finalMessage?: string): void { if (this.interval) clearInterval(this.interval); this.interval = null; process.stdout.write(`\r${' '.repeat(this.message.length + 4)}\r`); if (finalMessage) console.log(finalMessage); } } // ─── Event Renderer ───────────────────────────────────────────────────────── export interface RendererOptions { verbose: boolean; compact: boolean; } export class EventRenderer { private opts: RendererOptions; private spinner = new Spinner(); private streamBuffer = ''; constructor(opts: Partial = {}) { this.opts = { verbose: false, compact: false, ...opts }; } render(event: HarnessEvent): void { switch (event.type) { case 'session.started': console.log('\n' + box('Session', `${bold('Goal:')} ${event.goal}\n${gray(`ID: ${event.sessionId}`)}`, cyan)); break; case 'session.completed': this.flushStream(); console.log('\n' + green(`✓ ${bold('Session completed')} — ${event.summary}`)); break; case 'session.failed': this.flushStream(); console.log('\n' + red(`✗ ${bold('Session failed')} — ${event.error}`)); break; case 'plan.updated': if (!this.opts.compact) { const planStr = event.items.map((item) => { const icon = item.status === 'completed' ? green('✓') : item.status === 'in_progress' ? yellow('▶') : item.status === 'failed' ? red('✗') : gray('○'); return ` ${icon} ${item.title}`; }).join('\n'); console.log('\n' + box('Plan', planStr, magenta)); } break; case 'model.request.start': this.spinner.start(`${event.provider}/${event.model} thinking…`); break; case 'model.request.end': this.spinner.stop(); if (this.opts.verbose) { console.log(gray(` ⏱ ${event.durationMs}ms | ${event.usage.totalTokens} tokens | ~$${(event.usage.estimatedCostUsd ?? 0).toFixed(4)}`)); } break; case 'model.stream.delta': this.streamBuffer += event.text; process.stdout.write(event.text); break; case 'model.stream.end': this.flushStream(); break; case 'tool.requested': console.log(`\n${blue('⚡')} ${bold(event.toolCall.toolName)} ${gray(`[${event.toolCall.id.slice(0, 8)}]`)}`); if (this.opts.verbose) { console.log(gray(` Input: ${JSON.stringify(event.toolCall.input).slice(0, 200)}`)); } break; case 'tool.started': this.spinner.start('Tool executing…'); break; case 'tool.progress': this.spinner.update(event.message); break; case 'tool.finished': this.spinner.stop(green(` ✓ Done`) + (this.opts.verbose ? gray(` (${event.durationMs}ms)`) : '')); break; case 'tool.failed': this.spinner.stop(red(` ✗ Failed: ${event.error}`)); break; case 'tool.denied': console.log(yellow(` ⚠ Denied: ${event.reason}`)); break; case 'evaluation.completed': const r = event.report; const icon = r.passed ? green('✓') : red('✗'); const checksStr = r.checks.map((c) => ` ${c.passed ? green('✓') : red('✗')} ${c.name}${c.message ? gray(` — ${c.message}`) : ''}`).join('\n'); console.log('\n' + box('Evaluation', `${icon} ${r.summary}\n${checksStr}`, r.passed ? green : red)); break; case 'artifact.created': console.log(`${magenta('📎')} Artifact: ${bold(event.artifact.title)} ${gray(`(${event.artifact.type})`)}`); break; case 'budget.warning': console.log(yellow(`⚠ Budget warning: ${event.usage.totalTokens} tokens used`)); break; case 'error': console.log(red(`✗ Error: ${event.message}`)); break; } } private flushStream(): void { if (this.streamBuffer) { // Ensure newline after streamed content if (!this.streamBuffer.endsWith('\n')) process.stdout.write('\n'); this.streamBuffer = ''; } } } // ─── Header / Banner ──────────────────────────────────────────────────────── export function renderBanner(): void { console.log(` ${cyan(bold('╭─────────────────────────────────────╮'))} ${cyan(bold('│'))} ${bold('⚡ AI Harness')} ${gray('v0.1.0')} ${cyan(bold('│'))} ${cyan(bold('│'))} ${dim('model-agnostic CLI agent runtime')} ${cyan(bold('│'))} ${cyan(bold('╰─────────────────────────────────────╯'))} `); } // ─── Metrics Summary ──────────────────────────────────────────────────────── export function renderMetrics(metrics: { modelCalls: number; toolCalls: number; totalTokens: number; estimatedCostUsd: number; totalDurationMs: number; toolSuccessRate: number; }): void { const content = [ `${bold('Model calls:')} ${metrics.modelCalls}`, `${bold('Tool calls:')} ${metrics.toolCalls} (${Math.round(metrics.toolSuccessRate * 100)}% success)`, `${bold('Total tokens:')} ${metrics.totalTokens.toLocaleString()}`, `${bold('Est. cost:')} $${metrics.estimatedCostUsd.toFixed(4)}`, `${bold('Duration:')} ${(metrics.totalDurationMs / 1000).toFixed(1)}s`, ].join('\n'); console.log('\n' + box('Metrics', content, gray)); }