| |
| |
|
|
| import type { HarnessEvent } from '../../core/events/index.js'; |
|
|
| |
| 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}`; |
|
|
| |
| 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, ''); |
| } |
|
|
| |
| const SPINNER_FRAMES = ['โ ', 'โ ', 'โ น', 'โ ธ', 'โ ผ', 'โ ด', 'โ ฆ', 'โ ง', 'โ ', 'โ ']; |
|
|
| export class Spinner { |
| private frame = 0; |
| private interval: ReturnType<typeof setInterval> | 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); |
| } |
| } |
|
|
| |
| export interface RendererOptions { |
| verbose: boolean; |
| compact: boolean; |
| } |
|
|
| export class EventRenderer { |
| private opts: RendererOptions; |
| private spinner = new Spinner(); |
| private streamBuffer = ''; |
|
|
| constructor(opts: Partial<RendererOptions> = {}) { |
| 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) { |
| |
| if (!this.streamBuffer.endsWith('\n')) process.stdout.write('\n'); |
| this.streamBuffer = ''; |
| } |
| } |
| } |
|
|
| |
| 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('โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ'))} |
| `); |
| } |
|
|
| |
| 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)); |
| } |
|
|