@aitherium/shell-cli 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1812 @@
1
+ /**
2
+ * Terminal renderer — banners, streaming tokens, markdown, tables.
3
+ */
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+ import { marked } from 'marked';
7
+ import { markedTerminal } from 'marked-terminal';
8
+ import { writeFileSync, mkdirSync, existsSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { homedir } from 'os';
11
+ // Configure marked for terminal rendering
12
+ marked.use(markedTerminal({ reflowText: true, width: 80 }));
13
+ /* ── Banner ─────────────────────────────────────────────────── */
14
+ export function renderBanner(info) {
15
+ const version = 'v1.1.0';
16
+ const title = `AitherShell ${version}`;
17
+ let connected;
18
+ if (info.genesisOnline === true) {
19
+ const label = info.backendName || (info.backendType === 'adk' ? 'agent' : 'Genesis');
20
+ connected = `Connected to ${label} (${info.genesis})`;
21
+ }
22
+ else {
23
+ connected = `Offline (${info.genesis})`;
24
+ }
25
+ // Build service status line from direct probes
26
+ const probed = info.serviceLines || [];
27
+ const upNames = probed.filter(s => s.up).map(s => s.name);
28
+ const downNames = probed.filter(s => !s.up).map(s => s.name);
29
+ const upCount = upNames.length;
30
+ const parts = [
31
+ info.services != null ? `${info.services} services` : (upCount > 0 ? `${upCount} services` : null),
32
+ info.agents != null ? `${info.agents} agents` : null,
33
+ info.llm ? info.llm : null,
34
+ ].filter(Boolean);
35
+ const stats = parts.join(' \u00b7 ') || 'no services detected';
36
+ const userLine = info.user ? `Logged in as ${info.user}` : '';
37
+ // Build the service detail lines: "up: vLLM, ComfyUI, ..." "down: Watch, ..."
38
+ const detailLines = [];
39
+ if (upNames.length > 0)
40
+ detailLines.push(`UP: ${upNames.join(', ')}`);
41
+ if (downNames.length > 0)
42
+ detailLines.push(`DN: ${downNames.join(', ')}`);
43
+ const allLines = [connected, stats, ...detailLines, userLine].filter(Boolean);
44
+ const contentWidth = Math.max(...allLines.map(l => l.length));
45
+ const inner = contentWidth + 2; // 1 space padding each side
46
+ const titleBar = `\u2500 ${title} `;
47
+ const topPad = Math.max(0, inner - titleBar.length);
48
+ const border = chalk.cyan;
49
+ const pad = (raw, colored) => {
50
+ const display = colored || raw;
51
+ const gap = Math.max(0, contentWidth - raw.length);
52
+ return border(` \u2502`) + ` ${display}${' '.repeat(gap)} ` + border(`\u2502`);
53
+ };
54
+ console.log();
55
+ console.log(border(` \u256d${titleBar}${'\u2500'.repeat(topPad)}\u256e`));
56
+ const connColored = info.genesisOnline === true ? chalk.green(connected) : chalk.red(connected);
57
+ console.log(pad(connected, connColored));
58
+ console.log(pad(stats));
59
+ if (upNames.length > 0) {
60
+ const upLine = `UP: ${upNames.join(', ')}`;
61
+ console.log(pad(upLine, chalk.green(upLine)));
62
+ }
63
+ if (downNames.length > 0) {
64
+ const dnLine = `DN: ${downNames.join(', ')}`;
65
+ console.log(pad(dnLine, chalk.red(dnLine)));
66
+ }
67
+ if (userLine) {
68
+ console.log(pad(userLine, chalk.green(userLine)));
69
+ }
70
+ console.log(border(` \u2570${'\u2500'.repeat(inner)}\u256f`));
71
+ console.log();
72
+ }
73
+ /* ── Output cleanup ────────────────────────────────────────── */
74
+ /** Decode HTML entities that LLMs dump from scraped web content. */
75
+ function decodeHtmlEntities(text) {
76
+ return text
77
+ .replace(/&#(\d+);/g, (_m, code) => String.fromCharCode(Number(code)))
78
+ .replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16)))
79
+ .replace(/&/g, '&')
80
+ .replace(/&lt;/g, '<')
81
+ .replace(/&gt;/g, '>')
82
+ .replace(/&quot;/g, '"')
83
+ .replace(/&apos;/g, "'")
84
+ .replace(/&#8211;/g, '–')
85
+ .replace(/&#8212;/g, '—')
86
+ .replace(/&#8216;/g, '\u2018')
87
+ .replace(/&#8217;/g, '\u2019')
88
+ .replace(/&#8220;/g, '\u201C')
89
+ .replace(/&#8221;/g, '\u201D')
90
+ .replace(/&#038;/g, '&')
91
+ .replace(/&#039;/g, "'");
92
+ }
93
+ /**
94
+ * Strip website navigation chrome, cookie banners, menu dumps, and other
95
+ * non-content noise that LLMs paste verbatim from scraped pages.
96
+ */
97
+ function stripWebCruft(text) {
98
+ const lines = text.split('\n');
99
+ const cleaned = [];
100
+ for (const line of lines) {
101
+ const trimmed = line.trim();
102
+ // Skip lines that are mostly bullet-separated nav items (5+ bullets)
103
+ if ((trimmed.match(/ • /g) || []).length >= 4)
104
+ continue;
105
+ // Skip lines that are just "Primary Menu Sections" / nav headers
106
+ if (/^(Primary|Main|Footer|Secondary)\s+Menu/i.test(trimmed))
107
+ continue;
108
+ // Skip cookie/consent boilerplate
109
+ if (/^(We use cookies|Accept all|Cookie settings|Privacy Policy$)/i.test(trimmed))
110
+ continue;
111
+ // Skip lines that are just a chain of pipe-separated short items (nav)
112
+ if (/^(\s*\|?\s*\w[\w\s]{1,20}\s*\|){4,}/.test(trimmed))
113
+ continue;
114
+ cleaned.push(line);
115
+ }
116
+ return cleaned.join('\n');
117
+ }
118
+ /* ── Markdown ───────────────────────────────────────────────── */
119
+ /**
120
+ * Resolve `/api/files?path=...` image references to real file paths
121
+ * and render as clickable OSC 8 terminal hyperlinks.
122
+ */
123
+ export function resolveImagePath(relativePath) {
124
+ const { homedir } = require('os');
125
+ const { resolve } = require('path');
126
+ const home = homedir();
127
+ // Try common AitherOS paths
128
+ const candidates = [
129
+ resolve(home, 'AitherOS', relativePath),
130
+ resolve(home, 'AitherOS-Fresh', 'AitherOS', relativePath),
131
+ resolve('D:', 'AitherOS-Fresh', 'AitherOS', relativePath),
132
+ resolve('/app', 'AitherOS', relativePath),
133
+ ];
134
+ for (const p of candidates) {
135
+ try {
136
+ if (require('fs').existsSync(p))
137
+ return p;
138
+ }
139
+ catch { }
140
+ }
141
+ return resolve(relativePath);
142
+ }
143
+ function resolveImagePaths(text) {
144
+ // Match ![alt](/api/files?path=...) or ![alt](Library/...)
145
+ return text.replace(/!\[([^\]]*)\]\((?:\/api\/files\?path=)?([^)]+)\)/g, (_match, alt, rawPath) => {
146
+ // Resolve to absolute path — Library is under AitherOS/
147
+ let absPath = rawPath;
148
+ if (!rawPath.match(/^[A-Za-z]:[/\\]/) && !rawPath.startsWith('/')) {
149
+ // Relative path — resolve from repo root
150
+ const repoRoot = process.env.AITHER_ROOT || process.cwd();
151
+ absPath = `${repoRoot}/AitherOS/${rawPath}`.replace(/\//g, '\\');
152
+ }
153
+ const label = alt || rawPath.split(/[/\\]/).pop() || 'image';
154
+ const fileUrl = `file:///${absPath.replace(/\\/g, '/')}`;
155
+ // OSC 8 terminal hyperlink: \e]8;;url\e\\ text \e]8;;\e\\
156
+ const link = `\x1b]8;;${fileUrl}\x1b\\${chalk.cyan.underline(label)}\x1b]8;;\x1b\\`;
157
+ return `${link} ${chalk.dim(absPath)}`;
158
+ });
159
+ }
160
+ export function renderMarkdown(text) {
161
+ try {
162
+ let clean = text;
163
+ clean = decodeHtmlEntities(clean);
164
+ clean = stripWebCruft(clean);
165
+ clean = resolveImagePaths(clean);
166
+ return marked.parse(clean);
167
+ }
168
+ catch {
169
+ return text;
170
+ }
171
+ }
172
+ /* ── Bare Code Wrapping ────────────────────────────────────── */
173
+ /**
174
+ * Detect unformatted code in text and wrap it in markdown fences.
175
+ * Safety net for when the LLM ignores the prompt and dumps bare code.
176
+ */
177
+ function wrapBareCode(text) {
178
+ const lines = text.split('\n');
179
+ const result = [];
180
+ let codeBlock = [];
181
+ let inCode = false;
182
+ let inFence = false; // Track existing fenced regions to skip them
183
+ const CODE_PATTERNS = /^(def |class |import |from |async def |export |const |let |var |function |if __name__|@\w+| {2,}\S)/;
184
+ function flushCode() {
185
+ if (codeBlock.length >= 3) {
186
+ // Guess language from content
187
+ const joined = codeBlock.join('\n');
188
+ const lang = /\b(def |import |from |async def |if __name__)/.test(joined) ? 'python'
189
+ : /\b(const |let |var |function |export |=>)/.test(joined) ? 'javascript'
190
+ : 'text';
191
+ result.push('```' + lang);
192
+ result.push(...codeBlock);
193
+ result.push('```');
194
+ }
195
+ else {
196
+ result.push(...codeBlock);
197
+ }
198
+ codeBlock = [];
199
+ inCode = false;
200
+ }
201
+ for (const line of lines) {
202
+ const trimmed = line.trimEnd();
203
+ // Track fenced code blocks — pass them through untouched
204
+ if (trimmed.startsWith('```')) {
205
+ if (inCode)
206
+ flushCode();
207
+ inFence = !inFence;
208
+ result.push(line);
209
+ continue;
210
+ }
211
+ if (inFence) {
212
+ result.push(line);
213
+ continue;
214
+ }
215
+ const looksLikeCode = CODE_PATTERNS.test(trimmed) || (inCode && (trimmed === '' || /^\s{2,}\S/.test(trimmed)));
216
+ if (looksLikeCode) {
217
+ inCode = true;
218
+ codeBlock.push(line);
219
+ }
220
+ else {
221
+ if (inCode)
222
+ flushCode();
223
+ result.push(line);
224
+ }
225
+ }
226
+ if (inCode)
227
+ flushCode();
228
+ return result.join('\n');
229
+ }
230
+ /** Module-level artifact store — accumulates across prompts in one shell session. */
231
+ let _sessionArtifacts = [];
232
+ export function getSessionArtifacts() {
233
+ return [..._sessionArtifacts];
234
+ }
235
+ export function clearSessionArtifacts() {
236
+ _sessionArtifacts = [];
237
+ }
238
+ export function createStreamRenderer(sessionId, prompt, steeringBar) {
239
+ let spinner = null;
240
+ let content = '';
241
+ let hasOutput = false;
242
+ let isGroupChat = false; // Set when session_start has multiple agents
243
+ let lastThinkingContent = ''; // Track to dedup with answer event
244
+ let completePrinted = false; // Prevent duplicate timing lines
245
+ const seenToolResults = new Set(); // Dedup tool_result entries
246
+ let tokenStreamed = false; // Track if answer was delivered via token events
247
+ let tokensStarted = false; // Once true, spinner is permanently disabled for this session
248
+ let lastStage = ''; // Current pipeline stage — shown in heartbeat spinner
249
+ // ── Session trace collection ──
250
+ const traceEvents = [];
251
+ const traceToolCalls = [];
252
+ const traceThinking = [];
253
+ const traceErrors = [];
254
+ let traceContextSources = {};
255
+ let traceModel = '';
256
+ let traceAgent = '';
257
+ let traceTurns = 0;
258
+ let lastKnownMaxTurns = '?'; // Populated by turn_progress
259
+ const startedAt = new Date().toISOString();
260
+ const startTime = Date.now();
261
+ function stopSpinner() {
262
+ if (steeringBar?.active) {
263
+ steeringBar.clearStatus();
264
+ }
265
+ if (spinner) {
266
+ spinner.stop();
267
+ spinner = null;
268
+ }
269
+ }
270
+ /**
271
+ * Start or update the spinner with new text.
272
+ *
273
+ * When SteeringBar is active, status is shown INSIDE the bar (no ora).
274
+ * This prevents stderr (ora) from fighting stdout (bar) for cursor control.
275
+ *
276
+ * Once token streaming begins, the spinner is permanently disabled.
277
+ */
278
+ function startSpinner(text) {
279
+ if (tokensStarted)
280
+ return;
281
+ // When SteeringBar is active, show status in the bar — no ora
282
+ if (steeringBar?.active) {
283
+ // Kill any lingering ora instance
284
+ if (spinner) {
285
+ spinner.stop();
286
+ spinner = null;
287
+ }
288
+ steeringBar.setStatus(text.replace(/\x1b\[[0-9;]*m/g, '')); // strip ANSI colors
289
+ return;
290
+ }
291
+ if (spinner) {
292
+ spinner.text = text;
293
+ if (!spinner.isSpinning)
294
+ spinner.start();
295
+ }
296
+ else {
297
+ spinner = ora({ text, spinner: 'dots', discardStdin: false }).start();
298
+ }
299
+ }
300
+ function printIndentedBlock(text, indent = ' ', color = chalk.dim) {
301
+ stopSpinner();
302
+ const lines = String(text)
303
+ .replace(/\r\n/g, '\n')
304
+ .split('\n');
305
+ for (const line of lines) {
306
+ console.log(color(`${indent}${line}`));
307
+ }
308
+ }
309
+ /** console.log wrapper that auto-clears the spinner before writing.
310
+ * Prevents spinner ANSI escape codes from mixing with event output. */
311
+ function log(...args) {
312
+ stopSpinner();
313
+ console.log(...args);
314
+ }
315
+ return {
316
+ onEvent(event) {
317
+ // ── Collect every event for session trace ──
318
+ traceEvents.push({ ...event, data: { ...event.data, _ts: Date.now() } });
319
+ switch (event.type) {
320
+ case 'session_start':
321
+ if (event.data.group && Array.isArray(event.data.group) && event.data.group.length > 1) {
322
+ isGroupChat = true;
323
+ }
324
+ if (event.data.agent)
325
+ traceAgent = event.data.agent;
326
+ if (event.data.model)
327
+ traceModel = event.data.model;
328
+ startSpinner(chalk.dim(`${event.data.agent || 'thinking'}...`));
329
+ break;
330
+ case 'thinking': {
331
+ const rawThought = event.data.thought || event.data.content || '';
332
+ const turnNum = event.data.turn;
333
+ const phase = event.data.phase || '';
334
+ // Clean up <think> tags
335
+ const clean = rawThought.replace(/<\/?think(?:ing)?>/g, '').trim();
336
+ if (phase === 'streaming' && clean) {
337
+ // ── LIVE chain-of-thought streaming ──
338
+ // Stream reasoning tokens as they arrive, like AitherChat does
339
+ stopSpinner();
340
+ // Use carriage return to overwrite previous streaming line
341
+ const preview = clean.split('\n').filter((l) => l.trim()).slice(-3).join(' ').slice(-200);
342
+ process.stdout.write(`\r${chalk.magenta(' 💭 ')}${chalk.dim(preview)}${' '.repeat(20)}`);
343
+ }
344
+ else if (phase === 'complete' && clean) {
345
+ // ── Full reasoning block complete ──
346
+ // Clear the streaming line, then print the full thought
347
+ process.stdout.write('\r\x1b[2K');
348
+ log(chalk.magenta(' 💭 Reasoning complete:'));
349
+ // Show full reasoning, wrapped nicely
350
+ const lines = clean.split('\n').filter((l) => l.trim());
351
+ for (const line of lines) {
352
+ log(chalk.dim(` ${line}`));
353
+ }
354
+ lastThinkingContent = clean;
355
+ traceThinking.push(clean);
356
+ }
357
+ else if (turnNum != null && clean && clean !== 'thinking...' && clean.length > 5) {
358
+ // ── Agentic turn thought ──
359
+ stopSpinner();
360
+ lastThinkingContent = clean;
361
+ const turnLabel = chalk.cyan(` [Turn ${turnNum}]`);
362
+ // Show full reasoning trace, not just first line
363
+ const lines = clean.split('\n').filter((l) => l.trim());
364
+ for (const line of lines) {
365
+ log(`${turnLabel} ${chalk.dim(line)}`);
366
+ }
367
+ }
368
+ else if (clean.length > 10) {
369
+ // Post-completion thinking block — show full chain of thought
370
+ stopSpinner();
371
+ lastStage = 'reasoning';
372
+ const thinkLines = clean.split('\n').filter((l) => l.trim());
373
+ if (thinkLines.length > 8) {
374
+ log(chalk.magenta(' 💭 Chain of thought:'));
375
+ for (const line of thinkLines) {
376
+ log(chalk.dim(` ${line}`));
377
+ }
378
+ }
379
+ else {
380
+ for (const line of thinkLines) {
381
+ log(chalk.dim(` 💭 ${line}`));
382
+ }
383
+ }
384
+ traceThinking.push(clean);
385
+ }
386
+ else if (spinner) {
387
+ spinner.text = chalk.dim(clean || 'thinking...');
388
+ }
389
+ else if (!hasOutput) {
390
+ startSpinner(chalk.dim(clean || 'thinking...'));
391
+ }
392
+ break;
393
+ }
394
+ case 'thinking_end': {
395
+ // Clear any streaming line remnant
396
+ process.stdout.write('\r\x1b[2K');
397
+ break;
398
+ }
399
+ case 'progress':
400
+ case 'status': {
401
+ const msg = event.data.message || event.data.phase || event.data.status || '';
402
+ const phase = event.data.phase || '';
403
+ // Print ALL meaningful pipeline stages as visible lines
404
+ const _printablePhases = new Set([
405
+ 'tool_reg_start', 'tool_reg_done', 'discovery_start',
406
+ 'notebook_start', 'orchestrator_start', 'runtime_start',
407
+ 'affect_start', 'prefire_start', 'facet_plan_start',
408
+ 'plan_start', 'planner_start', 'planner_done',
409
+ 'research_start',
410
+ // IntentPlanner sub-phases
411
+ 'decompose_start', 'tool_match', 'critique_start',
412
+ // Context assembly sub-phases
413
+ 'context_neurons', 'context_memory', 'context_graph',
414
+ 'context_affect', 'context_flux',
415
+ // Tool execution phases
416
+ 'tool_executing',
417
+ ]);
418
+ if (_printablePhases.has(phase) && msg) {
419
+ stopSpinner();
420
+ log(chalk.dim(` ⚙ ${msg}`));
421
+ startSpinner(chalk.dim(msg));
422
+ }
423
+ else if (msg) {
424
+ if (steeringBar?.active) {
425
+ steeringBar.setStatus(msg);
426
+ }
427
+ else if (spinner) {
428
+ spinner.text = chalk.dim(msg);
429
+ }
430
+ }
431
+ break;
432
+ }
433
+ case 'token': {
434
+ stopSpinner();
435
+ tokensStarted = true; // Permanently disable spinner for this response
436
+ const t = decodeHtmlEntities(event.data.t || '');
437
+ content += t;
438
+ process.stdout.write(t);
439
+ hasOutput = true;
440
+ tokenStreamed = true;
441
+ break;
442
+ }
443
+ case 'message':
444
+ case 'answer':
445
+ case 'final_answer': {
446
+ stopSpinner();
447
+ const answer = event.data.response || event.data.answer || event.data.content || '';
448
+ if (answer) {
449
+ // Dedup: skip if already printed (AgentRuntime emits final_answer,
450
+ // then the router emits answer with the same content)
451
+ if (content && answer.trim() === content.trim()) {
452
+ break;
453
+ }
454
+ if (!hasOutput)
455
+ process.stdout.write('\n');
456
+ // Group-chat: label each agent's response when multiple agents respond
457
+ const answerAgent = event.data.agent || '';
458
+ if (isGroupChat && answerAgent) {
459
+ process.stdout.write(chalk.bold.cyan(`\n [${answerAgent}]\n`));
460
+ }
461
+ content = answer;
462
+ // Write answer text immediately — don't block on heavy markdown
463
+ // rendering. Markdown is deferred to the 'complete' handler for
464
+ // responses that contain code blocks.
465
+ process.stdout.write(answer);
466
+ if (!answer.endsWith('\n'))
467
+ process.stdout.write('\n');
468
+ hasOutput = true;
469
+ }
470
+ // Render interactive/display blocks in terminal
471
+ const blocks = event.data.render_blocks;
472
+ if (blocks && Array.isArray(blocks) && blocks.length > 0) {
473
+ process.stdout.write('\n');
474
+ renderTerminalBlocks(blocks);
475
+ }
476
+ break;
477
+ }
478
+ case 'render_blocks':
479
+ case 'await_input': {
480
+ stopSpinner();
481
+ const rblocks = event.data.render_blocks || event.data.blocks || [];
482
+ if (rblocks.length > 0) {
483
+ renderTerminalBlocks(rblocks);
484
+ }
485
+ if (event.data.prompt) {
486
+ log(chalk.yellow(`\n ⏳ ${event.data.prompt}`));
487
+ }
488
+ break;
489
+ }
490
+ case 'partial': {
491
+ stopSpinner();
492
+ const partial = event.data.content || event.data.text || '';
493
+ if (partial && !hasOutput) {
494
+ content = partial;
495
+ process.stdout.write(partial);
496
+ hasOutput = true;
497
+ }
498
+ break;
499
+ }
500
+ case 'tool_call': {
501
+ stopSpinner();
502
+ const turnLabel2 = event.data.turn != null ? chalk.cyan(`[Turn ${event.data.turn}] `) : '';
503
+ const tools = event.data.tools || event.data.tool_calls || [];
504
+ for (const tool of tools) {
505
+ const name = tool.name || tool.function?.name || 'tool';
506
+ const args = tool.args || tool.arguments;
507
+ traceToolCalls.push({ name, args, timestamp: Date.now() });
508
+ // Show the most useful argument (query, url, task, prompt, etc.)
509
+ let argDisplay = '';
510
+ if (args) {
511
+ const key = args.query || args.url || args.task || args.prompt || args.path || args.code;
512
+ if (key) {
513
+ argDisplay = chalk.dim(` → ${String(key)}`);
514
+ }
515
+ else {
516
+ argDisplay = chalk.dim(` ${JSON.stringify(args)}`);
517
+ }
518
+ }
519
+ log(` ${turnLabel2}` + chalk.yellow(`⚡ ${name}`) + argDisplay);
520
+ }
521
+ break;
522
+ }
523
+ case 'tool_result': {
524
+ const results = event.data.results || [];
525
+ let anySuccessfulOutput = false;
526
+ for (const result of results) {
527
+ // Dedup: skip identical tool+output combos (LLM may call the
528
+ // same tool twice across follow-up turns)
529
+ const dedupKey = `${result.tool || 'tool'}::${(result.output || '').slice(0, 200)}`;
530
+ if (seenToolResults.has(dedupKey))
531
+ continue;
532
+ seenToolResults.add(dedupKey);
533
+ const icon = result.success !== false ? chalk.green('\u2713') : chalk.red('\u2717');
534
+ const name = result.tool || 'tool';
535
+ if (result.success !== false && result.output)
536
+ anySuccessfulOutput = true;
537
+ if (result.output && result.success !== false) {
538
+ try {
539
+ const parsed = JSON.parse(result.output);
540
+ // ── Search results: expand each result ──
541
+ if (parsed.results && Array.isArray(parsed.results) && parsed.results.length > 0) {
542
+ log(` ${icon} ${chalk.bold(name)} — ${parsed.results.length} results:`);
543
+ if (parsed.answer && parsed.answer.length > 0) {
544
+ printIndentedBlock(`📝 ${decodeHtmlEntities(parsed.answer)}`);
545
+ }
546
+ for (const [idx, r] of parsed.results.entries()) {
547
+ const title = decodeHtmlEntities(r.title || 'Untitled');
548
+ const snippet = decodeHtmlEntities(r.snippet || r.body || '');
549
+ log(chalk.white(` ${idx + 1}. ${title}`));
550
+ if (snippet) {
551
+ printIndentedBlock(snippet, ' ');
552
+ }
553
+ }
554
+ }
555
+ // ── Webpage fetch: show title + clean content preview ──
556
+ else if (parsed.content) {
557
+ const title = parsed.title ? decodeHtmlEntities(parsed.title) + ' — ' : '';
558
+ const cleanContent = stripWebCruft(decodeHtmlEntities(parsed.content));
559
+ log(` ${icon} ${chalk.bold(name)}`);
560
+ printIndentedBlock(`${title}${cleanContent}`);
561
+ }
562
+ // ── Generic JSON ──
563
+ else {
564
+ log(` ${icon} ${chalk.bold(name)}:`);
565
+ printIndentedBlock(JSON.stringify(parsed, null, 2));
566
+ }
567
+ }
568
+ catch {
569
+ // Non-JSON output
570
+ const lines = String(result.output).split('\n').filter((l) => l.trim());
571
+ log(` ${icon} ${chalk.bold(name)}:`);
572
+ for (const line of lines) {
573
+ printIndentedBlock(line);
574
+ }
575
+ }
576
+ }
577
+ else if (result.success === false) {
578
+ const err = result.output || result.error || 'unknown error';
579
+ log(` ${icon} ${chalk.bold(name)}:`);
580
+ printIndentedBlock(String(err), ' ', chalk.red);
581
+ }
582
+ else {
583
+ log(` ${icon} ${name}`);
584
+ }
585
+ }
586
+ // Mark that we showed tool output — prevents '(no response)'
587
+ // when the LLM's text answer is empty but tools returned data
588
+ if (anySuccessfulOutput)
589
+ hasOutput = true;
590
+ break;
591
+ }
592
+ case 'done':
593
+ case 'complete': {
594
+ stopSpinner();
595
+ // Only render content + timing once (engine + router can both emit 'complete')
596
+ if (completePrinted)
597
+ break;
598
+ completePrinted = true;
599
+ if (!hasOutput && event.data.content) {
600
+ content = event.data.content;
601
+ process.stdout.write(renderMarkdown(wrapBareCode(content)));
602
+ hasOutput = true;
603
+ }
604
+ else if (tokenStreamed) {
605
+ // Content was streamed raw via token events — re-render with
606
+ // markdown so code blocks display properly. wrapBareCode catches
607
+ // bare code even when no fences are present.
608
+ const wrapped = wrapBareCode(content);
609
+ if (wrapped !== content || content.includes('```')) {
610
+ process.stdout.write('\n');
611
+ process.stdout.write(renderMarkdown(wrapped));
612
+ }
613
+ }
614
+ if (hasOutput)
615
+ process.stdout.write('\n');
616
+ const meta = [];
617
+ if (event.data.model || event.data.model_used)
618
+ meta.push(event.data.model || event.data.model_used);
619
+ if (event.data.duration_ms || event.data.elapsed_ms) {
620
+ meta.push(`${Math.round(event.data.duration_ms || event.data.elapsed_ms)}ms`);
621
+ }
622
+ if (event.data.turns_completed)
623
+ meta.push(`${event.data.turns_completed} turns`);
624
+ if (event.data.agent)
625
+ meta.push(event.data.agent);
626
+ if (meta.length) {
627
+ log(chalk.dim(` [${meta.join(' \u00b7 ')}]`));
628
+ }
629
+ break;
630
+ }
631
+ case 'error':
632
+ stopSpinner();
633
+ log(chalk.red(`\n Error: ${event.data.error || 'unknown error'}`));
634
+ traceErrors.push(String(event.data.error || 'unknown error'));
635
+ break;
636
+ case 'stream_timeout':
637
+ // Soft timeout — stream went quiet but agent may still be running.
638
+ // Don't show a hard error; the REPL will check forge status.
639
+ stopSpinner();
640
+ hasOutput = true; // Prevent "(no response)" — we already showed trace output
641
+ break;
642
+ case 'heartbeat':
643
+ case 'keepalive': {
644
+ const elapsedMs = event.data.elapsed_ms || event.data.elapsed;
645
+ if (elapsedMs != null) {
646
+ const secs = Math.round(elapsedMs / 1000);
647
+ const elapsed = secs >= 60 ? `${Math.floor(secs / 60)}m ${secs % 60}s` : `${secs}s`;
648
+ const stageHint = lastStage ? ` · ${lastStage}` : '';
649
+ const statusMsg = `Working... ${elapsed} elapsed${stageHint}`;
650
+ if (steeringBar?.active) {
651
+ steeringBar.setStatus(statusMsg);
652
+ }
653
+ else if (spinner) {
654
+ spinner.text = chalk.dim(statusMsg);
655
+ }
656
+ if (secs > 0 && secs % 30 === 0) {
657
+ stopSpinner();
658
+ log(chalk.dim(` ⏱ Still working... ${elapsed}${stageHint}`));
659
+ startSpinner(chalk.dim(statusMsg));
660
+ }
661
+ }
662
+ else {
663
+ const stageHint = lastStage ? ` · ${lastStage}` : '';
664
+ if (steeringBar?.active) {
665
+ steeringBar.setStatus(`Working...${stageHint}`);
666
+ }
667
+ else if (spinner) {
668
+ spinner.text = chalk.dim(`Working...${stageHint}`);
669
+ }
670
+ else if (!hasOutput) {
671
+ startSpinner(chalk.dim('Working...'));
672
+ }
673
+ }
674
+ break;
675
+ }
676
+ case 'debug':
677
+ break; // silent
678
+ // ── LLM lifecycle events — show what the model is doing ──
679
+ case 'llm_start': {
680
+ const llmModel = event.data.model || 'auto';
681
+ const llmTurn = event.data.turn;
682
+ const turnHint = llmTurn != null ? ` (turn ${llmTurn})` : '';
683
+ lastStage = `inference: ${llmModel}`;
684
+ startSpinner(chalk.dim(`🧠 LLM inference: ${llmModel}${turnHint}`));
685
+ break;
686
+ }
687
+ case 'llm_done': {
688
+ const llmMs = event.data.llm_time_ms || event.data.elapsed_ms || event.data.duration_ms || 0;
689
+ const llmTokens = event.data.tokens_used || event.data.tokens || event.data.completion_tokens || 0;
690
+ const llmMod = event.data.model_used || event.data.model || '';
691
+ const hasTools = event.data.has_tool_calls;
692
+ stopSpinner();
693
+ const parts = [];
694
+ if (llmMs > 0)
695
+ parts.push(`${Math.round(llmMs)}ms`);
696
+ if (llmTokens > 0)
697
+ parts.push(`${llmTokens} tokens`);
698
+ if (llmMod)
699
+ parts.push(llmMod);
700
+ if (hasTools)
701
+ parts.push('+ tool calls');
702
+ if (parts.length > 0) {
703
+ log(chalk.dim(` ⚡ LLM: ${parts.join(' · ')}`));
704
+ }
705
+ lastStage = '';
706
+ break;
707
+ }
708
+ case 'llm_error': {
709
+ stopSpinner();
710
+ const llmErr = event.data.error || 'inference failed';
711
+ log(chalk.red(` ⚡ LLM error: ${llmErr}`));
712
+ traceErrors.push(`LLM: ${llmErr}`);
713
+ lastStage = '';
714
+ break;
715
+ }
716
+ // ── Pipeline trace events — show in spinner ──
717
+ case 'classify': {
718
+ const intent = event.data.intent?.type || '?';
719
+ const effort = event.data.effort?.level || '?';
720
+ const tier = event.data.effort?.tier || '';
721
+ const followupBoost = event.data.effort?.followup_boost || 0;
722
+ const oneshot = event.data.effort?.oneshot || false;
723
+ const depth = event.data.depth_label || '';
724
+ let effortLine = ` 📋 Intent: ${intent} | Effort: ${effort}`;
725
+ if (tier)
726
+ effortLine += ` | Tier: ${tier}`;
727
+ if (depth)
728
+ effortLine += ` | Depth: ${depth}`;
729
+ if (followupBoost > 0)
730
+ effortLine += ` (+${followupBoost} follow-up)`;
731
+ if (oneshot)
732
+ effortLine += ` ⚡ one-shot`;
733
+ log(chalk.dim(effortLine));
734
+ break;
735
+ }
736
+ // promotion_suggested handled below (near line 1050+)
737
+ case 'classify_update': {
738
+ const updatedIntent = event.data.intent?.type || '?';
739
+ const updatedEffort = event.data.effort?.level || '?';
740
+ const reason = event.data.reason || 'context';
741
+ log(chalk.dim(` 📋 Intent: ${updatedIntent} | Effort: ${updatedEffort} (${reason})`));
742
+ break;
743
+ }
744
+ case 'model_select': {
745
+ const model = event.data.model_selection?.recommended_model || 'auto';
746
+ const tier = event.data.model_selection?.tier || 'auto';
747
+ log(chalk.dim(` 🎯 Model: ${model} (${tier})`));
748
+ break;
749
+ }
750
+ case 'context_assembly': {
751
+ const tokens = event.data.total_tokens || 0;
752
+ const gatherMs = event.data.gather_time_ms || 0;
753
+ const sources = event.data.sources || {};
754
+ traceContextSources = sources;
755
+ const sourceEntries = Object.entries(sources);
756
+ if (sourceEntries.length > 0) {
757
+ stopSpinner();
758
+ log(chalk.dim(` 📦 Context assembled: ${tokens} tokens (${gatherMs}ms)`));
759
+ for (const [name, info] of sourceEntries) {
760
+ const si = info;
761
+ const toks = si.tokens || 0;
762
+ const extra = si.fired ? ` (${si.fired} fired)` : '';
763
+ const bar = toks > 0 ? chalk.green('█'.repeat(Math.min(Math.ceil(toks / 50), 20))) : chalk.red('—');
764
+ log(chalk.dim(` ${bar} ${name}: ${toks} tokens${extra}`));
765
+ }
766
+ startSpinner(chalk.dim('Building prompt...'));
767
+ }
768
+ else {
769
+ log(chalk.dim(` 📦 Context: ${tokens} tokens`));
770
+ }
771
+ break;
772
+ }
773
+ case 'context_xray': {
774
+ // Rich context introspection — show what made it into the prompt
775
+ const snapshot = event.data.snapshot_id || '';
776
+ const totalCtx = event.data.total_context_tokens || 0;
777
+ const breakdown = event.data.breakdown || {};
778
+ if (Object.keys(breakdown).length > 0) {
779
+ log(chalk.dim(` 🔬 Context X-Ray: ${totalCtx} tokens ${snapshot ? `(${snapshot})` : ''}`));
780
+ for (const [k, v] of Object.entries(breakdown)) {
781
+ log(chalk.dim(` ${k}: ${v}`));
782
+ }
783
+ }
784
+ break;
785
+ }
786
+ case 'context_summary': {
787
+ const origTokens = event.data.original_tokens || 0;
788
+ const summTokens = event.data.summary_tokens || 0;
789
+ if (origTokens > 0) {
790
+ const pct = summTokens > 0 ? Math.round((1 - summTokens / origTokens) * 100) : 0;
791
+ log(chalk.dim(` 📝 Context compressed: ${origTokens} → ${summTokens} tokens (${pct}% reduction)`));
792
+ }
793
+ break;
794
+ }
795
+ case 'clarification_needed': {
796
+ stopSpinner();
797
+ const questions = event.data.questions || [];
798
+ const planSummary = event.data.plan_summary || '';
799
+ if (planSummary) {
800
+ log(chalk.cyan(`\n 📋 Plan: `) + chalk.dim(planSummary));
801
+ }
802
+ if (questions.length > 0) {
803
+ log(chalk.yellow(`\n ⏸ Clarification needed:`));
804
+ for (const [i, q] of questions.entries()) {
805
+ const qText = typeof q === 'string' ? q : (q.question || q.text || '?');
806
+ const opts = (typeof q === 'object' && q.suggested_options) ? chalk.dim(` Options: ${q.suggested_options.join(', ')}`) : '';
807
+ log(chalk.white(` ${i + 1}. ${qText}`) + opts);
808
+ }
809
+ log(chalk.dim('\n 💡 Just type your answer — it will continue the plan automatically.'));
810
+ }
811
+ hasOutput = true;
812
+ break;
813
+ }
814
+ case 'plan_refined': {
815
+ stopSpinner();
816
+ const refined = event.data.plan_summary || '';
817
+ const executable = event.data.is_executable ? chalk.green(' [ready]') : '';
818
+ if (refined) {
819
+ log(chalk.cyan(`\n 📋 Plan refined: `) + chalk.dim(refined) + executable);
820
+ }
821
+ // Restart spinner so heartbeats keep showing liveness
822
+ startSpinner(chalk.dim('Executing plan...'));
823
+ break;
824
+ }
825
+ case 'plan_ready': {
826
+ stopSpinner();
827
+ // Backend sends summary string, not steps array
828
+ const summary = event.data.summary || '';
829
+ const steps = event.data.steps || [];
830
+ if (summary) {
831
+ const agentic = event.data.agentic ? chalk.cyan(' [agentic]') : '';
832
+ log(chalk.cyan(`\n 📋 Plan: `) + chalk.dim(summary) + agentic);
833
+ }
834
+ else if (steps.length > 0) {
835
+ log(chalk.cyan(`\n 📋 Plan: ${steps.length} steps`));
836
+ for (const [i, step] of steps.entries()) {
837
+ const task = typeof step === 'string' ? step : (step.task || step.description || '?');
838
+ log(chalk.dim(` ${i + 1}. ${task}`));
839
+ }
840
+ }
841
+ // Restart spinner so heartbeats keep showing liveness
842
+ startSpinner(chalk.dim('Executing plan...'));
843
+ break;
844
+ }
845
+ case 'plan_step': {
846
+ const icon = event.data.status === 'complete' ? chalk.green('✓') : chalk.yellow('→');
847
+ log(chalk.dim(` ${icon} ${event.data.step || '?'}`));
848
+ break;
849
+ }
850
+ case 'approval_required': {
851
+ stopSpinner();
852
+ log(chalk.yellow(`\n ⏸ Approval required: ${event.data.action || event.data.reason || 'plan review'}`));
853
+ log(chalk.dim(' (use web UI to respond, or /approve to continue)'));
854
+ break;
855
+ }
856
+ case 'tool_selection': {
857
+ const tools = (event.data.tool_names || event.data.detected_categories || []).join(', ') || 'none';
858
+ const strategy = event.data.strategy || '';
859
+ log(chalk.dim(` 🔧 Tools: [${tools}]${strategy ? ` strategy=${strategy}` : ''}`));
860
+ break;
861
+ }
862
+ case 'tool_loop_step': {
863
+ // Individual tool completion within the multi-turn tool loop
864
+ const stepName = event.data.tool_name || 'tool';
865
+ const stepOk = event.data.success !== false;
866
+ const stepMs = event.data.elapsed_ms || 0;
867
+ const stepIcon = stepOk ? chalk.green('\u2713') : chalk.red('\u2717');
868
+ const msHint = stepMs > 0 ? chalk.dim(` (${stepMs}ms)`) : '';
869
+ log(` ${stepIcon} ${chalk.bold(stepName)}${msHint}`);
870
+ break;
871
+ }
872
+ case 'checkpoint': {
873
+ const turn = event.data.turn || '?';
874
+ const maxTurns = event.data.max_turns || lastKnownMaxTurns || '?';
875
+ if (event.data.max_turns)
876
+ lastKnownMaxTurns = event.data.max_turns;
877
+ const ok = event.data.ok || 0;
878
+ const errors = event.data.errors || 0;
879
+ const totalCalls = event.data.total_tool_calls || 0;
880
+ const budgetPct = event.data.budget_pct || 0;
881
+ const elapsed = event.data.elapsed_ms ? `${event.data.elapsed_ms}ms` : '';
882
+ // Show progress bar
883
+ const filled = Math.round(budgetPct / 10);
884
+ const bar = '█'.repeat(filled) + '░'.repeat(10 - filled);
885
+ const errStr = errors > 0 ? chalk.red(` ${errors} err`) : '';
886
+ log(chalk.dim(` ── Turn ${turn}/${maxTurns} [${bar}] ${ok} ok${errStr} · ${totalCalls} total calls · ${elapsed}`));
887
+ break;
888
+ }
889
+ case 'ooda_observe': {
890
+ stopSpinner();
891
+ const calls = event.data.tool_calls || 0;
892
+ const ok = event.data.successes || 0;
893
+ const turn3 = event.data.turn || '?';
894
+ const maxT = event.data.max_turns || lastKnownMaxTurns || '?';
895
+ log(chalk.dim(` 🔍 observe: Turn ${turn3}/${maxT} — ${ok}/${calls} tools ok`));
896
+ break;
897
+ }
898
+ case 'ooda_decide': {
899
+ const phase = event.data.phase || 'decide';
900
+ const reason = event.data.reason || '';
901
+ // Always show OODA decisions — they explain what the agent is doing
902
+ if (reason) {
903
+ stopSpinner();
904
+ log(chalk.dim(` 🔄 ${phase}: ${reason}`));
905
+ }
906
+ else if (spinner) {
907
+ spinner.text = chalk.dim(`OODA ${phase}`);
908
+ }
909
+ if (event.data.strip_tools) {
910
+ log(chalk.dim(' 🔄 Synthesizing from gathered data...'));
911
+ }
912
+ break;
913
+ }
914
+ case 'ooda_delegate': {
915
+ stopSpinner();
916
+ const target = event.data.delegate_to || '?';
917
+ const reason = event.data.reason || '';
918
+ log(chalk.yellow(` 🤝 Delegation → ${target}: ${reason}`));
919
+ break;
920
+ }
921
+ case 'loop_guard': {
922
+ log(chalk.red(` ⛔ Loop guard: ${event.data.tool} — ${event.data.reason || 'blocked'}`));
923
+ break;
924
+ }
925
+ case 'speculative_fire': {
926
+ stopSpinner();
927
+ const specTools = (event.data.tools || []).join(', ');
928
+ log(chalk.cyan(` 🚀 Pre-firing: [${specTools}] (parallel speculative execution)`));
929
+ break;
930
+ }
931
+ case 'speculative_result': {
932
+ stopSpinner();
933
+ const fired = event.data.tools_fired?.length || 0;
934
+ const succeeded = event.data.tools_succeeded?.length || 0;
935
+ const chars = event.data.total_chars || 0;
936
+ log(chalk.green(` ✓ Pre-fetched: ${succeeded}/${fired} tools returned data (${chars} chars)`));
937
+ break;
938
+ }
939
+ case 'turn_start': {
940
+ // Start spinner for new agent turn
941
+ startSpinner(chalk.dim(`Turn ${event.data.turn || '?'}...`));
942
+ break;
943
+ }
944
+ case 'escalation': {
945
+ stopSpinner();
946
+ log(chalk.yellow(` ⬆ Escalation #${event.data.attempt || 1}: ${event.data.reason || 'quality'}`));
947
+ break;
948
+ }
949
+ case 'reasoning_start':
950
+ case 'reasoning_engage': {
951
+ const rawDepth = event.data.depth || 'deep';
952
+ // Map internal labels to user-friendly display
953
+ const depth = rawDepth === 'skip' ? 'light' : rawDepth === 'none' ? 'light' : rawDepth;
954
+ const reason = event.data.reason || event.data.content || '';
955
+ if (spinner) {
956
+ spinner.text = chalk.dim(`Reasoning (${depth}): ${reason || '...'}`);
957
+ }
958
+ // Show reasoning trace content when available
959
+ if (reason.length > 30) {
960
+ log(chalk.dim(` 🧠 Reasoning (${depth}): ${reason}`));
961
+ }
962
+ break;
963
+ }
964
+ case 'reasoning_strategy': {
965
+ stopSpinner();
966
+ const index = event.data.strategy != null ? `#${event.data.strategy}` : '';
967
+ const name = event.data.name || event.data.strategy_name || 'strategy';
968
+ const detail = event.data.detail || event.data.content || '';
969
+ log(chalk.dim(` 🧠 Strategy ${index} ${name}`.trimEnd()));
970
+ if (detail) {
971
+ printIndentedBlock(detail);
972
+ }
973
+ break;
974
+ }
975
+ case 'reasoning_trace':
976
+ case 'reasoning_step': {
977
+ const step = event.data.content || event.data.step || event.data.trace || '';
978
+ if (step) {
979
+ stopSpinner();
980
+ log(chalk.dim(' 🧠 Reasoning step:'));
981
+ printIndentedBlock(step);
982
+ }
983
+ break;
984
+ }
985
+ case 'agentic_upgrade': {
986
+ stopSpinner();
987
+ const reason = event.data.reason || 'auto';
988
+ log(chalk.cyan(` 🔀 Agentic mode: ${reason}`));
989
+ break;
990
+ }
991
+ case 'promotion_suggested': {
992
+ stopSpinner();
993
+ const sugEffort = event.data.suggested_effort || '?';
994
+ const sugReason = event.data.reason || '';
995
+ const sugMsg = event.data.message || '';
996
+ log(chalk.yellow(`\n ⬆ ${sugMsg || `Suggest escalation to effort ${sugEffort}: ${sugReason}`}`));
997
+ break;
998
+ }
999
+ case 'council':
1000
+ case 'council_perspective': {
1001
+ const agent = event.data.agent || 'council';
1002
+ const content = event.data.perspective || event.data.consensus || '';
1003
+ if (content) {
1004
+ log(chalk.magenta(` 👥 ${agent}: `) + chalk.dim(content));
1005
+ }
1006
+ break;
1007
+ }
1008
+ case 'agent_message': {
1009
+ const agentName = event.data.agent || 'agent';
1010
+ log(chalk.blue(` 🤖 ${agentName}: `) + chalk.dim(event.data.content || ''));
1011
+ break;
1012
+ }
1013
+ case 'steering_guide': {
1014
+ log(chalk.yellow(` 🔄 Steering: ${event.data.hook || '?'} → ${event.data.reason || 'guided'}`));
1015
+ if (event.data.suggestion) {
1016
+ log(chalk.dim(` 💡 ${event.data.suggestion}`));
1017
+ }
1018
+ break;
1019
+ }
1020
+ case 'think_start': {
1021
+ // Show think configuration as a visible pipeline trace
1022
+ const model = event.data.model || 'auto';
1023
+ const effort = event.data.effort_level || '?';
1024
+ const reasoning = event.data.use_reasoning ? 'reasoning' : 'standard';
1025
+ const elapsed = event.data.elapsed_ms ? `${event.data.elapsed_ms}ms` : '';
1026
+ log(chalk.dim(` ⚙ Think: E${effort} ${reasoning} model=${model} ${elapsed}`));
1027
+ break;
1028
+ }
1029
+ // ── UCB pipeline stages — context gathering + LLM call ──
1030
+ case 'context_start': {
1031
+ const sources = (event.data.sources || []).join(', ') || 'none';
1032
+ startSpinner(chalk.dim(`Gathering context: ${sources}`));
1033
+ break;
1034
+ }
1035
+ case 'context_done': {
1036
+ const gatherMs2 = event.data.gather_time_ms || 0;
1037
+ const sourceResults = event.data.sources || {};
1038
+ const sourceNames = Object.keys(sourceResults);
1039
+ const neuronsFired = event.data.neurons_fired || 0;
1040
+ const cacheHits = event.data.cache_hits || 0;
1041
+ const ctxTokens = event.data.context_tokens || event.data.system_prompt_tokens || 0;
1042
+ if (sourceNames.length > 0) {
1043
+ stopSpinner();
1044
+ const extras = [];
1045
+ if (neuronsFired > 0)
1046
+ extras.push(`${neuronsFired} neurons`);
1047
+ if (cacheHits > 0)
1048
+ extras.push(`${cacheHits} cache hits`);
1049
+ if (ctxTokens > 0)
1050
+ extras.push(`${ctxTokens} tokens`);
1051
+ const extStr = extras.length > 0 ? ` · ${extras.join(', ')}` : '';
1052
+ log(chalk.dim(` 📦 Context gathered: ${sourceNames.join(', ')} (${gatherMs2}ms${extStr})`));
1053
+ // Show token breakdown per source
1054
+ for (const [name, info] of Object.entries(sourceResults)) {
1055
+ const si = info;
1056
+ const toks = si.tokens || 0;
1057
+ if (toks > 0) {
1058
+ const bar = chalk.green('█'.repeat(Math.min(Math.ceil(toks / 100), 20)));
1059
+ const extra = si.fired ? ` (${si.fired} fired)` : si.count ? ` (${si.count} entries)` : '';
1060
+ log(chalk.dim(` ${bar} ${name}: ${toks} tokens${extra}`));
1061
+ }
1062
+ }
1063
+ }
1064
+ break;
1065
+ }
1066
+ case 'llm_start': {
1067
+ // From UCB: has model, prompt_tokens_est, has_tools
1068
+ // From AgentRuntime: has turn, timestamp (minimal)
1069
+ const llmModel = event.data.model || 'auto';
1070
+ traceModel = llmModel;
1071
+ const promptTokens = event.data.prompt_tokens_est || 0;
1072
+ const hasTools2 = event.data.has_tools ? ' + tools' : '';
1073
+ const turnInfo = event.data.turn ? `Turn ${event.data.turn} · ` : '';
1074
+ const tokenInfo = promptTokens > 0 ? ` (${promptTokens} tokens${hasTools2})` : '';
1075
+ const spinText = `${turnInfo}LLM inference: ${llmModel}${tokenInfo}`;
1076
+ lastStage = `LLM: ${llmModel}`;
1077
+ // Always print a visible line so the user knows what's happening
1078
+ stopSpinner();
1079
+ log(chalk.dim(` 🧠 ${spinText}`));
1080
+ startSpinner(chalk.dim(spinText));
1081
+ break;
1082
+ }
1083
+ case 'llm_done':
1084
+ case 'llm_end': {
1085
+ stopSpinner();
1086
+ const llmMs = event.data.llm_time_ms || event.data.duration_ms || 0;
1087
+ const tokensUsed = event.data.tokens_used || event.data.tokens || 0;
1088
+ const llmModelUsed = event.data.model_used || event.data.model || 'default';
1089
+ const hasThinking = event.data.has_thinking ? ' + reasoning' : '';
1090
+ const hasToolCalls = event.data.has_tool_calls ? ' + tool_calls' : '';
1091
+ log(chalk.dim(` ⚡ LLM: ${llmModelUsed} → ${tokensUsed} tokens (${Math.round(llmMs)}ms)${hasThinking}${hasToolCalls}`));
1092
+ break;
1093
+ }
1094
+ case 'middleware_progress': {
1095
+ const mwName = event.data.middleware || event.data.stage || 'middleware';
1096
+ const mwStatus = event.data.status || 'running';
1097
+ const mwDetail = event.data.detail || '';
1098
+ lastStage = mwName;
1099
+ const mwIcon = mwStatus === 'complete' ? chalk.green('✓') : chalk.yellow('→');
1100
+ const detailStr = mwDetail ? chalk.dim(` (${mwDetail})`) : '';
1101
+ if (steeringBar?.active) {
1102
+ // Log line goes through interceptor (one bar redraw).
1103
+ // Status updates without extra redraws.
1104
+ console.log(chalk.dim(` ${mwIcon} ${mwName}: ${mwStatus}${detailStr}`));
1105
+ steeringBar.setStatus(`${mwName}...`);
1106
+ }
1107
+ else {
1108
+ stopSpinner();
1109
+ log(chalk.dim(` ${mwIcon} ${mwName}: ${mwStatus}${detailStr}`));
1110
+ startSpinner(chalk.dim(`${mwName}...`));
1111
+ }
1112
+ break;
1113
+ }
1114
+ case 'mcts_plan': {
1115
+ stopSpinner();
1116
+ const mSteps = event.data.steps || [];
1117
+ const mMethodRaw = event.data.method || 'MCTS';
1118
+ const mMethodLabels = {
1119
+ 'direct': 'direct',
1120
+ 'mcts_heuristic': 'mcts_heuristic',
1121
+ 'mcts': 'mcts',
1122
+ };
1123
+ const mMethod = mMethodLabels[mMethodRaw] || mMethodRaw;
1124
+ log(chalk.cyan(` 🌲 ${mMethod} plan: ${mSteps.length} steps`));
1125
+ for (const [i, step] of mSteps.entries()) {
1126
+ const desc = typeof step === 'string' ? step : (step.task || step.description || step.type || '?');
1127
+ const tool = (typeof step === 'object' && step.tool) ? chalk.yellow(` [${step.tool}]`) : '';
1128
+ log(chalk.dim(` ${i + 1}. ${desc}`) + tool);
1129
+ }
1130
+ break;
1131
+ }
1132
+ // ── Context Facets — multi-phase agentic execution ──
1133
+ case 'facet_start': {
1134
+ stopSpinner();
1135
+ const chType = (event.data.facet_type || 'unknown').toUpperCase();
1136
+ const chIdx = event.data.facet_index ?? '?';
1137
+ const chTurns = event.data.max_turns || '?';
1138
+ const chTools = Array.isArray(event.data.tools) ? event.data.tools.length + ' tools' : 'all tools';
1139
+ log(chalk.cyan(`\n ── Facet ${chIdx}: ${chType} (${chTurns} turns, ${chTools}) ──`));
1140
+ startSpinner(chalk.dim(`${chType} phase...`));
1141
+ break;
1142
+ }
1143
+ case 'facet_end': {
1144
+ stopSpinner();
1145
+ const endType = (event.data.facet_type || '').toUpperCase();
1146
+ const endTurns = event.data.turns_completed || 0;
1147
+ const endTokens = event.data.tokens_used || 0;
1148
+ const endRemaining = event.data.tokens_remaining || 0;
1149
+ const crystalPreview = event.data.crystal_summary || '';
1150
+ log(chalk.green(` ✓ ${endType} complete: ${endTurns} turns, ${endTokens} tokens${endRemaining > 0 ? ` (+${endRemaining} unspent)` : ''}`));
1151
+ if (crystalPreview) {
1152
+ log(chalk.dim(' Crystal:'));
1153
+ printIndentedBlock(crystalPreview, ' ');
1154
+ }
1155
+ break;
1156
+ }
1157
+ case 'facet_crystallize': {
1158
+ const crIdx = event.data.facet_index ?? '?';
1159
+ const findings = event.data.key_findings || [];
1160
+ if (findings.length > 0) {
1161
+ log(chalk.yellow(` 💎 Crystallized ${findings.length} findings from facet ${crIdx}:`));
1162
+ for (const f of findings) {
1163
+ printIndentedBlock(`• ${f}`);
1164
+ }
1165
+ }
1166
+ break;
1167
+ }
1168
+ case 'budget_cascade': {
1169
+ const fromCh = event.data.from_facet || event.data.from_chapter || '?';
1170
+ const toCh = event.data.to_facet || event.data.to_chapter || 'pool (reusable for later facets)';
1171
+ const transferred = event.data.tokens_transferred || 0;
1172
+ if (transferred > 0) {
1173
+ log(chalk.dim(` ↪ Budget cascade: ${transferred} turns from ${fromCh} → ${toCh}`));
1174
+ }
1175
+ break;
1176
+ }
1177
+ case 'artifact_delivered': {
1178
+ stopSpinner();
1179
+ const artFile = event.data.filename || event.data.path || 'file';
1180
+ const artSize = event.data.size || 0;
1181
+ const artCmd = event.data.retrieve_cmd || '';
1182
+ const artLang = event.data.language || '';
1183
+ const artPath = event.data.path || '';
1184
+ // Track artifact in session store
1185
+ _sessionArtifacts.push({
1186
+ filename: artFile,
1187
+ path: artPath,
1188
+ size: artSize,
1189
+ language: artLang,
1190
+ retrieve_cmd: artCmd,
1191
+ timestamp: new Date().toISOString(),
1192
+ });
1193
+ const artIdx = _sessionArtifacts.length;
1194
+ log(chalk.green.bold(`\n 📦 Artifact #${artIdx}: ${artFile} (${artSize} bytes)`));
1195
+ if (artCmd) {
1196
+ log(chalk.dim(` Retrieve: ${artCmd}`));
1197
+ log(chalk.cyan(` Run /artifacts to list all, /get ${artIdx} to download`));
1198
+ }
1199
+ break;
1200
+ }
1201
+ // Pipeline stages that update spinner
1202
+ case 'pipeline':
1203
+ case 'session_learn':
1204
+ case 'search_fire':
1205
+ case 'judge':
1206
+ case 'delegation_decide':
1207
+ case 'fast_path':
1208
+ case 'security_gate':
1209
+ case 'memory_recall':
1210
+ case 'guardrail_check': {
1211
+ if (spinner) {
1212
+ const msg = event.data.message || event.data.stage || event.data.phase
1213
+ || event.data.status || event.type;
1214
+ spinner.text = chalk.dim(msg);
1215
+ }
1216
+ break;
1217
+ }
1218
+ // ── Missing event handlers — full observability ──
1219
+ case 'turn_end': {
1220
+ const teTurn = event.data.turn || '?';
1221
+ const teDuration = event.data.duration_ms ? `${Math.round(event.data.duration_ms)}ms` : '';
1222
+ const teOutcome = event.data.outcome || '';
1223
+ log(chalk.dim(` ── Turn ${teTurn} end${teOutcome ? `: ${teOutcome}` : ''}${teDuration ? ` (${teDuration})` : ''}`));
1224
+ break;
1225
+ }
1226
+ case 'tool_denied': {
1227
+ const denied = event.data.denied || [];
1228
+ if (denied.length > 0) {
1229
+ log(chalk.red(` ⛔ Tools denied: ${denied.join(', ')}`));
1230
+ }
1231
+ break;
1232
+ }
1233
+ case 'tool_selected': {
1234
+ const selToolsArr = event.data.tools || [];
1235
+ const selTool = selToolsArr.length > 0
1236
+ ? `[${selToolsArr.slice(0, 4).join(', ')}${selToolsArr.length > 4 ? `, +${selToolsArr.length - 4}` : ''}]`
1237
+ : (event.data.tool || event.data.name || '?');
1238
+ const selTotal = event.data.total_available || 0;
1239
+ const selReason = selTotal > 0 ? `${selToolsArr.length}/${selTotal} tools` : (event.data.reason || '');
1240
+ log(chalk.dim(` 🔧 Tool selected: ${selTool}${selReason ? ` — ${selReason}` : ''}`));
1241
+ break;
1242
+ }
1243
+ case 'llm_error': {
1244
+ stopSpinner();
1245
+ const llmErr = event.data.error || event.data.message || 'unknown';
1246
+ const llmErrModel = event.data.model || '?';
1247
+ log(chalk.red(` ✗ LLM error (${llmErrModel}): ${String(llmErr)}`));
1248
+ traceErrors.push(`LLM error (${llmErrModel}): ${String(llmErr)}`);
1249
+ break;
1250
+ }
1251
+ case 'reasoning_depth': {
1252
+ const rdDepth = event.data.depth || event.data.level || '?';
1253
+ const rdReason = event.data.reason || '';
1254
+ log(chalk.dim(` 🧠 Reasoning depth: ${rdDepth}${rdReason ? ` — ${rdReason}` : ''}`));
1255
+ break;
1256
+ }
1257
+ case 'agentic_promotion': {
1258
+ stopSpinner();
1259
+ const apReason = event.data.reason || 'auto';
1260
+ const apEffort = event.data.effort || '?';
1261
+ log(chalk.cyan(` 🔀 Agentic promotion: effort=${apEffort} — ${apReason}`));
1262
+ break;
1263
+ }
1264
+ case 'steering': {
1265
+ const stHook = event.data.hook || event.data.type || '?';
1266
+ const stReason = event.data.reason || '';
1267
+ log(chalk.yellow(` 🔄 Steering: ${stHook}${stReason ? ` — ${stReason}` : ''}`));
1268
+ break;
1269
+ }
1270
+ case 'strategy_subsystem': {
1271
+ const ssName = event.data.subsystem || event.data.name || '?';
1272
+ const ssStatus = event.data.status || event.data.decision || '';
1273
+ log(chalk.dim(` ⚙ Strategy: ${ssName} → ${ssStatus}`));
1274
+ break;
1275
+ }
1276
+ case 'plan_approved': {
1277
+ log(chalk.green(` ✓ Plan approved${event.data.plan_id ? ` (${event.data.plan_id})` : ''}`));
1278
+ break;
1279
+ }
1280
+ case 'verification': {
1281
+ const vResults = event.data.results || [];
1282
+ if (Array.isArray(vResults) && vResults.length > 0) {
1283
+ for (const v of vResults) {
1284
+ const icon = v.success ? chalk.green('✓') : chalk.red('✗');
1285
+ const path = v.path || '';
1286
+ const kind = v.kind || '';
1287
+ const reason = v.reason || '';
1288
+ const kindLabel = kind ? ` (${kind.replace(/_/g, ' ')})` : '';
1289
+ log(chalk.dim(` 🔍 ${icon} ${path}${kindLabel}${reason ? ` — ${reason}` : ''}`));
1290
+ }
1291
+ }
1292
+ else {
1293
+ const vResult = event.data.passed ? chalk.green('✓ passed') : chalk.red('✗ failed');
1294
+ const vDetail = event.data.detail || event.data.reason || '';
1295
+ log(chalk.dim(` 🔍 Verification: ${vResult}${vDetail ? ` — ${vDetail}` : ''}`));
1296
+ }
1297
+ break;
1298
+ }
1299
+ case 'image_gen_start': {
1300
+ stopSpinner();
1301
+ const igPrompt = event.data.prompt || event.data.description || '';
1302
+ log(chalk.cyan(` 🎨 Generating image: ${igPrompt}`));
1303
+ startSpinner(chalk.dim('Generating image...'));
1304
+ break;
1305
+ }
1306
+ case 'image_gen_complete': {
1307
+ stopSpinner();
1308
+ const igUrl = event.data.url || '';
1309
+ log(chalk.green(` ✓ Image generated${igUrl ? `: ${igUrl}` : ''}`));
1310
+ break;
1311
+ }
1312
+ case 'image_gen_failed': {
1313
+ stopSpinner();
1314
+ const igErr = event.data.error || 'unknown';
1315
+ log(chalk.red(` ✗ Image generation failed: ${igErr}`));
1316
+ break;
1317
+ }
1318
+ case 'session_context': {
1319
+ const scTopic = event.data.topic || event.data.summary || '';
1320
+ if (scTopic) {
1321
+ log(chalk.dim(` 📝 Session context: ${scTopic}`));
1322
+ }
1323
+ break;
1324
+ }
1325
+ case 'reasoning_backend_health': {
1326
+ const backends = event.data.backends || {};
1327
+ const entries = Object.entries(backends);
1328
+ // Friendly display names for backend IDs
1329
+ const friendlyNames = {
1330
+ 'vllm': 'orchestrator vLLM',
1331
+ 'vllm_swap': 'local swap (on-demand)',
1332
+ 'vllm_orchestrator': 'orchestrator (fallback)',
1333
+ 'vllm_reasoning': 'local reasoning (on-demand)',
1334
+ 'vllm_coding': 'coding vLLM',
1335
+ 'vllm_qwen': 'Qwen vLLM',
1336
+ 'vllm_cloud_reasoning': 'cloud reasoning',
1337
+ 'vllm_gemma4_reasoning': 'Gemma4 (cloud)',
1338
+ 'vllm_gemma4_flagship': 'Gemma4 flagship (cloud)',
1339
+ 'vllm_dgx': 'DGX Spark',
1340
+ 'vllm_dgx_swap': 'DGX Spark (swap)',
1341
+ 'vllm_dgx_embed': 'DGX Spark (embeddings)',
1342
+ 'vllm_dgx_orch': 'DGX Spark (orchestrator)',
1343
+ 'deepseek_api': 'DeepSeek API',
1344
+ 'comfyui': 'ComfyUI',
1345
+ };
1346
+ // On-demand backends are expected to be offline — hide when down, show when up
1347
+ const onDemandBackends = new Set([
1348
+ 'vllm_swap', 'vllm_reasoning', 'vllm_dgx_swap',
1349
+ 'vllm_gemma4_reasoning', 'vllm_gemma4_flagship', 'vllm_cloud_reasoning',
1350
+ ]);
1351
+ if (entries.length > 0) {
1352
+ const online = entries.filter(([, alive]) => alive);
1353
+ // Only show offline backends that aren't on-demand (expected off)
1354
+ const offline = entries.filter(([name, alive]) => !alive && !onDemandBackends.has(name));
1355
+ for (const [name, alive] of [...online, ...offline]) {
1356
+ const label = friendlyNames[name] || name.replace(/^vllm_/, '').replace(/_/g, ' ');
1357
+ const status = alive ? chalk.green('online') : chalk.red('offline');
1358
+ log(chalk.dim(` 🔧 ${label}: ${status}`));
1359
+ }
1360
+ }
1361
+ break;
1362
+ }
1363
+ case 'autonomous_start':
1364
+ case 'autonomous_task': {
1365
+ stopSpinner();
1366
+ const atTask = event.data.task || event.data.description || '';
1367
+ log(chalk.cyan(` 🤖 Autonomous task: ${atTask}`));
1368
+ break;
1369
+ }
1370
+ case 'expedition_created': {
1371
+ const expId = event.data.expedition_id || event.data.id || '?';
1372
+ log(chalk.cyan(` 🗺 Expedition created: ${expId}`));
1373
+ break;
1374
+ }
1375
+ case 'checkpoint_resumed': {
1376
+ const crTurn = event.data.turn || '?';
1377
+ log(chalk.dim(` ↩ Checkpoint resumed at turn ${crTurn}`));
1378
+ break;
1379
+ }
1380
+ case 'council_review': {
1381
+ const crVerdict = event.data.verdict || event.data.decision || '';
1382
+ const crReason = event.data.reason || '';
1383
+ log(chalk.magenta(` 👥 Council review: ${crVerdict}${crReason ? ` — ${crReason}` : ''}`));
1384
+ break;
1385
+ }
1386
+ case 'cuga_available': {
1387
+ break; // Internal signal, no display needed
1388
+ }
1389
+ case 'context_stage': {
1390
+ const csStage = event.data.substage || event.data.stage || 'context';
1391
+ const csStatus = event.data.status || event.data.detail || '';
1392
+ const csLabel = csStatus ? `${csStage}: ${csStatus}` : csStage;
1393
+ lastStage = csLabel;
1394
+ stopSpinner();
1395
+ log(chalk.dim(` [context_stage] ${csLabel}`));
1396
+ startSpinner(chalk.dim(csLabel));
1397
+ break;
1398
+ }
1399
+ case 'pipeline': {
1400
+ const plStage = event.data.stage || event.data.phase || '';
1401
+ const plStrategy = event.data.strategy || '';
1402
+ const plEffort = event.data.effort;
1403
+ // Show effort/strategy info inline
1404
+ if (plEffort && typeof plEffort === 'object') {
1405
+ const efLvl = plEffort.level || '?';
1406
+ const efLabel = plEffort.label || plEffort.tier || '';
1407
+ stopSpinner();
1408
+ log(chalk.dim(` [pipeline] effort=${efLvl}${efLabel ? ` (${efLabel})` : ''}${plStrategy ? ` strategy=${plStrategy}` : ''}`));
1409
+ }
1410
+ else if (plStage) {
1411
+ lastStage = plStage;
1412
+ if (spinner) {
1413
+ spinner.text = chalk.dim(plStage);
1414
+ }
1415
+ }
1416
+ break;
1417
+ }
1418
+ case 'plan_complete':
1419
+ case 'plan_status': {
1420
+ const pcStatus = event.data.status || event.data.outcome || 'done';
1421
+ lastStage = `plan: ${pcStatus}`;
1422
+ log(chalk.dim(` [plan_complete] ${pcStatus}`));
1423
+ break;
1424
+ }
1425
+ case 'turn_progress': {
1426
+ const tpTurn = event.data.turn || '?';
1427
+ const tpMax = event.data.max_turns || '?';
1428
+ if (event.data.max_turns)
1429
+ lastKnownMaxTurns = event.data.max_turns;
1430
+ lastStage = `turn ${tpTurn}/${tpMax}`;
1431
+ if (spinner) {
1432
+ spinner.text = chalk.dim(`Turn ${tpTurn}/${tpMax}...`);
1433
+ }
1434
+ break;
1435
+ }
1436
+ default: {
1437
+ // Show content from unexpected events as fallback
1438
+ if (event.data.content && !hasOutput) {
1439
+ stopSpinner();
1440
+ process.stdout.write(event.data.content);
1441
+ content += event.data.content;
1442
+ hasOutput = true;
1443
+ }
1444
+ // Show meaningful unknown events so nothing is silently swallowed
1445
+ const msg = event.data.message || event.data.reason || event.data.status || '';
1446
+ if (msg && event.type !== 'debug') {
1447
+ log(chalk.dim(` [${event.type}] ${String(msg).slice(0, 120)}`));
1448
+ }
1449
+ break;
1450
+ }
1451
+ }
1452
+ },
1453
+ getContent() {
1454
+ return content;
1455
+ },
1456
+ finish() {
1457
+ stopSpinner();
1458
+ if (!hasOutput) {
1459
+ console.log(chalk.dim(' (no response)'));
1460
+ }
1461
+ // ── Status bar — compact summary after each response ──
1462
+ const elapsed = Date.now() - startTime;
1463
+ const parts = [];
1464
+ if (traceAgent)
1465
+ parts.push(`agent: ${traceAgent}`);
1466
+ if (traceModel)
1467
+ parts.push(`model: ${traceModel}`);
1468
+ if (traceTurns > 0)
1469
+ parts.push(`turns: ${traceTurns}/${lastKnownMaxTurns}`);
1470
+ if (traceToolCalls.length > 0)
1471
+ parts.push(`tools: ${traceToolCalls.length}`);
1472
+ parts.push(`${(elapsed / 1000).toFixed(1)}s`);
1473
+ console.log(chalk.dim(` [${parts.join(' | ')}]`));
1474
+ // ── Persist session trace to disk ──
1475
+ if (sessionId) {
1476
+ try {
1477
+ const sessDir = join(homedir(), '.aither', 'sessions', sessionId);
1478
+ if (!existsSync(sessDir))
1479
+ mkdirSync(sessDir, { recursive: true });
1480
+ const profile = this.getSessionProfile();
1481
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
1482
+ const filePath = join(sessDir, `${ts}.json`);
1483
+ writeFileSync(filePath, JSON.stringify(profile, null, 2));
1484
+ }
1485
+ catch (_e) {
1486
+ // best-effort — don't crash on trace write failure
1487
+ }
1488
+ }
1489
+ },
1490
+ getTrace() {
1491
+ return traceEvents;
1492
+ },
1493
+ getSessionProfile() {
1494
+ return {
1495
+ session_id: sessionId || 'anonymous',
1496
+ prompt: prompt || '',
1497
+ started_at: startedAt,
1498
+ duration_ms: Date.now() - startTime,
1499
+ event_count: traceEvents.length,
1500
+ tool_calls: traceToolCalls,
1501
+ thinking_traces: traceThinking,
1502
+ context_sources: traceContextSources,
1503
+ model: traceModel,
1504
+ agent: traceAgent,
1505
+ errors: traceErrors,
1506
+ events: traceEvents,
1507
+ };
1508
+ },
1509
+ };
1510
+ }
1511
+ /* ── Terminal Block Renderer ────────────────────────────────── */
1512
+ function renderTerminalBlocks(blocks) {
1513
+ for (const block of blocks) {
1514
+ switch (block.type) {
1515
+ case 'header':
1516
+ console.log(chalk.bold.cyan(`\n ${block.text}`) + (block.subtitle ? chalk.dim(` — ${block.subtitle}`) : ''));
1517
+ break;
1518
+ case 'kv': {
1519
+ const pairs = block.pairs || {};
1520
+ for (const [k, v] of Object.entries(pairs)) {
1521
+ console.log(chalk.dim(` ${k}: `) + chalk.white(String(v)));
1522
+ }
1523
+ break;
1524
+ }
1525
+ case 'table': {
1526
+ const cols = (block.columns || []).map((c) => typeof c === 'string' ? c : c.label);
1527
+ const rows = (block.rows || []).map((r) => r.map(String));
1528
+ if (cols.length > 0) {
1529
+ console.log(' ' + formatTable(cols, rows).split('\n').join('\n '));
1530
+ }
1531
+ break;
1532
+ }
1533
+ case 'callout': {
1534
+ const icons = { info: 'ℹ', success: '✓', warning: '⚠', error: '✗', tip: '💡' };
1535
+ const colors = {
1536
+ info: chalk.blue, success: chalk.green, warning: chalk.yellow,
1537
+ error: chalk.red, tip: chalk.magenta,
1538
+ };
1539
+ const color = colors[block.variant || 'info'] || chalk.blue;
1540
+ const icon = icons[block.variant || 'info'] || 'ℹ';
1541
+ console.log(color(` ${icon} ${block.title || ''} ${block.text}`));
1542
+ break;
1543
+ }
1544
+ case 'progress': {
1545
+ if (block.steps) {
1546
+ const icons = { done: '✓', active: '⟳', pending: '○', error: '✗' };
1547
+ for (const step of block.steps) {
1548
+ const icon = icons[step.status] || '○';
1549
+ const color = step.status === 'done' ? chalk.green : step.status === 'active' ? chalk.blue : step.status === 'error' ? chalk.red : chalk.dim;
1550
+ console.log(color(` ${icon} ${step.name}`) + (step.detail ? chalk.dim(` (${step.detail})`) : ''));
1551
+ }
1552
+ }
1553
+ else if (block.value != null) {
1554
+ const pct = Math.round(block.value * 100);
1555
+ const filled = Math.round(pct / 5);
1556
+ const bar = '█'.repeat(filled) + '░'.repeat(20 - filled);
1557
+ console.log(chalk.blue(` [${bar}] ${pct}%`) + (block.label ? chalk.dim(` ${block.label}`) : ''));
1558
+ }
1559
+ break;
1560
+ }
1561
+ case 'scores': {
1562
+ for (const [name, val] of Object.entries(block.scores || {})) {
1563
+ const v = val;
1564
+ const pct = Math.round(v * 100);
1565
+ const color = pct >= 80 ? chalk.green : pct >= 50 ? chalk.yellow : chalk.red;
1566
+ console.log(` ${chalk.dim(name.padEnd(20))} ${color(`${pct}%`)}`);
1567
+ }
1568
+ break;
1569
+ }
1570
+ case 'code': {
1571
+ const label = block.filename || block.language || 'code';
1572
+ console.log(chalk.dim(`\n ─── ${label} ───`));
1573
+ const codeLines = (block.content || '').split('\n');
1574
+ for (const codeLine of codeLines) {
1575
+ console.log(chalk.white(` ${codeLine}`));
1576
+ }
1577
+ console.log(chalk.dim(` ─── end ───`));
1578
+ if (block.filename && block.filename !== 'code' && !block.filename.startsWith('code.')) {
1579
+ console.log(chalk.dim(` Saved to: ${block.filename}`));
1580
+ }
1581
+ break;
1582
+ }
1583
+ case 'list': {
1584
+ const items = block.items || [];
1585
+ for (const item of items) {
1586
+ const text = typeof item === 'string' ? item : item.text;
1587
+ console.log(chalk.dim(' • ') + text);
1588
+ }
1589
+ break;
1590
+ }
1591
+ case 'approve':
1592
+ console.log(chalk.yellow.bold(`\n ⚠ ${block.title}`));
1593
+ if (block.detail)
1594
+ console.log(chalk.dim(` ${block.detail}`));
1595
+ console.log(chalk.dim(` [${block.approve_label || 'Approve'}] / [${block.reject_label || 'Reject'}]`));
1596
+ console.log(chalk.dim(` (interactive approval — use web UI to respond)`));
1597
+ break;
1598
+ case 'form':
1599
+ if (block.title)
1600
+ console.log(chalk.cyan.bold(`\n 📋 ${block.title}`));
1601
+ if (block.description)
1602
+ console.log(chalk.dim(` ${block.description}`));
1603
+ for (const f of block.fields || []) {
1604
+ const def = f.default != null ? chalk.dim(` [${f.default}]`) : '';
1605
+ console.log(chalk.dim(` ${f.label}:`) + def);
1606
+ }
1607
+ console.log(chalk.dim(` (interactive form — use web UI to submit)`));
1608
+ break;
1609
+ case 'select':
1610
+ if (block.label)
1611
+ console.log(chalk.cyan(` ${block.label}`));
1612
+ for (const opt of block.options || []) {
1613
+ const label = typeof opt === 'string' ? opt : opt.label;
1614
+ const desc = typeof opt === 'object' && opt.description ? chalk.dim(` — ${opt.description}`) : '';
1615
+ console.log(chalk.dim(' ○ ') + label + desc);
1616
+ }
1617
+ console.log(chalk.dim(` (interactive selection — use web UI to choose)`));
1618
+ break;
1619
+ case 'markdown':
1620
+ if (block.text)
1621
+ console.log(' ' + renderMarkdown(block.text).trim().split('\n').join('\n '));
1622
+ break;
1623
+ default:
1624
+ // Unknown block — show type + any text content
1625
+ if (block.text || block.content) {
1626
+ console.log(chalk.dim(` [${block.type}] `) + (block.text || block.content));
1627
+ }
1628
+ break;
1629
+ }
1630
+ }
1631
+ }
1632
+ /* ── Table Formatter ────────────────────────────────────────── */
1633
+ export function formatTable(headers, rows) {
1634
+ // Strip ANSI for width calculation
1635
+ const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
1636
+ const widths = headers.map((h, i) => Math.max(strip(h).length, ...rows.map(r => strip(r[i] || '').length)));
1637
+ const pad = (s, w) => s + ''.padEnd(Math.max(0, w - strip(s).length));
1638
+ const header = headers.map((h, i) => pad(h, widths[i])).join(' ');
1639
+ const sep = widths.map(w => '\u2500'.repeat(w)).join('\u2500\u2500');
1640
+ const body = rows.map(row => row.map((cell, i) => pad(cell || '', widths[i])).join(' ')).join('\n');
1641
+ return `${chalk.bold(header)}\n${chalk.dim(sep)}\n${body}`;
1642
+ }
1643
+ /* ── Steering Bar — dedicated input line during streaming ─────── */
1644
+ /**
1645
+ * Keeps a visible input line at the bottom of the terminal while
1646
+ * stream output prints above. Works on Windows Terminal (no ANSI
1647
+ * scroll regions).
1648
+ *
1649
+ * How it works:
1650
+ * 1. Intercepts process.stdout.write
1651
+ * 2. Before each write: erase the bar line so output doesn't collide
1652
+ * 3. After each write: redraw the bar on a fresh line below output
1653
+ * 4. User keystrokes update the bar's displayed text in real time
1654
+ * 5. On Enter, the bar clears and the repl routes to /chat/steer
1655
+ *
1656
+ * The bar line looks like:
1657
+ * ── [steer] > what the user is typing_
1658
+ */
1659
+ export class SteeringBar {
1660
+ _active = false;
1661
+ _inputText = '';
1662
+ _statusText = '';
1663
+ _origWrite = null;
1664
+ _barDrawn = false;
1665
+ _debounceTimer = null;
1666
+ _spinFrame = 0;
1667
+ _spinTimer = null;
1668
+ static _SPIN_CHARS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1669
+ get active() { return this._active; }
1670
+ get inputText() { return this._inputText; }
1671
+ activate() {
1672
+ if (this._active)
1673
+ return;
1674
+ this._active = true;
1675
+ this._inputText = '';
1676
+ this._statusText = '';
1677
+ this._barDrawn = false;
1678
+ if (!process.stdout.isTTY)
1679
+ return;
1680
+ const self = this;
1681
+ this._origWrite = process.stdout.write.bind(process.stdout);
1682
+ const origWrite = this._origWrite;
1683
+ process.stdout.write = function (chunk, encodingOrCb, cb) {
1684
+ if (!self._active) {
1685
+ return origWrite(chunk, encodingOrCb, cb);
1686
+ }
1687
+ // Erase bar before writing output
1688
+ if (self._barDrawn) {
1689
+ origWrite('\x1b8\x1b[J');
1690
+ self._barDrawn = false;
1691
+ }
1692
+ // Write the actual output
1693
+ const result = origWrite(chunk, encodingOrCb, cb);
1694
+ // Schedule bar redraw (debounced — token bursts produce many writes)
1695
+ if (self._debounceTimer)
1696
+ clearTimeout(self._debounceTimer);
1697
+ self._debounceTimer = setTimeout(() => {
1698
+ self._debounceTimer = null;
1699
+ if (self._active)
1700
+ self._drawBar(origWrite);
1701
+ }, 30);
1702
+ return result;
1703
+ };
1704
+ // Draw initial bar
1705
+ this._drawBar(origWrite);
1706
+ }
1707
+ deactivate() {
1708
+ if (!this._active)
1709
+ return;
1710
+ this._active = false;
1711
+ this._statusText = '';
1712
+ if (this._debounceTimer) {
1713
+ clearTimeout(this._debounceTimer);
1714
+ this._debounceTimer = null;
1715
+ }
1716
+ if (this._spinTimer) {
1717
+ clearInterval(this._spinTimer);
1718
+ this._spinTimer = null;
1719
+ }
1720
+ // Erase bar if drawn
1721
+ if (this._barDrawn && this._origWrite) {
1722
+ this._origWrite('\x1b8\x1b[J');
1723
+ this._barDrawn = false;
1724
+ }
1725
+ // Restore stdout.write
1726
+ if (this._origWrite) {
1727
+ process.stdout.write = this._origWrite;
1728
+ this._origWrite = null;
1729
+ }
1730
+ }
1731
+ /** Schedule a single debounced redraw. All state changes route here. */
1732
+ _scheduleRedraw() {
1733
+ if (!this._active || !this._origWrite)
1734
+ return;
1735
+ if (this._debounceTimer)
1736
+ clearTimeout(this._debounceTimer);
1737
+ const w = this._origWrite;
1738
+ this._debounceTimer = setTimeout(() => {
1739
+ this._debounceTimer = null;
1740
+ if (!this._active)
1741
+ return;
1742
+ if (this._barDrawn) {
1743
+ w('\x1b8\x1b[J');
1744
+ this._barDrawn = false;
1745
+ }
1746
+ this._drawBar(w);
1747
+ }, 16);
1748
+ }
1749
+ /** Show spinner + status text in the bar (replaces ora when bar is active). */
1750
+ setStatus(text) {
1751
+ this._statusText = text;
1752
+ if (!this._spinTimer && this._active && this._origWrite) {
1753
+ const origWrite = this._origWrite;
1754
+ this._spinTimer = setInterval(() => {
1755
+ this._spinFrame = (this._spinFrame + 1) % SteeringBar._SPIN_CHARS.length;
1756
+ if (this._active)
1757
+ this._scheduleRedraw();
1758
+ }, 80);
1759
+ }
1760
+ // Don't redraw immediately — the interceptor or spin timer will do it
1761
+ }
1762
+ /** Clear status text. */
1763
+ clearStatus() {
1764
+ this._statusText = '';
1765
+ if (this._spinTimer) {
1766
+ clearInterval(this._spinTimer);
1767
+ this._spinTimer = null;
1768
+ }
1769
+ // Don't redraw — next stdout write or setStatus will handle it
1770
+ }
1771
+ /** Update displayed input text (called from readline _ttyWrite hook). */
1772
+ setInput(text) {
1773
+ this._inputText = text;
1774
+ if (this._active && this._origWrite) {
1775
+ // Keystroke — redraw immediately (no debounce for input responsiveness)
1776
+ if (this._debounceTimer)
1777
+ clearTimeout(this._debounceTimer);
1778
+ this._debounceTimer = null;
1779
+ if (this._barDrawn) {
1780
+ this._origWrite('\x1b8\x1b[J');
1781
+ this._barDrawn = false;
1782
+ }
1783
+ this._drawBar(this._origWrite);
1784
+ }
1785
+ }
1786
+ /** Clear input after steer send. */
1787
+ clearInput() {
1788
+ this._inputText = '';
1789
+ this._scheduleRedraw();
1790
+ }
1791
+ _drawBar(write) {
1792
+ const cols = process.stdout.columns || 80;
1793
+ const barWidth = Math.min(cols, 60);
1794
+ const sep = chalk.dim('─'.repeat(barWidth));
1795
+ // Status line (spinner + status text)
1796
+ let statusLine = '';
1797
+ if (this._statusText) {
1798
+ const frame = SteeringBar._SPIN_CHARS[this._spinFrame];
1799
+ statusLine = `\n${chalk.cyan(frame)} ${chalk.dim(this._statusText)}\x1b[K`;
1800
+ }
1801
+ // Input prompt
1802
+ const prefix = chalk.dim(' [steer]') + chalk.yellow(' > ');
1803
+ const prefixLen = 11;
1804
+ const maxInput = cols - prefixLen - 1;
1805
+ const display = this._inputText.length > maxInput
1806
+ ? this._inputText.slice(-maxInput)
1807
+ : this._inputText;
1808
+ write('\x1b7'); // Save cursor position (end of content)
1809
+ write(`${statusLine}\n${sep}\n${prefix}${display}\x1b[K`);
1810
+ this._barDrawn = true;
1811
+ }
1812
+ }