@ryuu-reinzz/ryuu-cli 0.0.0-stage → 0.4.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1269 -2
  4. package/package.json +45 -4
  5. package/src/agent/agent.js +1194 -0
  6. package/src/agent/approvals.js +76 -0
  7. package/src/agent/context.js +106 -0
  8. package/src/agent/planner.js +237 -0
  9. package/src/agent/worklog.js +318 -0
  10. package/src/cli/app.js +471 -0
  11. package/src/cli/commands.js +1155 -0
  12. package/src/cli/input.js +287 -0
  13. package/src/cli/markdown.js +527 -0
  14. package/src/cli/prompts.js +255 -0
  15. package/src/cli/renderer.js +2155 -0
  16. package/src/cli/settings-editor.js +280 -0
  17. package/src/cli/settings-tui.js +669 -0
  18. package/src/cli/tui.js +1737 -0
  19. package/src/cli/width.js +48 -0
  20. package/src/config/load.js +137 -0
  21. package/src/config/scaffold.js +50 -0
  22. package/src/config/settings-registry.js +495 -0
  23. package/src/config/settings.js +304 -0
  24. package/src/config/validate.js +461 -0
  25. package/src/index.js +449 -0
  26. package/src/providers/catalog.js +407 -0
  27. package/src/providers/ids.js +24 -0
  28. package/src/providers/index.js +103 -0
  29. package/src/providers/nvidia.js +671 -0
  30. package/src/providers/registry.js +104 -0
  31. package/src/providers/river.js +198 -0
  32. package/src/providers/tokenharbor.js +38 -0
  33. package/src/providers/vendor-extras.js +82 -0
  34. package/src/services/clipboard.js +38 -0
  35. package/src/services/diff.js +153 -0
  36. package/src/services/doctor.js +126 -0
  37. package/src/services/logger.js +190 -0
  38. package/src/services/preferences.js +249 -0
  39. package/src/services/processes.js +345 -0
  40. package/src/services/provider-settings.js +226 -0
  41. package/src/services/sessions.js +279 -0
  42. package/src/services/undo.js +133 -0
  43. package/src/services/ushell.js +146 -0
  44. package/src/services/websearch/manager.js +616 -0
  45. package/src/services/websearch/mcp.js +233 -0
  46. package/src/services/websearch/normalize.js +284 -0
  47. package/src/services/websearch/providers.js +178 -0
  48. package/src/services/websearch/serper.js +263 -0
  49. package/src/services/workspace.js +589 -0
  50. package/src/tools/filesystem.js +304 -0
  51. package/src/tools/git.js +114 -0
  52. package/src/tools/index.js +48 -0
  53. package/src/tools/project.js +160 -0
  54. package/src/tools/registry.js +118 -0
  55. package/src/tools/search.js +210 -0
  56. package/src/tools/shell.js +279 -0
  57. package/src/tools/url.js +542 -0
  58. package/src/tools/web.js +67 -0
  59. package/src/utils/errors.js +160 -0
  60. package/src/utils/network.js +129 -0
  61. package/src/utils/redaction.js +73 -0
  62. package/src/utils/validation.js +370 -0
  63. package/src/utils/version.js +24 -0
@@ -0,0 +1,2155 @@
1
+ import { Chalk } from 'chalk';
2
+ import { renderInline, renderMarkdown } from './markdown.js';
3
+ import { ANSI_SEQUENCE, charWidth, displayWidth } from './width.js';
4
+ import { copyToClipboard } from '../services/clipboard.js';
5
+
6
+ const MAX_REASONING_CHARS = 2_000_000;
7
+ const ANSI_AT = /\x1b\[[0-?]*[ -/]*[@-~]/y;
8
+ /** Rows shown in the live reasoning/response boxes. */
9
+ const PREVIEW_LINES = 5;
10
+ /** Rows of captured command output shown in the closed block. */
11
+ const COMMAND_PREVIEW_LINES = 5;
12
+ /** Output kept for the expandable command view; one huge log cannot pin memory. */
13
+ const MAX_COMMAND_OUTPUT_CHARS = 200_000;
14
+ /** Code kept for a clipboard target; OSC 52 payloads are capped at the same size. */
15
+ const MAX_CODE_COPY_CHARS = 100_000;
16
+
17
+ const PALETTE = {
18
+ cyan: '#22d3ee',
19
+ sky: '#38bdf8',
20
+ blue: '#60a5fa',
21
+ violet: '#a78bfa',
22
+ pink: '#f472b6',
23
+ green: '#4ade80',
24
+ amber: '#fbbf24',
25
+ yellow: '#fbbf24',
26
+ red: '#f87171',
27
+ slate: '#94a3b8',
28
+ white: '#e2e8f0',
29
+ };
30
+
31
+ const STATUS_STYLE = {
32
+ completed: { label: 'completed', color: 'green' },
33
+ cancelled: { label: 'cancelled', color: 'yellow' },
34
+ failed: { label: 'failed', color: 'red' },
35
+ 'step-limit': { label: 'step limit reached', color: 'yellow' },
36
+ };
37
+
38
+ const ERROR_HINTS = {
39
+ AUTH_INVALID: 'Check the active provider key (providers.<id>.apiKey in config.json, edited with `ryuu --settings`), then run `ryuu --doctor --live`.',
40
+ AUTH_FORBIDDEN: 'Verify this key has access to the selected model in the provider dashboard; /provider shows the active provider.',
41
+ MODEL_NOT_FOUND: 'Check the model id with /model and the base URL with /config (it must end with /v1).',
42
+ RATE_LIMIT: 'The provider may enforce quotas. Wait, reduce context, or switch model/provider with /model and /provider.',
43
+ TOOLS_UNSUPPORTED: 'This model may not support OpenAI-style tool calling. Pick a tool-capable model with /model.',
44
+ CONTEXT_LENGTH: 'Start a new session or use a smaller task; agent.maxContextChars controls local compaction.',
45
+ PROVIDER_NETWORK: 'Check network/DNS access to the active provider and any proxy settings; /provider shows the endpoint.',
46
+ PROVIDER_TIMEOUT: 'Increase agent.requestTimeoutMs or reduce the task size.',
47
+ TASK_TIMEOUT: 'Increase agent.maxTaskDurationMs or split the task.',
48
+ CONFIG_INVALID: 'Fix config.json with `ryuu --settings`, then restart.',
49
+ STREAM_INTERRUPTED: 'Partial output was kept and not retried. Re-run the task if it was incomplete.',
50
+ INVALID_TOOL_ARGUMENTS: 'A tool call in the conversation was not valid JSON; it was repaired and the request was retried.',
51
+ };
52
+
53
+ /**
54
+ * Model chat-template control tokens that some providers leak into visible
55
+ * text (`<|channel>thought`, `<channel|>`, `<start_of_turn>`, `</tool_call>`,
56
+ * `<|repo_name|>`, …). They are protocol, not content.
57
+ */
58
+ const MODEL_TOKEN_RE =
59
+ /<\|[^|\n<>]{1,48}\|?>|<[a-z][a-z0-9_]{0,20}\|>|<\/?[a-z][a-z0-9_]{1,24}>|<@[a-z_]{1,24}>/gi;
60
+
61
+ export function cleanModelTokens(text) {
62
+ return String(text ?? '').replace(MODEL_TOKEN_RE, '');
63
+ }
64
+
65
+ /** Remove ANSI/control sequences and leaked template tokens from reasoning. */
66
+ export function cleanReasoning(text) {
67
+ return cleanModelTokens(text)
68
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
69
+ .replace(/\r\n?/g, '\n')
70
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
71
+ }
72
+
73
+ /** Hard-wrap text to a visible width (no ANSI involved; clean the text first). */
74
+ export function wrapText(text, width) {
75
+ const wrapped = [];
76
+ for (const line of String(text || '').split('\n')) {
77
+ if (!line) {
78
+ wrapped.push('');
79
+ continue;
80
+ }
81
+ let current = '';
82
+ let used = 0;
83
+ for (const char of line) {
84
+ const size = charWidth(char.codePointAt(0));
85
+ if (used + size > width && current) {
86
+ wrapped.push(current);
87
+ current = '';
88
+ used = 0;
89
+ }
90
+ current += char;
91
+ used += size;
92
+ }
93
+ wrapped.push(current);
94
+ }
95
+ return wrapped;
96
+ }
97
+
98
+ /**
99
+ * Split captured stdout/stderr into displayable rows: ANSI and control bytes
100
+ * are stripped (they would break the box width math), blank tail rows dropped,
101
+ * and only the last MAX_COMMAND_OUTPUT_CHARS characters kept — the tail is what
102
+ * matters after a failure, and the discarded count is reported to the user.
103
+ */
104
+ export function commandOutputLines(stdout, stderr) {
105
+ const rows = [];
106
+ const push = (text, stream) => {
107
+ const streamRows = String(text ?? '')
108
+ .replace(/\r\n?/g, '\n')
109
+ .split('\n')
110
+ .map((line) => ({
111
+ stream,
112
+ text: line.replace(ANSI_SEQUENCE, '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, ''),
113
+ }));
114
+ // The trailing newline of one stream must not become a blank row between
115
+ // it and the other stream, but blank lines inside the output are kept.
116
+ while (streamRows.length > 0 && streamRows[streamRows.length - 1].text === '') streamRows.pop();
117
+ rows.push(...streamRows);
118
+ };
119
+ push(stdout, 'stdout');
120
+ push(stderr, 'stderr');
121
+ let chars = 0;
122
+ let start = rows.length;
123
+ while (start > 0 && chars + rows[start - 1].text.length + 1 <= MAX_COMMAND_OUTPUT_CHARS) {
124
+ start -= 1;
125
+ chars += rows[start].text.length + 1;
126
+ }
127
+ return { lines: rows.slice(start), dropped: start };
128
+ }
129
+
130
+ export function formatSeconds(ms) {
131
+ return `${(Number(ms || 0) / 1000).toFixed(1)}s`;
132
+ }
133
+
134
+ export function formatTokens(count) {
135
+ const value = Number(count || 0);
136
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`;
137
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
138
+ return String(value);
139
+ }
140
+
141
+ /**
142
+ *
143
+ * Live region pacing. Every streamed token used to redraw the whole reasoning
144
+ * box, which pushed megabytes through the terminal (and the SSH channel) for a
145
+ * long reasoning pass. Redraws are now coalesced to a frame budget, and each
146
+ * frame only touches the tail of the text it can actually show.
147
+ */
148
+ const RENDER_INTERVAL_MS = 80;
149
+ /** Diff rows printed per file change (the rest is summarised). */
150
+ const MAX_DIFF_LINES = 24;
151
+ /** Animation tick for the always-on activity line (10 fps). */
152
+ const STATUS_INTERVAL_MS = 100;
153
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
154
+
155
+ export class Renderer {
156
+ constructor({ stdout = process.stdout, stderr = process.stderr, color = null, plain = false, debug = false } = {}) {
157
+ this.stdout = stdout;
158
+ this.stderr = stderr;
159
+ this.plain = plain || !stdout.isTTY;
160
+ this.color = color === null ? !this.plain && !process.env.NO_COLOR : Boolean(color);
161
+ const depth = typeof stdout.getColorDepth === 'function' ? stdout.getColorDepth() : 1;
162
+ this.c = new Chalk({ level: this.color ? (depth >= 24 ? 3 : depth >= 8 ? 2 : 1) : 0 });
163
+ this.debug = debug;
164
+ this.ansi = Boolean(stdout.isTTY && !this.plain && this.color);
165
+ this.reasoning = { text: '', active: false, blockId: null };
166
+ // Prompts are kept so `/copy` can put one back on the clipboard without
167
+ // asking the user to select text across wrapped output.
168
+ this.tasks = [];
169
+ // Final-answer stream: printed directly as plain text (no box, no erase).
170
+ // `pending` holds the current line that has not been flushed yet.
171
+ this.answer = { text: '', active: false, pending: '', block: null };
172
+ // Rows of the one in-place live region (reasoning box and/or activity
173
+ // line). The cursor always rests on the region's first row, so every frame
174
+ // clears and repaints from a fixed anchor.
175
+ this.liveLines = 0;
176
+ // Every reasoning pass and every captured run_command output is kept here
177
+ // with a stable id, so it can be opened again long after it was printed.
178
+ // `screenLines` mirrors the printed transcript so a click can be mapped
179
+ // back to the block it landed on (wrapped rows included).
180
+ this.blocks = new Map();
181
+ // Fenced code blocks rendered in answers are clipboard targets, not
182
+ // expandable blocks: clicking one copies its code instead of opening a
183
+ // panel. They share the block id counter but stay out of `blocks`, so
184
+ // /expand and the block list never offer them.
185
+ this.copies = new Map();
186
+ this.nextBlockId = 1;
187
+ this.blockView = null;
188
+ this.promptController = null;
189
+ // True while the live prompt has been erased so transcript output can be
190
+ // written in its place; #closeScreen puts it back below the output.
191
+ this.promptParked = false;
192
+ this.screenLines = [];
193
+ this.screenPartial = '';
194
+ this.screenPartialBlock = null;
195
+ // The terminal reflows on resize, so the row math for an open panel is no
196
+ // longer trustworthy; drop the panel state instead of erasing the wrong rows.
197
+ if (typeof stdout.on === 'function') {
198
+ stdout.on('resize', () => {
199
+ this.blockView = null;
200
+ });
201
+ }
202
+ // Coalesced redraw timer for the box content (never one frame per token).
203
+ this.frameTimer = null;
204
+ // Always-on activity line: thinking → reasoning → working. It carries the
205
+ // whole run state (step, tool i/n, tokens, first-token latency), not just a
206
+ // spinner, so a slow model can never look like an opaque "working".
207
+ this.status = {
208
+ active: false,
209
+ phase: null,
210
+ startedAt: 0,
211
+ taskStartedAt: 0,
212
+ extra: '',
213
+ step: null,
214
+ maxSteps: null,
215
+ tool: '',
216
+ toolIndex: 0,
217
+ toolTotal: 0,
218
+ promptTokens: 0,
219
+ completionTokens: 0,
220
+ firstTokenMs: null,
221
+ output: [],
222
+ frame: 0,
223
+ timer: null,
224
+ outputTimer: null,
225
+ };
226
+ }
227
+
228
+ #write(line = '', blockId = null) {
229
+ if (this.blockView) this.closeBlockView();
230
+ // Take the prompt and the live region off screen: the region can only be
231
+ // erased from its own first row, and the prompt sits below it.
232
+ const prompt = this.#openScreen();
233
+ // Anything printed while an answer line is still buffered goes AFTER it:
234
+ // the stream must never be split mid-sentence by a plan/tool/step line.
235
+ if (this.answer.pending) this.#flushAnswer({ force: true });
236
+ if (this.answer.block) {
237
+ // Interleaving output closes any open streamed block so it renders here,
238
+ // above the other output, instead of detached below it.
239
+ this.#renderBlock(this.answer.block);
240
+ this.answer.block = null;
241
+ }
242
+ const text = String(line ?? '');
243
+ this.#trackScreen(`${text}\n`, blockId);
244
+ this.stdout.write(`${text}\n`);
245
+ // Draw the activity line (and the reasoning box) under the new output.
246
+ this.#paintAfterWrite();
247
+ this.#closeScreen(prompt);
248
+ }
249
+
250
+ /**
251
+ * Mirror printed text into the click map. Each logical line remembers the
252
+ * block it belongs to; wrapped rows are counted when a click needs a row.
253
+ */
254
+ #trackScreen(text, blockId = null) {
255
+ const value = String(text ?? '');
256
+ if (!value) return;
257
+ let start = 0;
258
+ for (let index = 0; index < value.length; index += 1) {
259
+ if (value.charCodeAt(index) !== 10) continue;
260
+ this.screenLines.push({
261
+ text: this.screenPartial + value.slice(start, index),
262
+ blockId: this.screenPartialBlock ?? blockId,
263
+ });
264
+ this.screenPartial = '';
265
+ this.screenPartialBlock = null;
266
+ start = index + 1;
267
+ }
268
+ if (start < value.length) {
269
+ if (!this.screenPartial) this.screenPartialBlock = blockId;
270
+ this.screenPartial += value.slice(start);
271
+ }
272
+ }
273
+
274
+ #terminalColumns() {
275
+ return Math.max(20, Number(this.stdout.columns) || 80);
276
+ }
277
+
278
+ #terminalRows() {
279
+ return Math.max(10, Number(this.stdout.rows) || 40);
280
+ }
281
+
282
+ #screenRowsFor(text) {
283
+ const width = displayWidth(text, { ansi: true });
284
+ return Math.max(1, Math.ceil(width / this.#terminalColumns()));
285
+ }
286
+
287
+ #screenRowCount() {
288
+ let rows = 0;
289
+ for (const line of this.screenLines) rows += this.#screenRowsFor(line.text);
290
+ if (this.screenPartial) rows += this.#screenRowsFor(this.screenPartial);
291
+ return rows;
292
+ }
293
+
294
+ /** Block id printed on the given absolute transcript row, or null. */
295
+ #blockAtRow(row) {
296
+ let cursor = 0;
297
+ for (const line of this.screenLines) {
298
+ const height = this.#screenRowsFor(line.text);
299
+ if (row < cursor + height) return line.blockId ?? null;
300
+ cursor += height;
301
+ }
302
+ return null;
303
+ }
304
+
305
+ #icon(symbol, fallback) {
306
+ return this.color ? symbol : fallback;
307
+ }
308
+
309
+ #paint(name, text) {
310
+ return this.c.hex(PALETTE[name] || PALETTE.white)(String(text));
311
+ }
312
+
313
+ #visibleLength(text) {
314
+ return displayWidth(text, { ansi: true });
315
+ }
316
+
317
+ #width() {
318
+ const columns = Number(this.stdout.columns) || 92;
319
+ // Never draw wider than the terminal: a box wider than the window wraps
320
+ // every row, which made the cursor-up erase miscount and shred the screen.
321
+ return Math.max(24, Math.min(columns - 2, 92));
322
+ }
323
+
324
+ #innerWidth() {
325
+ return this.#width() - 6;
326
+ }
327
+
328
+ /** Trim to a visible column budget without cutting ANSI escape sequences. */
329
+ #clip(text, max) {
330
+ const value = String(text ?? '');
331
+ if (this.#visibleLength(value) <= max) return value;
332
+ let output = '';
333
+ let width = 0;
334
+ let index = 0;
335
+ while (index < value.length) {
336
+ if (value[index] === '\x1b') {
337
+ ANSI_AT.lastIndex = index;
338
+ const match = ANSI_AT.exec(value);
339
+ if (match) {
340
+ output += match[0];
341
+ index = ANSI_AT.lastIndex;
342
+ continue;
343
+ }
344
+ }
345
+ const code = value.codePointAt(index);
346
+ const char = String.fromCodePoint(code);
347
+ const size = charWidth(code);
348
+ if (width + size > Math.max(0, max - 1)) break;
349
+ output += char;
350
+ width += size;
351
+ index += char.length;
352
+ }
353
+ return `${output}${this.c.dim('…')}${this.color ? '\x1b[0m' : ''}`;
354
+ }
355
+
356
+ /** Fit plain text into a column budget (wide glyphs count as two). */
357
+ #fit(text, max) {
358
+ const value = String(text ?? '');
359
+ if (displayWidth(value) <= max) return value;
360
+ let output = '';
361
+ let width = 0;
362
+ for (const char of value) {
363
+ const size = charWidth(char.codePointAt(0));
364
+ if (width + size > Math.max(0, max - 1)) break;
365
+ output += char;
366
+ width += size;
367
+ }
368
+ return `${output}…`;
369
+ }
370
+
371
+ /** Cyan → violet → pink character gradient. */
372
+ #grad(text) {
373
+ const value = String(text ?? '');
374
+ if (!this.color || !value) return value;
375
+ const stops = [
376
+ [34, 211, 238],
377
+ [167, 139, 250],
378
+ [244, 114, 182],
379
+ ];
380
+ const chars = [...value];
381
+ const span = Math.max(1, chars.length - 1);
382
+ return chars
383
+ .map((char, index) => {
384
+ const position = (index / span) * (stops.length - 1);
385
+ const from = Math.min(stops.length - 2, Math.floor(position));
386
+ const local = position - from;
387
+ const rgb = stops[from].map((start, channel) =>
388
+ Math.round(start + (stops[from + 1][channel] - start) * local));
389
+ return this.c.hex(`#${rgb.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`)(char);
390
+ })
391
+ .join('');
392
+ }
393
+
394
+ #top(title, meta = '') {
395
+ const width = this.#width();
396
+ const lead = ' ';
397
+ let safeTitle = this.#fit(title, Math.max(4, width - 14));
398
+ let shownMeta = meta;
399
+ let leftPlain = `╭─ ${safeTitle} `;
400
+ let rightPlain = shownMeta ? ` ${shownMeta} ─╮` : '─╮';
401
+ let fill = width - lead.length - displayWidth(leftPlain) - displayWidth(rightPlain);
402
+ if (fill < 1 && shownMeta) {
403
+ shownMeta = '';
404
+ rightPlain = '─╮';
405
+ fill = width - lead.length - displayWidth(leftPlain) - displayWidth(rightPlain);
406
+ }
407
+ if (fill < 1) {
408
+ safeTitle = this.#fit(safeTitle, Math.max(4, width - 10));
409
+ leftPlain = `╭─ ${safeTitle} `;
410
+ fill = Math.max(1, width - lead.length - displayWidth(leftPlain) - displayWidth(rightPlain));
411
+ }
412
+ return (
413
+ `${lead}${this.#paint('cyan', '╭─ ')}${this.c.bold(this.#paint('white', safeTitle))}${this.#paint('cyan', ' ')}` +
414
+ `${this.c.dim('─'.repeat(fill))}` +
415
+ `${shownMeta ? this.c.dim(` ${shownMeta} `) : ''}${this.#paint('cyan', '─╮')}`
416
+ );
417
+ }
418
+
419
+ #mid(title = '') {
420
+ const width = this.#width();
421
+ if (!title) {
422
+ return ` ${this.#paint('cyan', '├')}${this.c.dim('─'.repeat(width - 4))}${this.#paint('cyan', '┤')}`;
423
+ }
424
+ const leftPlain = `├─ ${title} `;
425
+ const fill = Math.max(1, width - 2 - leftPlain.length - 2);
426
+ return (
427
+ ` ${this.#paint('cyan', '├─ ')}${this.c.bold(title)}${this.#paint('cyan', ' ')}` +
428
+ `${this.c.dim('─'.repeat(fill))}${this.#paint('cyan', '─┤')}`
429
+ );
430
+ }
431
+
432
+ #bottom() {
433
+ return ` ${this.#paint('cyan', `╰${'─'.repeat(this.#width() - 4)}╯`)}`;
434
+ }
435
+
436
+ #row(content = '') {
437
+ const inner = this.#innerWidth();
438
+ const clipped = this.#clip(String(content ?? '').replace(/\r?\n/g, ' '), inner);
439
+ const padding = Math.max(0, inner - this.#visibleLength(clipped));
440
+ return ` ${this.#paint('cyan', '│')} ${clipped}${' '.repeat(padding)} ${this.#paint('cyan', '│')}`;
441
+ }
442
+
443
+ /**
444
+ * Render an assistant message: Markdown, tables and code blocks.
445
+ * `base` sets the default text colour (response text is painted white so it
446
+ * reads differently from task/command lines).
447
+ */
448
+ #markdown(text, { base = null } = {}) {
449
+ if (!text) return;
450
+ const width = Math.max(24, this.#width() - 4);
451
+ const copies = [];
452
+ const rows = renderMarkdown(cleanModelTokens(text), {
453
+ width,
454
+ c: this.c,
455
+ color: this.color,
456
+ base,
457
+ // A piped run has no mouse and no click map: keep its output clean.
458
+ copy: this.ansi,
459
+ onCodeBlock: (info) => copies.push(info),
460
+ });
461
+ this.#printMarkdownRows(rows, copies);
462
+ }
463
+
464
+ /**
465
+ * Print rendered rows and mark every row of a fenced code block with its
466
+ * clipboard target, so a click on the `⧉ copy` chip (or the block) copies it.
467
+ */
468
+ #printMarkdownRows(rows, copies, write = null) {
469
+ const targets = copies.map(({ lang, code, start, end }) => {
470
+ const target = {
471
+ id: this.nextBlockId,
472
+ kind: 'code',
473
+ lang,
474
+ code: String(code ?? '').slice(0, MAX_CODE_COPY_CHARS),
475
+ start,
476
+ end,
477
+ };
478
+ this.nextBlockId += 1;
479
+ this.copies.set(target.id, target);
480
+ return target;
481
+ });
482
+ for (let index = 0; index < rows.length; index += 1) {
483
+ const target = targets.find((item) => index >= item.start && index <= item.end);
484
+ const line = rows[index] ? ` ${rows[index]}` : '';
485
+ if (write) write(line, target?.id ?? null);
486
+ else this.#write(line, target?.id ?? null);
487
+ }
488
+ }
489
+
490
+ /** Copy a sent prompt to the clipboard; `which` counts back from the newest. */
491
+ copyTask(which = 0) {
492
+ if (this.tasks.length === 0) return { ok: false, reason: 'no-task', chars: 0 };
493
+ const depth = Math.max(0, Math.min(Math.trunc(Number(which) || 0), this.tasks.length - 1));
494
+ const task = this.tasks[this.tasks.length - 1 - depth];
495
+ const result = copyToClipboard(task, { output: this.stdout });
496
+ if (result.ok) this.info(`Copied ${result.chars} chars of the prompt to the clipboard (OSC 52).`);
497
+ else this.warn('Clipboard copy needs an interactive terminal that supports OSC 52.');
498
+ return result;
499
+ }
500
+
501
+ /** The interactive prompt prefix. */
502
+ prompt() {
503
+ if (this.plain) return 'ryuu> ';
504
+ return ` ${this.#grad('◈')} ${this.c.bold(this.#grad('ryuu'))} ${this.c.dim('▸')} `;
505
+ }
506
+
507
+ /** Print pre-formatted text (used by slash commands). */
508
+ lines(text = '') {
509
+ for (const line of String(text).split('\n')) this.#write(line);
510
+ }
511
+
512
+ banner({ version, model, provider, workspace, node, approvalPolicy, logFile }) {
513
+ const c = this.c;
514
+ if (this.plain) {
515
+ this.#write(`Ryuu Agent CLI v${version}`);
516
+ this.#write(`provider: ${provider}`);
517
+ this.#write(`model: ${model}`);
518
+ this.#write(`workspace: ${workspace}`);
519
+ this.#write(`node: ${node}`);
520
+ this.#write(`approval: ${approvalPolicy}`);
521
+ this.#write(`log: ${logFile || '(disabled)'}`);
522
+ this.#write('');
523
+ return;
524
+ }
525
+
526
+ const inner = this.#innerWidth();
527
+ const wordmark = [
528
+ '██████╗ ██╗ ██╗██╗ ██╗██╗ ██╗',
529
+ '██╔══██╗╚██╗ ██╔╝██║ ██║██║ ██║',
530
+ '██████╔╝ ╚████╔╝ ██║ ██║██║ ██║',
531
+ '██╔══██╗ ╚██╔╝ ██║ ██║██║ ██║',
532
+ '██║ ██║ ██║ ╚██████╔╝╚██████╔╝',
533
+ '╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝',
534
+ ];
535
+ const wordmarkWidth = Math.max(...wordmark.map((line) => line.length));
536
+ const tagline = 'autonomous coding intelligence · NVIDIA';
537
+ this.#write('');
538
+ this.#write(this.#top('◢ RYUU // AGENT OS', `v${version}`));
539
+ this.#write(this.#row(''));
540
+ for (const line of wordmark) {
541
+ const pad = Math.max(0, Math.floor((inner - wordmarkWidth) / 2));
542
+ this.#write(this.#row(`${' '.repeat(pad)}${this.#grad(line)}`));
543
+ }
544
+ this.#write(this.#row(''));
545
+ this.#write(
546
+ this.#row(c.dim(`${' '.repeat(Math.max(0, Math.floor((inner - tagline.length) / 2)))}${tagline}`)),
547
+ );
548
+ this.#write(this.#row(''));
549
+ this.#write(this.#mid('◆ TELEMETRY'));
550
+ this.#write(this.#row(`${this.#paint('cyan', 'model'.padEnd(10))} ${c.bold(this.#fit(model, inner - 12))}`));
551
+ this.#write(this.#row(`${this.#paint('cyan', 'workspace'.padEnd(10))} ${this.#fit(workspace, inner - 12)}`));
552
+ this.#write(
553
+ this.#row(`${this.#paint('cyan', 'runtime'.padEnd(10))} ${c.dim(`Node.js ${node} · ${process.platform}`)}`),
554
+ );
555
+ this.#write(
556
+ this.#row(`${this.#paint('cyan', 'access'.padEnd(10))} ${c.dim(`${approvalPolicy} · shell is not a sandbox`)}`),
557
+ );
558
+ if (logFile) {
559
+ this.#write(this.#row(`${this.#paint('cyan', 'log'.padEnd(10))} ${c.dim(this.#fit(logFile, inner - 12))}`));
560
+ }
561
+ this.#write(this.#bottom());
562
+ this.#write(
563
+ c.dim(
564
+ this.#width() >= 66
565
+ ? ' click a block or /expand N !cmd shell ⌃C cancel /help commands /exit quit'
566
+ : ' click block · /expand N · !cmd · ⌃C · /help · /exit',
567
+ ),
568
+ );
569
+ this.#write('');
570
+ }
571
+
572
+ info(message) {
573
+ this.#write(`${this.#paint('cyan', this.#icon('◇', '·'))} ${this.c.dim(message)}`);
574
+ }
575
+
576
+ success(message) {
577
+ this.#write(`${this.#paint('green', this.#icon('✓', '[ok]'))} ${message}`);
578
+ }
579
+
580
+ warn(message) {
581
+ this.#write(`${this.#paint('amber', this.#icon('▲', '[warn]'))} ${this.#paint('amber', message)}`);
582
+ }
583
+
584
+ muted(message) {
585
+ this.#write(this.c.dim(message));
586
+ }
587
+
588
+ taskStart({ task, sessionId, maxSteps }) {
589
+ this.stopActivity();
590
+ this.tasks.push(String(task ?? ''));
591
+ if (this.tasks.length > 25) this.tasks.shift();
592
+ this.answer = { text: '', active: false, pending: '', block: null };
593
+ this.status.taskStartedAt = Date.now();
594
+ this.status.step = 0;
595
+ this.status.maxSteps = maxSteps || null;
596
+ this.status.promptTokens = 0;
597
+ this.status.completionTokens = 0;
598
+ this.status.firstTokenMs = null;
599
+ this.status.output = [];
600
+ if (this.plain) {
601
+ this.#write('');
602
+ this.#write(`${this.#icon('◆', '>')} Task ${task.length > 160 ? `${task.slice(0, 160)}…` : task}`);
603
+ this.#write(` session ${sessionId} · ${maxSteps ? `max ${maxSteps} steps` : 'unlimited steps'}`);
604
+ return;
605
+ }
606
+ const limit = maxSteps ? `max ${maxSteps} steps` : '∞ autonomous';
607
+ this.#write('');
608
+ this.#write(this.#top('◢ task uplink', `session ${sessionId}`));
609
+ this.#write(this.#row(''));
610
+ this.#write(
611
+ this.#row(
612
+ `${this.#paint('blue', '▸')} ${this.c.bold(this.c.hex(PALETTE.blue)(this.#fit(task, this.#innerWidth() - 4)))}`,
613
+ ),
614
+ );
615
+ this.#write(this.#row(this.c.dim(`${limit} · approval-gated · live telemetry`)));
616
+ this.#write(this.#bottom());
617
+ }
618
+
619
+ plan({ plan }) {
620
+ if (!plan) return;
621
+ const text = cleanModelTokens(plan);
622
+ if (this.plain) {
623
+ this.#write('');
624
+ this.#write(' Plan');
625
+ for (const line of text.split('\n').slice(0, 12)) this.#write(this.c.dim(` ${line}`));
626
+ return;
627
+ }
628
+ this.#write('');
629
+ this.#write(` ${this.#paint('violet', '◆')} ${this.c.bold('PLAN')} ${this.c.dim('· proposed sequence')}`);
630
+ for (const line of text.split('\n').slice(0, 12)) {
631
+ const match = line.match(/^(\s*)(\d+[.)]|[-*])\s+(.*)$/);
632
+ if (match) {
633
+ this.#write(
634
+ ` ${this.c.dim('│')} ${this.#paint('cyan', match[2].padStart(3, ' '))} ${this.#fit(match[3], this.#innerWidth())}`,
635
+ );
636
+ } else {
637
+ this.#write(` ${this.c.dim('│')} ${this.c.dim(this.#fit(line.trim(), this.#innerWidth()))}`);
638
+ }
639
+ }
640
+ }
641
+
642
+ stepStart({ step, maxSteps }) {
643
+ const label = maxSteps ? `step ${step}/${maxSteps}` : `step ${step}`;
644
+ if (this.plain) {
645
+ this.#write('');
646
+ this.#write(`── ${label} ${'─'.repeat(Math.max(0, 40 - label.length))}`);
647
+ return;
648
+ }
649
+ const tag = `STEP ${String(step).padStart(2, '0')}`;
650
+ const meta = maxSteps ? `${step}/${maxSteps}` : '∞ autonomous';
651
+ this.#write('');
652
+ this.#write(` ${this.#grad('◢')} ${this.c.bold(tag)} ${this.c.dim('·')} ${this.c.dim(meta)}`);
653
+ this.#write(this.c.dim(` ${'╌'.repeat(this.#width() - 4)}`));
654
+ }
655
+
656
+ /**
657
+ * The always-on activity line at the bottom of the chat UI. Exactly one
658
+ * phase is shown at a time:
659
+ * thinking — the model thinks in the background (no visible output yet)
660
+ * reasoning — its reasoning stream is being displayed in the box above
661
+ * working — everything else: assembling tool calls, running commands,
662
+ * approvals, streaming the answer
663
+ */
664
+ /** Human verb for the tool that is currently running. */
665
+ #activityVerb(name) {
666
+ if (!name) return '';
667
+ if (/^(create_file|write_file|edit_file)$/.test(name)) return 'writing';
668
+ if (name === 'read_file' || name === 'file_info' || name === 'diff_file') return 'reading';
669
+ if (name === 'run_command') return 'running';
670
+ if (name === 'start_process' || name === 'stop_process') return 'process';
671
+ if (name === 'search_files' || name === 'list_files' || name === 'tree') return 'scanning';
672
+ return 'working';
673
+ }
674
+
675
+ /**
676
+ * The activity line: phase and elapsed time, then as much real telemetry as
677
+ * fits — step, total run time, the tool being used and how many are queued,
678
+ * live token counts and the first-token latency.
679
+ */
680
+ #statusText() {
681
+ const s = this.status;
682
+ const glyph = SPINNER_FRAMES[s.frame % SPINNER_FRAMES.length];
683
+ const phase = s.phase || 'working';
684
+ const phaseElapsed = formatSeconds(Date.now() - s.startedAt);
685
+ const headPlain = ` ${glyph} ${phase} · ${phaseElapsed}`;
686
+ const details = [];
687
+ if (s.step) details.push(s.maxSteps ? `step ${s.step}/${s.maxSteps}` : `step ${s.step}`);
688
+ if (s.taskStartedAt) details.push(`total ${formatSeconds(Date.now() - s.taskStartedAt)}`);
689
+ if (s.tool) {
690
+ const label = `${this.#activityVerb(s.tool)} ${s.tool}`;
691
+ const target = s.extra ? ` ${this.#fit(s.extra, 28)}` : '';
692
+ details.push(s.toolTotal > 1 ? `${label}${target} (${s.toolIndex}/${s.toolTotal})` : `${label}${target}`);
693
+ } else if (s.extra) {
694
+ details.push(s.extra);
695
+ }
696
+ if (s.promptTokens || s.completionTokens) {
697
+ details.push(`${formatTokens(s.promptTokens)}↑ ${formatTokens(s.completionTokens)}↓ tok`);
698
+ }
699
+ if (s.phase === 'thinking' && s.firstTokenMs !== null && s.firstTokenMs !== undefined) {
700
+ details.push(`first token ${formatSeconds(s.firstTokenMs)}`);
701
+ }
702
+
703
+ const room = Math.max(10, this.#width() - 2);
704
+ let used = displayWidth(headPlain);
705
+ const shown = [];
706
+ for (const detail of details) {
707
+ const left = room - used - 3;
708
+ if (left < 8) break;
709
+ const fitted = this.#fit(detail, left);
710
+ if (!fitted) break;
711
+ shown.push(fitted);
712
+ used += displayWidth(fitted) + 3;
713
+ }
714
+ const coloredHead = ` ${this.#paint('cyan', glyph)} ${this.c.bold(phase)} ${this.c.dim(`· ${phaseElapsed}`)}`;
715
+ return coloredHead + shown.map((detail) => this.c.dim(` · ${detail}`)).join('');
716
+ }
717
+
718
+ #startTicker() {
719
+ if (!this.ansi || this.status.timer) return;
720
+ this.status.timer = setInterval(() => {
721
+ if (!this.status.active) return;
722
+ this.status.frame += 1;
723
+ if (this.liveLines > 0) this.#updateStatusRow();
724
+ else this.#paintLive();
725
+ }, STATUS_INTERVAL_MS);
726
+ this.status.timer.unref?.();
727
+ }
728
+
729
+ #stopTicker() {
730
+ if (!this.status.timer) return;
731
+ clearInterval(this.status.timer);
732
+ this.status.timer = null;
733
+ }
734
+
735
+ /** Switch the activity line to a phase (starting the clock on first use). */
736
+ #setPhase(phase, extra = '') {
737
+ if (!this.ansi) return;
738
+ const s = this.status;
739
+ // The clock measures the current phase, not the whole task: a long
740
+ // `thinking` pass followed by `working · 0.3s · run_command` reads correctly.
741
+ if (!s.active || s.phase !== phase) {
742
+ s.startedAt = Date.now();
743
+ s.frame = 0;
744
+ }
745
+ s.active = true;
746
+ s.phase = phase;
747
+ s.extra = extra;
748
+ this.#startTicker();
749
+ this.#paintLive();
750
+ }
751
+
752
+ /** Stop the activity line and take the live region off screen. */
753
+ stopActivity() {
754
+ this.status.active = false;
755
+ this.status.phase = null;
756
+ this.status.extra = '';
757
+ this.status.tool = '';
758
+ this.status.toolIndex = 0;
759
+ this.status.toolTotal = 0;
760
+ this.status.output = [];
761
+ this.#stopTicker();
762
+ this.#cancelFrame();
763
+ if (this.status.outputTimer) {
764
+ clearTimeout(this.status.outputTimer);
765
+ this.status.outputTimer = null;
766
+ }
767
+ if (this.liveLines > 0) this.#clearLive();
768
+ }
769
+
770
+ thinkingStart({ step = null, maxSteps = null } = {}) {
771
+ this.status.step = step ?? this.status.step;
772
+ this.status.maxSteps = maxSteps ?? this.status.maxSteps;
773
+ this.status.tool = '';
774
+ this.status.toolIndex = 0;
775
+ this.status.toolTotal = 0;
776
+ this.status.firstTokenMs = null;
777
+ this.#setPhase('thinking');
778
+ }
779
+
780
+ workingStart({ tool = '', step = null, maxSteps = null, toolCalls = 0 } = {}) {
781
+ this.status.step = step ?? this.status.step;
782
+ this.status.maxSteps = maxSteps ?? this.status.maxSteps;
783
+ this.status.tool = tool || '';
784
+ this.status.toolTotal = toolCalls || 0;
785
+ this.status.toolIndex = 0;
786
+ this.#setPhase('working', tool || '');
787
+ }
788
+
789
+ /**
790
+ * Live telemetry from the agent: token counts, first-token latency and the
791
+ * step counter, pushed while the model is still working. Without this the
792
+ * activity line could only say "thinking", which is exactly the complaint.
793
+ */
794
+ progress({ step = null, maxSteps = null, promptTokens = null, completionTokens = null, firstTokenMs = null } = {}) {
795
+ const s = this.status;
796
+ if (step !== null) s.step = step;
797
+ if (maxSteps !== null) s.maxSteps = maxSteps;
798
+ if (Number.isFinite(promptTokens)) s.promptTokens = promptTokens;
799
+ if (Number.isFinite(completionTokens)) s.completionTokens = completionTokens;
800
+ if (firstTokenMs !== null && firstTokenMs !== undefined) s.firstTokenMs = firstTokenMs;
801
+ if (!this.ansi || !s.active) return;
802
+ this.#paintLive();
803
+ }
804
+
805
+ /**
806
+ * Live stdout/stderr tail for a running command. The ProcessManager already
807
+ * emits this data; the agent used to drop it until the process exited.
808
+ */
809
+ processOutput({ text = '', stream = 'stdout' } = {}) {
810
+ if (!text || this.plain) return;
811
+ const clean = String(text)
812
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
813
+ .replace(/\r\n?/g, '\n')
814
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
815
+ const rows = clean
816
+ .split('\n')
817
+ .map((line) => line.trimEnd())
818
+ .filter((line) => line.length > 0)
819
+ .slice(-2)
820
+ .map((line) => this.#fit(`${stream === 'stderr' ? '!' : '›'} ${line}`, Math.max(8, this.#width() - 4)));
821
+ if (rows.length === 0) return;
822
+ const output = [...this.status.output, ...rows].slice(-2);
823
+ this.status.output = output;
824
+ this.#queueOutputPaint();
825
+ }
826
+
827
+ /** Coalesce command-output repaints: never one full redraw per data chunk. */
828
+ #queueOutputPaint() {
829
+ if (!this.ansi || this.status.outputTimer) return;
830
+ this.status.outputTimer = setTimeout(() => {
831
+ this.status.outputTimer = null;
832
+ if (this.status.active) this.#paintLive();
833
+ }, 150);
834
+ this.status.outputTimer.unref?.();
835
+ }
836
+
837
+ /**
838
+ * Heartbeat: proof that the agent is alive while it recovers.
839
+ *
840
+ * retry — the provider link dropped; a reconnect is in progress
841
+ * restored — the link is back and the run continues
842
+ * degraded — the model's response was broken and has been repaired
843
+ *
844
+ * The activity line keeps spinning in every phase, so a long reconnect
845
+ * never looks like a frozen terminal.
846
+ */
847
+ heartbeat({ phase = 'retry', attempt = 0, maxAttempts = 0, delayMs = 0, code = '', message = '' } = {}) {
848
+ const detail = String(message || '').replace(/\s+/g, ' ').trim();
849
+ if (phase === 'retry') {
850
+ const seconds = (Number(delayMs || 0) / 1000).toFixed(1);
851
+ // maxAttempts 0 means "no cap": say so instead of printing 0 or ?.
852
+ const cap = Number(maxAttempts) > 0 ? `/${maxAttempts}` : ' (unlimited)';
853
+ const emptyTurn = code === 'EMPTY_RESPONSE';
854
+ const what = emptyTurn ? 'empty response' : 'reconnecting';
855
+ const label = `${what} · attempt ${attempt}${cap} in ${seconds}s`;
856
+ // The label already says what happened for an empty turn; printing the
857
+ // internal EMPTY_RESPONSE code there reads like a fatal fault.
858
+ const shownCode = emptyTurn ? '' : code;
859
+ this.#setPhase(emptyTurn ? 'retrying' : 'reconnecting', emptyTurn ? '' : code || '');
860
+ if (this.plain) {
861
+ this.#write(this.c.dim(` [heartbeat] ${label}${shownCode ? ` · ${shownCode}` : ''}`));
862
+ return;
863
+ }
864
+ // Heartbeat notices are bold sky blue: transport news, not a fault.
865
+ this.#write(
866
+ ` ${this.c.bold(this.#paint('sky', `♥ heartbeat · ${label}${shownCode ? ` · ${shownCode}` : ''}`))}`,
867
+ );
868
+ return;
869
+ }
870
+ if (phase === 'restored') {
871
+ this.#setPhase('working');
872
+ if (this.plain) {
873
+ this.#write(this.c.dim(` [heartbeat] link restored after ${attempt} attempt(s)`));
874
+ return;
875
+ }
876
+ this.#write(
877
+ ` ${this.c.bold(this.#paint('sky', `♥ heartbeat · link restored after ${attempt} attempt(s)`))}`,
878
+ );
879
+ return;
880
+ }
881
+ // degraded: the response was repaired, the agent keeps working
882
+ this.#setPhase('working', code || '');
883
+ if (this.plain) {
884
+ this.#write(this.c.dim(` [heartbeat] ${code || 'degraded'}${detail ? ` · ${detail}` : ''}`));
885
+ return;
886
+ }
887
+ this.#write(
888
+ ` ${this.c.bold(
889
+ this.#paint(
890
+ 'sky',
891
+ `♥ heartbeat · ${code || 'degraded'}${detail ? ` · ${this.#fit(detail, this.#innerWidth() - 24)}` : ''}`,
892
+ ),
893
+ )}`,
894
+ );
895
+ }
896
+
897
+ /** Rows of the reasoning box (empty when no reasoning is streaming). */
898
+ #previewBoxLines({ id = null, live = false } = {}) {
899
+ // No clock in the header: the activity line below already carries the
900
+ // elapsed time, and it is the only thing that animates every tick.
901
+ const title = id ? `◈ reasoning stream #${id}` : '◈ reasoning stream';
902
+ return [
903
+ this.#top(title, live ? 'live' : '▸ closed'),
904
+ ...this.#previewLines().map((line) => this.#row(this.c.dim(line))),
905
+ this.#bottom(),
906
+ ];
907
+ }
908
+
909
+ #liveBox() {
910
+ if (!this.ansi || !this.reasoning.active) return [];
911
+ return this.#previewBoxLines({ id: this.reasoning.blockId, live: true });
912
+ }
913
+
914
+ /** Every row of the live region: reasoning box, command tail, activity line. */
915
+ #liveRegionLines() {
916
+ const lines = this.#liveBox();
917
+ if (this.status.active) {
918
+ for (const row of this.status.output) lines.push(` ${this.c.dim(row)}`);
919
+ lines.push(this.#statusText());
920
+ }
921
+ return lines;
922
+ }
923
+
924
+ /**
925
+ * Paint the whole live region (box + activity line) and leave the cursor on
926
+ * its first row, so the next frame always redraws from the same anchor.
927
+ *
928
+ * The composer prompt stays visible during a task, so every screen change
929
+ * parks it first and puts it back afterwards. Lines typed while the agent
930
+ * works therefore never land inside the reasoning box.
931
+ */
932
+ #paintLive() {
933
+ if (!this.ansi || this.blockView) return;
934
+ const prompt = this.#openScreen();
935
+ const lines = this.#liveRegionLines();
936
+ if (lines.length > 0) {
937
+ for (const line of lines) this.stdout.write(`${line}\n`);
938
+ this.stdout.write(`\x1b[${lines.length}A\r`);
939
+ this.liveLines = lines.length;
940
+ }
941
+ this.#closeScreen(prompt);
942
+ }
943
+
944
+ /** Animate only the last row of the region (the activity line). */
945
+ #updateStatusRow() {
946
+ if (!this.ansi || !this.status.active || this.liveLines <= 0) return;
947
+ if (this.blockView) return;
948
+ const prompt = this.#parkPrompt();
949
+ const down = this.liveLines - 1;
950
+ const move = down > 0 ? `\x1b[${down}B` : '';
951
+ const back = down > 0 ? `\x1b[${down}A` : '';
952
+ this.stdout.write(`${move}\r\x1b[2K${this.#statusText()}${back}\r`);
953
+ this.#closeScreen(prompt);
954
+ }
955
+
956
+ /** Raw answer output: takes the region down, then restores it underneath. */
957
+ #answerWrite(text, blockId = null) {
958
+ const prompt = this.#openScreen();
959
+ this.#trackScreen(text, blockId);
960
+ this.stdout.write(text);
961
+ this.#paintAfterWrite();
962
+ this.#closeScreen(prompt);
963
+ }
964
+
965
+ #paintAfterWrite() {
966
+ if (!this.ansi) return;
967
+ if (!this.reasoning.active && !this.status.active) return;
968
+ this.#paintLive();
969
+ }
970
+
971
+ /** Tail of the reasoning text (clean), bounded so a frame is O(viewport). */
972
+ #reasoningTail(chars) {
973
+ const text = cleanReasoning(this.reasoning.text);
974
+ return text.length <= chars ? text : Array.from(text).slice(-chars).join('');
975
+ }
976
+
977
+ /** Last PREVIEW_LINES wrapped lines, padded so the box keeps a fixed height. */
978
+ #previewLines() {
979
+ const width = this.#innerWidth();
980
+ // Only the tail can ever be shown, so only the tail is wrapped. Wrapping
981
+ // the whole transcript on every token was the quadratic hot spot that
982
+ // starved the terminal during long reasoning passes.
983
+ const tail = this.#reasoningTail(width * (PREVIEW_LINES + 2) * 2);
984
+ const lines = wrapText(tail, width).slice(-PREVIEW_LINES);
985
+ while (lines.length < PREVIEW_LINES) lines.unshift('');
986
+ return lines.map((line) => this.#fit(line, width));
987
+ }
988
+
989
+ /**
990
+ * Take the live region off screen. The prompt is parked first and redrawn
991
+ * afterwards, so it keeps its place under whatever was printed.
992
+ */
993
+ #clearLive() {
994
+ if (!this.ansi || this.liveLines <= 0) {
995
+ this.liveLines = 0;
996
+ return;
997
+ }
998
+ const prompt = this.#openScreen();
999
+ this.#closeScreen(prompt);
1000
+ }
1001
+
1002
+ // ── prompt parking ───────────────────────────────────────────────────────
1003
+
1004
+ /**
1005
+ * Erase the live prompt (if one is on screen) and leave the cursor on the
1006
+ * row where new output belongs: the top of the live region, or the prompt's
1007
+ * first row when nothing is painted under it. Returns the prompt state so
1008
+ * #closeScreen can draw it again.
1009
+ */
1010
+ #openScreen() {
1011
+ if (this.blockView && !this.promptParked) this.closeBlockView();
1012
+ if (this.promptParked) {
1013
+ // Nested write inside a frame that already took the screen: the cursor
1014
+ // rests on the live region's first row, so clear downwards from here.
1015
+ if (this.liveLines > 0) {
1016
+ this.stdout.write('\r\x1b[0J');
1017
+ this.liveLines = 0;
1018
+ }
1019
+ return null;
1020
+ }
1021
+ const prompt = this.#promptState();
1022
+ if (prompt) {
1023
+ this.promptParked = true;
1024
+ const up = prompt.cursorRow;
1025
+ this.stdout.write(`${up > 0 ? `\x1b[${up}A` : ''}\r\x1b[0J`);
1026
+ }
1027
+ if (this.liveLines > 0) {
1028
+ this.stdout.write(`\x1b[${this.liveLines}A\r\x1b[0J`);
1029
+ this.liveLines = 0;
1030
+ }
1031
+ return prompt;
1032
+ }
1033
+
1034
+ /** Erase only the prompt and stop on the live region's first row. */
1035
+ #parkPrompt() {
1036
+ const prompt = this.promptParked ? null : this.#promptState();
1037
+ if (!prompt) return null;
1038
+ this.promptParked = true;
1039
+ const up = prompt.cursorRow;
1040
+ this.stdout.write(`${up > 0 ? `\x1b[${up}A` : ''}\r\x1b[0J`);
1041
+ if (this.liveLines > 0) this.stdout.write(`\x1b[${this.liveLines}A`);
1042
+ return prompt;
1043
+ }
1044
+
1045
+ /** Redraw the prompt below the live region (no-op when none was parked). */
1046
+ #closeScreen(prompt) {
1047
+ if (!prompt) return;
1048
+ this.promptParked = false;
1049
+ if (this.liveLines > 0) this.stdout.write(`\x1b[${this.liveLines}B`);
1050
+ this.stdout.write('\r');
1051
+ this.promptController?.resetCursor?.();
1052
+ this.promptController?.repaint?.();
1053
+ }
1054
+
1055
+ /** Coalesce box content redraws instead of one full box per token. */
1056
+ #scheduleFrame() {
1057
+ if (!this.ansi || this.frameTimer) return;
1058
+ this.frameTimer = setTimeout(() => {
1059
+ this.frameTimer = null;
1060
+ if (this.reasoning.active) this.#paintLive();
1061
+ }, RENDER_INTERVAL_MS);
1062
+ this.frameTimer.unref?.();
1063
+ }
1064
+
1065
+ #cancelFrame() {
1066
+ if (!this.frameTimer) return;
1067
+ clearTimeout(this.frameTimer);
1068
+ this.frameTimer = null;
1069
+ }
1070
+
1071
+ /** Live provider reasoning: a closed five-line box that keeps its block id. */
1072
+ reasoningDelta({ text } = {}) {
1073
+ if (!text) return;
1074
+ const st = this.reasoning;
1075
+ if (this.answer.active) this.answerEnd();
1076
+ const starting = !st.active;
1077
+ if (starting) {
1078
+ st.active = true;
1079
+ st.text = '';
1080
+ st.blockId = this.#createBlock({ kind: 'reasoning', text: '', chars: 0, active: true }).id;
1081
+ }
1082
+ if (st.text.length < MAX_REASONING_CHARS) st.text += text;
1083
+ if (this.plain) return;
1084
+ // The phase switch paints the first frame (nothing can look frozen), later
1085
+ // tokens only mark the box dirty for the next coalesced frame.
1086
+ if (starting) this.#setPhase('reasoning');
1087
+ else this.#scheduleFrame();
1088
+ }
1089
+
1090
+ /**
1091
+ * Finish the live reasoning region. The five-row preview stays as the step
1092
+ * record, and the complete text stays in the block registry so `/expand`
1093
+ * can show it again at any time.
1094
+ */
1095
+ reasoningEnd({ logFile = null, partial = false } = {}) {
1096
+ const st = this.reasoning;
1097
+ if (!st.active) return;
1098
+ const chars = st.text.length;
1099
+ const block = st.blockId ? this.blocks.get(st.blockId) : null;
1100
+ if (block) {
1101
+ block.text = cleanReasoning(st.text);
1102
+ block.chars = chars;
1103
+ block.active = false;
1104
+ }
1105
+ const record = this.ansi ? this.#previewBoxLines({ id: st.blockId }) : [];
1106
+ st.active = false;
1107
+ st.blockId = null;
1108
+ st.text = '';
1109
+ this.#cancelFrame();
1110
+ if (record.length > 0) this.#printLines(record, block?.id ?? null);
1111
+ if (chars > 0) {
1112
+ const suffix = `${chars} chars${partial ? ' · partial' : ''}`;
1113
+ const hint = block ? ` · click or /expand ${block.id}` : '';
1114
+ const line = this.plain
1115
+ ? this.c.dim(` reasoning ${suffix}`)
1116
+ : ` ${this.#paint('violet', '◈')} ${this.c.dim(`reasoning ${suffix}${hint}`)}`;
1117
+ if (block) this.#printLines([line], block.id);
1118
+ else this.#write(line);
1119
+ }
1120
+ }
1121
+
1122
+ /** Live final-answer text: printed directly as it streams — no box. */
1123
+ contentDelta({ text } = {}) {
1124
+ if (!text || this.plain) return;
1125
+ if (this.reasoning.active) this.reasoningEnd({});
1126
+ const st = this.answer;
1127
+ if (!st.active) {
1128
+ st.active = true;
1129
+ st.text = '';
1130
+ st.pending = '';
1131
+ st.block = null;
1132
+ this.thinkingEnd();
1133
+ this.#write('');
1134
+ }
1135
+ if (st.text.length < MAX_REASONING_CHARS) {
1136
+ const clean = String(text).replace(/\r\n?/g, '\n');
1137
+ st.text += clean;
1138
+ st.pending += clean;
1139
+ this.#flushAnswer();
1140
+ }
1141
+ }
1142
+
1143
+ /**
1144
+ * Emit the completed logical lines of the pending answer, keeping the rest
1145
+ * for later. `force` also flushes the trailing partial line — used when other
1146
+ * output is about to be printed so the stream is never split mid-sentence.
1147
+ * Only newline boundaries are handled here: display wrapping happens per
1148
+ * line inside #answerRow, so table/code rows are never cut before the block
1149
+ * detector sees them (a wrapped table row would break the table in half).
1150
+ */
1151
+ #flushAnswer({ force = false } = {}) {
1152
+ const st = this.answer;
1153
+ let newlineAt;
1154
+ while ((newlineAt = st.pending.indexOf('\n')) !== -1) {
1155
+ this.#answerRow(st.pending.slice(0, newlineAt));
1156
+ st.pending = st.pending.slice(newlineAt + 1);
1157
+ }
1158
+ if (force && st.pending) {
1159
+ this.#answerRow(st.pending);
1160
+ st.pending = '';
1161
+ }
1162
+ }
1163
+
1164
+ /** Word-boundary wrap that keeps `**bold**`/`code` spans balanced. */
1165
+ #wrapProse(text) {
1166
+ const width = Math.max(24, this.#width() - 2);
1167
+ const balanced = (value) =>
1168
+ (value.match(/\*\*/g) || []).length % 2 === 0 && (value.match(/`/g) || []).length % 2 === 0;
1169
+ const lines = [];
1170
+ let rest = String(text ?? '');
1171
+ while (displayWidth(rest) > width) {
1172
+ // Cut by columns, not by character count: CJK/emoji are two columns each
1173
+ // and counting them as one would overflow the terminal line.
1174
+ let used = 0;
1175
+ let cut = 0;
1176
+ for (const char of rest) {
1177
+ const size = charWidth(char.codePointAt(0));
1178
+ if (used + size > width) break;
1179
+ used += size;
1180
+ cut += char.length;
1181
+ }
1182
+ // Break at the last space that still fits the column budget, never past
1183
+ // it: clamping to a space beyond the cut would split a `**bold**` span.
1184
+ for (let index = rest.lastIndexOf(' ', cut - 1); index > 0; index = rest.lastIndexOf(' ', index - 1)) {
1185
+ if (balanced(rest.slice(0, index))) {
1186
+ cut = index;
1187
+ break;
1188
+ }
1189
+ }
1190
+ if (cut <= 0) break;
1191
+ lines.push(rest.slice(0, cut));
1192
+ rest = rest.slice(cut).replace(/^ +/, '');
1193
+ }
1194
+ lines.push(rest);
1195
+ return lines;
1196
+ }
1197
+
1198
+ /** One streamed answer logical line: block detection first, then rendering. */
1199
+ #answerRow(line) {
1200
+ // Raw write on purpose: #write flushes the answer first, and this IS the
1201
+ // answer — routing through it would recurse.
1202
+ const clean = cleanModelTokens(line);
1203
+ const st = this.answer;
1204
+
1205
+ // Fenced code blocks accumulate until the closing fence, then render as
1206
+ // a highlighted block (identical to the final-response renderer).
1207
+ if (st.block?.type === 'code') {
1208
+ st.block.lines.push(clean);
1209
+ if (/^\s*```/.test(clean) && st.block.lines.length > 1) {
1210
+ this.#renderBlock(st.block);
1211
+ st.block = null;
1212
+ }
1213
+ return;
1214
+ }
1215
+ if (/^\s*```/.test(clean)) {
1216
+ st.block = { type: 'code', lines: [clean] };
1217
+ return;
1218
+ }
1219
+ // Pipe tables accumulate UNWRAPPED: real rows are often far wider than
1220
+ // the screen, and the block renderer wraps their cells width-aware.
1221
+ // Wrapping a row here would split it mid-cell and break the table into
1222
+ // fragments separated by stray text.
1223
+ if (/^\s*\|/.test(clean)) {
1224
+ if (st.block?.type === 'table') st.block.lines.push(clean);
1225
+ else st.block = { type: 'table', lines: [clean] };
1226
+ return;
1227
+ }
1228
+ if (st.block?.type === 'table') {
1229
+ this.#renderBlock(st.block);
1230
+ st.block = null;
1231
+ }
1232
+
1233
+ if (!clean.trim()) {
1234
+ this.#answerWrite('\n');
1235
+ return;
1236
+ }
1237
+ // Horizontal rules become a dim divider instead of literal dashes.
1238
+ if (/^\s*---+\s*$/.test(clean)) {
1239
+ const width = Math.min(60, Math.max(24, this.#width() - 4));
1240
+ this.#answerWrite(` ${this.c.dim('─'.repeat(width))}\n`);
1241
+ return;
1242
+ }
1243
+ // Blockquotes lose their `>` marker and render dim/quoted.
1244
+ const quoted = clean.match(/^\s*>\s?(.*)$/);
1245
+ if (quoted) {
1246
+ for (const display of this.#wrapProse(quoted[1])) {
1247
+ this.#answerWrite(` ${this.c.dim(display)}\n`);
1248
+ }
1249
+ return;
1250
+ }
1251
+ // ATX headers (`### title`) lose their hashes and render bold instead:
1252
+ // the strip markers are presentation the model expects us to consume.
1253
+ // Prose wraps at word boundaries so inline tokens survive.
1254
+ const header = clean.match(/^\s*#{1,6}\s+(.*)$/);
1255
+ const body = header ? header[1] : clean;
1256
+ const base = header
1257
+ ? (text) => this.c.bold(this.#paint('white', text))
1258
+ : (text) => this.#paint('white', text);
1259
+ for (const display of this.#wrapProse(body)) {
1260
+ this.#answerWrite(` ${renderInline(display, this.c, base)}\n`);
1261
+ }
1262
+ }
1263
+
1264
+ /** Render an accumulated streamed block (table or fenced code) via the block renderer. */
1265
+ #renderBlock(block) {
1266
+ let text = block.lines.join('\n');
1267
+ if (block.type === 'code' && !/^\s*```[^\n]*\n[\s\S]*\n\s*```\s*$/.test(text)) {
1268
+ text += '\n```'; // the stream ended mid-fence: close it so it renders
1269
+ }
1270
+ const width = Math.max(24, this.#width() - 4);
1271
+ const copies = [];
1272
+ const rows = renderMarkdown(text, {
1273
+ width,
1274
+ c: this.c,
1275
+ color: this.color,
1276
+ base: (item) => this.#paint('white', item),
1277
+ copy: this.ansi,
1278
+ onCodeBlock: (info) => copies.push(info),
1279
+ });
1280
+ this.#printMarkdownRows(rows, copies, (line, id) => this.#answerWrite(line ? `${line}\n` : '\n', id));
1281
+ }
1282
+
1283
+ /** Flush the trailing partial line; the answer is plain text and is never erased. */
1284
+ answerEnd() {
1285
+ const st = this.answer;
1286
+ if (!st.active) return;
1287
+ st.active = false;
1288
+ if (st.pending) {
1289
+ this.#answerRow(st.pending);
1290
+ st.pending = '';
1291
+ }
1292
+ if (st.block) {
1293
+ this.#renderBlock(st.block); // the stream ended inside a block: render it
1294
+ st.block = null;
1295
+ }
1296
+ }
1297
+
1298
+ /** True only when the streamed answer was actually drawn as a live box. */
1299
+ streamedResponse() {
1300
+ return Boolean(this.ansi && (this.answer.active || this.answer.text.length > 0));
1301
+ }
1302
+
1303
+ /**
1304
+ * The response arrived: one line of real telemetry about that model call —
1305
+ * latency, tokens, time to first token, and whether it was cut off.
1306
+ */
1307
+ thinkingEnd({ durationMs, firstTokenMs = null, usage = null, finishReason = null, truncated = false } = {}) {
1308
+ // Keep the activity line alive as `working` until the next phase (reasoning,
1309
+ // tools or the answer) takes over.
1310
+ if (this.status.active) this.#setPhase('working');
1311
+ if (durationMs === undefined) return;
1312
+ const prompt = Number.isFinite(usage?.prompt) ? usage.prompt : null;
1313
+ const completion = Number.isFinite(usage?.completion) ? usage.completion : null;
1314
+ if (prompt !== null) this.status.promptTokens = prompt;
1315
+ if (completion !== null) this.status.completionTokens = completion;
1316
+ if (this.plain) {
1317
+ const extra = firstTokenMs !== null && firstTokenMs !== undefined ? ` · first token ${firstTokenMs}ms` : '';
1318
+ this.#write(this.c.dim(` (model responded in ${durationMs}ms${extra})`));
1319
+ return;
1320
+ }
1321
+ const parts = [formatSeconds(durationMs)];
1322
+ if (prompt !== null || completion !== null) parts.push(`${formatTokens(prompt)}↑ ${formatTokens(completion)}↓ tok`);
1323
+ if (firstTokenMs !== null && firstTokenMs !== undefined) parts.push(`first token ${formatSeconds(firstTokenMs)}`);
1324
+ if (truncated || finishReason === 'length') parts.push('truncated at the token cap');
1325
+ this.#write(` ${this.c.dim('◇')} ${this.c.dim(`model ${parts.join(' · ')}`)}`);
1326
+ }
1327
+
1328
+ workingEnd({ durationMs } = {}) {
1329
+ // Tools finished; the next step switches the line back to `thinking`.
1330
+ if (this.plain && durationMs !== undefined) {
1331
+ this.#write(this.c.dim(` (model responded in ${durationMs}ms)`));
1332
+ }
1333
+ }
1334
+
1335
+ note({ text }) {
1336
+ if (!text) return;
1337
+ const clean = cleanModelTokens(text).trim().replace(/\s+/g, ' ');
1338
+ const clipped = clean.length > 240 ? `${clean.slice(0, 240)}…` : clean;
1339
+ if (this.plain) {
1340
+ this.#write(this.c.dim(` ${clipped}`));
1341
+ } else {
1342
+ this.#write(` ${this.c.dim('│')} ${this.#paint('white', clipped)}`);
1343
+ }
1344
+ }
1345
+
1346
+ toolStart({ name, args, index = 0, total = 0, detail = '' }) {
1347
+ if (this.reasoning.active) this.reasoningEnd({});
1348
+ if (this.answer.active) this.answerEnd();
1349
+ // Running a tool is exactly the `working` phase the user asked for.
1350
+ this.status.tool = name;
1351
+ this.status.toolIndex = index;
1352
+ this.status.toolTotal = total;
1353
+ this.status.extra = String(detail || '').slice(0, 120);
1354
+ this.#setPhase('working', this.status.extra);
1355
+ let argsPreview = '';
1356
+ try {
1357
+ argsPreview = JSON.stringify(args ?? {});
1358
+ } catch {
1359
+ argsPreview = '{}';
1360
+ }
1361
+ if (argsPreview.length > 160) argsPreview = `${argsPreview.slice(0, 160)}…`;
1362
+ if (this.plain) {
1363
+ this.#write(` ${this.#icon('→', '>')} ${name} ${this.c.dim(argsPreview)}`);
1364
+ return;
1365
+ }
1366
+ this.#write(
1367
+ ` ${this.c.dim('│')} ${this.#paint('violet', '◆')} ${this.c.bold(this.c.hex(PALETTE.blue)(name))} ${this.c.dim(this.#fit(argsPreview, this.#innerWidth() - 20))}`,
1368
+ );
1369
+ }
1370
+
1371
+ toolEnd({ name, ok, durationMs, summary, cached = false }) {
1372
+ this.status.tool = '';
1373
+ this.status.toolIndex = 0;
1374
+ this.status.toolTotal = 0;
1375
+ this.status.extra = '';
1376
+ // The command box below carries the output from here on; keeping the live
1377
+ // tail would print the same two lines twice.
1378
+ this.status.output = [];
1379
+ const duration = durationMs !== undefined && !cached ? `${durationMs}ms` : '';
1380
+ const detail = summary ? String(summary).replace(/\s+/g, ' ').slice(0, 220) : '';
1381
+ if (cached) {
1382
+ if (this.plain) {
1383
+ this.#write(this.c.dim(` [cached] ${name} · ${detail}`));
1384
+ return;
1385
+ }
1386
+ this.#write(` ${this.c.dim('│')} ${this.#paint('cyan', '↺')} ${this.c.hex(PALETTE.blue)(name)} ${this.c.dim(`· ${detail}`)}`);
1387
+ return;
1388
+ }
1389
+ if (this.plain) {
1390
+ const symbol = ok ? this.c.green('[ok]') : this.c.red('[fail]');
1391
+ this.#write(
1392
+ ` ${symbol} ${name}${duration ? this.c.dim(` ${duration}`) : ''}${detail ? this.c.dim(` · ${detail}`) : ''}`,
1393
+ );
1394
+ return;
1395
+ }
1396
+ const symbol = ok ? this.#paint('green', '✓') : this.#paint('red', '✕');
1397
+ const stats = [duration, detail].filter(Boolean).join(' · ');
1398
+ this.#write(
1399
+ ` ${this.c.dim('│')} ${symbol} ${this.c.hex(PALETTE.blue)(name)}${stats ? ` ${this.c.dim(`· ${stats}`)}` : ''}`,
1400
+ );
1401
+ }
1402
+
1403
+ /**
1404
+ * Compact multi-provider fallback report for one web search.
1405
+ *
1406
+ * │ ◇ WEB SEARCH
1407
+ * │ ├─ You.com ✕ timeout
1408
+ * │ ├─ Firecrawl ✓ berhasil
1409
+ * │ └─ Hasil pencarian diteruskan ke agent
1410
+ *
1411
+ * Only the final state is printed: one block per search, not one line per
1412
+ * attempt, so a slow provider cannot flood the terminal. Stack traces stay
1413
+ * in the debug log.
1414
+ */
1415
+ webSearchStatus(event = {}) {
1416
+ if (!event || event.phase !== 'done' || !Array.isArray(event.rows) || event.rows.length === 0) return;
1417
+ const rows = event.rows;
1418
+ const symbols = {
1419
+ ok: ['✓', '[ok]'],
1420
+ failed: ['✕', '[fail]'],
1421
+ empty: ['○', '[empty]'],
1422
+ skipped: ['!', '[skip]'],
1423
+ standby: ['○', '[wait]'],
1424
+ };
1425
+ const colors = { ok: 'green', failed: 'red', empty: 'amber', skipped: 'amber', standby: 'dim' };
1426
+ if (this.plain) {
1427
+ const parts = rows.map((row) => {
1428
+ const state = symbols[row.status]?.[1] || row.status;
1429
+ const ms = row.durationMs ? ` ${row.durationMs}ms` : '';
1430
+ return `${row.label}: ${state}${row.reason && row.status !== 'standby' ? ` (${row.reason})` : ''}${ms}`;
1431
+ });
1432
+ this.#write(this.c.dim(` [web] ${parts.join(' · ')}`));
1433
+ return;
1434
+ }
1435
+ const line = (row, last) => {
1436
+ const [symbol] = symbols[row.status] || ['○'];
1437
+ const paint = colors[row.status] === 'dim' ? this.c.dim : (text) => this.#paint(colors[row.status] || 'white', text);
1438
+ const reason = row.status === 'standby' ? '' : row.reason ? ` ${this.c.dim(row.reason)}` : '';
1439
+ const ms = row.durationMs ? this.c.dim(` ${row.durationMs}ms`) : '';
1440
+ return ` ${this.c.dim('│')} ${this.c.dim(last ? '└─' : '├─')} ${this.#paint('white', row.label.padEnd(12))} ${paint(symbol)}${reason}${ms}`;
1441
+ };
1442
+ this.#write(` ${this.c.dim('│')} ${this.#paint('violet', '◇')} ${this.c.bold(this.#paint('cyan', 'WEB SEARCH'))}`);
1443
+ rows.forEach((row, index) => this.#write(line(row, index === rows.length - 1)));
1444
+ const footer = event.ok
1445
+ ? event.provider
1446
+ ? 'Hasil pencarian diteruskan ke agent'
1447
+ : 'Tidak ada hasil; agent lanjut tanpa sumber web'
1448
+ : 'Tidak ada provider yang berhasil; agent dapat melanjutkan tanpa pencarian web';
1449
+ this.#write(` ${this.c.dim('│')} ${this.c.dim('└─')} ${this.c.dim(footer)}`);
1450
+ }
1451
+
1452
+ /**
1453
+ * Show the exact lines a write changed, unified-diff style, so the user can
1454
+ * audit the edit and cancel + re-task if it is wrong.
1455
+ *
1456
+ * • Edit src/agent/agent.js (+12 −3)
1457
+ * - tokenUsage: session.tokenUsage,
1458
+ * + metrics: {
1459
+ */
1460
+ fileDiff({ kind = 'edit', path: relPath = '', diff = '', added = 0, removed = 0, from = null, to = null } = {}) {
1461
+ const label = kind === 'create' ? 'Create' : kind === 'write' ? 'Write' : kind === 'delete' ? 'Delete' : kind === 'move' ? 'Move' : 'Edit';
1462
+ const target = kind === 'move' && from ? `${from} -> ${to}` : relPath;
1463
+ if (!target) return;
1464
+ const stats = kind === 'move' || kind === 'delete' ? '' : `${this.c.green(`+${added}`)} ${this.c.red(`−${removed}`)}`;
1465
+ const heading = ` ${this.#paint('violet', '•')} ${this.c.bold(label)} ${this.c.hex(PALETTE.blue)(this.#fit(target, this.#innerWidth() - 18))}`;
1466
+ if (this.plain) {
1467
+ this.#write(` • ${label} ${target}${stats ? ` (+${added} -${removed})` : ''}`);
1468
+ } else {
1469
+ this.#write(stats ? `${heading} ${this.c.dim('(')}${stats}${this.c.dim(')')}` : heading);
1470
+ }
1471
+ if (!diff) return;
1472
+
1473
+ const rows = String(diff).split('\n');
1474
+ const shown = [];
1475
+ let hidden = 0;
1476
+ for (const row of rows) {
1477
+ if (row.startsWith('---') || row.startsWith('+++')) continue;
1478
+ if (shown.length >= MAX_DIFF_LINES) {
1479
+ hidden += 1;
1480
+ continue;
1481
+ }
1482
+ shown.push(row);
1483
+ }
1484
+ const width = Math.max(20, this.#width() - 6);
1485
+ for (const row of shown) {
1486
+ const marker = row.startsWith('+') ? '+' : row.startsWith('-') ? '-' : row.startsWith('@@') ? '@' : ' ';
1487
+ const body = marker === '@' ? row : row.slice(1);
1488
+ const clipped = this.#fit(body, width);
1489
+ if (marker === '@') {
1490
+ this.#write(this.plain ? ` ${clipped}` : ` ${this.c.dim(clipped)}`);
1491
+ } else if (this.plain) {
1492
+ this.#write(` ${marker}${clipped}`);
1493
+ } else if (marker === '+') {
1494
+ this.#write(` ${this.c.green('+')}${this.c.green(clipped)}`);
1495
+ } else if (marker === '-') {
1496
+ this.#write(` ${this.c.red('-')}${this.c.red(clipped)}`);
1497
+ } else {
1498
+ this.#write(` ${this.c.dim(` ${clipped}`)}`);
1499
+ }
1500
+ }
1501
+ if (hidden > 0) {
1502
+ this.#write(this.c.dim(` … ${hidden} more diff line(s)`));
1503
+ }
1504
+ }
1505
+
1506
+ toolDenied({ name, reason }) {
1507
+ if (this.plain) {
1508
+ this.#write(` [denied] ${name} ${this.c.dim(`· ${reason}`)}`);
1509
+ return;
1510
+ }
1511
+ this.#write(
1512
+ ` ${this.c.dim('│')} ${this.#paint('amber', '⊘')} ${this.c.hex(PALETTE.blue)(name)} ${this.c.dim(`· ${reason}`)}`,
1513
+ );
1514
+ }
1515
+
1516
+ approvalRequest({ tool, risk, preview, reasons, critical }) {
1517
+ // Never leave the activity line running under an interactive y/N prompt.
1518
+ this.stopActivity();
1519
+ const tone = critical ? 'red' : 'amber';
1520
+ if (this.plain) {
1521
+ this.#write('');
1522
+ this.#write(`${critical ? '[!]' : '[approval]'} Approval required · ${tool} · risk: ${risk}${critical ? ' · CRITICAL' : ''}`);
1523
+ if (reasons?.length) {
1524
+ for (const reason of reasons) this.#write(this.c.dim(` - ${reason}`));
1525
+ }
1526
+ if (preview) {
1527
+ this.#write(this.c.dim(' ┌─ preview ' + '─'.repeat(40)));
1528
+ for (const line of String(preview).split('\n').slice(0, 60)) this.#write(` │ ${line}`);
1529
+ const total = String(preview).split('\n').length;
1530
+ if (total > 60) this.#write(this.c.dim(` │ … (${total - 60} more lines)`));
1531
+ this.#write(this.c.dim(` └${'─'.repeat(49)}`));
1532
+ }
1533
+ return;
1534
+ }
1535
+
1536
+ this.#write('');
1537
+ this.#write(this.#top(`${critical ? '▲' : '◆'} approval required`, `${tool} · risk: ${risk}`));
1538
+ this.#write(this.#row(`${this.#paint('cyan', 'gate'.padEnd(10))} ${critical ? this.#paint('red', 'CRITICAL') : 'interactive consent'}`));
1539
+ if (reasons?.length) {
1540
+ for (const reason of reasons) this.#write(this.#row(`${this.#paint('amber', '·')} ${reason}`));
1541
+ }
1542
+ if (preview) {
1543
+ this.#write(this.#mid('preview'));
1544
+ const lines = String(preview).split('\n');
1545
+ for (const line of lines.slice(0, 60)) this.#write(` ${this.#paint('cyan', '│')} ${line}`);
1546
+ if (lines.length > 60) this.#write(` ${this.#paint('cyan', '│')} ${this.c.dim(`… (${lines.length - 60} more lines)`)}`);
1547
+ }
1548
+ this.#write(this.#bottom());
1549
+ this.#write(` ${this.#paint(tone, '▸')} ${this.c.dim('Review the exact action above before approving.')}`);
1550
+ this.#write('');
1551
+ }
1552
+
1553
+ /**
1554
+ * Show what a command printed, as a closed block with a stable id:
1555
+ *
1556
+ * ╭─ ⌘ run_command #5 · exit 0 ─────────────────────╮
1557
+ * │ $ npm test │
1558
+ * ├─ output ────────────────────────────────────────┤
1559
+ * │ … the last 5 lines … │
1560
+ * ╰─────────────────────────────────────────────────╯
1561
+ * 4 more line(s) · click or /expand 5 · /logs p1
1562
+ *
1563
+ * The complete captured output stays in the block registry, so a click on
1564
+ * the block (or `/expand 5`) can show all of it at any later time.
1565
+ */
1566
+ commandOutput({ argv = [], stdout = '', stderr = '', exitCode = null, status = '', processId = null, timedOut = false, truncated = false } = {}) {
1567
+ const command = Array.isArray(argv) ? argv.join(' ') : String(argv ?? '');
1568
+ if (!command) return;
1569
+ const { lines, dropped } = commandOutputLines(stdout, stderr);
1570
+ const block = this.#createBlock({
1571
+ kind: 'command',
1572
+ command,
1573
+ lines,
1574
+ dropped,
1575
+ exitCode,
1576
+ status,
1577
+ processId,
1578
+ timedOut: Boolean(timedOut),
1579
+ truncated: Boolean(truncated),
1580
+ });
1581
+ if (this.plain) {
1582
+ this.#write(` $ ${command}`);
1583
+ const shown = lines.slice(-20);
1584
+ const hidden = lines.length - shown.length + dropped;
1585
+ for (const row of shown) this.#write(` ${row.stream === 'stderr' ? `! ${row.text}` : row.text}`);
1586
+ if (hidden > 0) {
1587
+ this.#write(this.c.dim(` … ${hidden} more line(s)${processId ? ` · /logs ${processId}` : ''}`));
1588
+ }
1589
+ return;
1590
+ }
1591
+ this.#drawCommandBlock(block);
1592
+ }
1593
+
1594
+ /** Rows of the closed command preview box. */
1595
+ #commandBoxLines(block) {
1596
+ const total = block.lines.length + block.dropped;
1597
+ const shown = block.lines.slice(-COMMAND_PREVIEW_LINES);
1598
+ const hidden = total - shown.length;
1599
+ const exit = block.exitCode === null || block.exitCode === undefined
1600
+ ? block.timedOut ? 'timed out' : block.status || ''
1601
+ : `exit ${block.exitCode}`;
1602
+ const inner = this.#innerWidth();
1603
+ const rows = [
1604
+ this.#top(`⌘ run_command #${block.id}${exit ? ` · ${exit}` : ''}`, '▸ closed'),
1605
+ this.#row(this.c.bold(this.c.hex(PALETTE.blue)(this.#fit(`$ ${block.command}`, inner)))),
1606
+ this.#mid('output'),
1607
+ ];
1608
+ if (shown.length === 0) {
1609
+ rows.push(this.#row(this.c.dim('(no output)')));
1610
+ } else {
1611
+ for (const row of shown) {
1612
+ const text = row.stream === 'stderr' ? `! ${row.text}` : row.text;
1613
+ rows.push(this.#row(row.stream === 'stderr' ? this.c.dim(this.c.red(text)) : this.c.dim(text)));
1614
+ }
1615
+ }
1616
+ rows.push(this.#bottom());
1617
+ const parts = [];
1618
+ if (hidden > 0) parts.push(`${hidden} more line(s)`);
1619
+ if (block.truncated) parts.push('capture truncated');
1620
+ parts.push(`click or /expand ${block.id}`);
1621
+ if (block.processId) parts.push(`/logs ${block.processId}`);
1622
+ rows.push(this.c.dim(` ${parts.join(' · ')}`));
1623
+ return rows;
1624
+ }
1625
+
1626
+ /** Print the command block and keep every row addressable for clicks. */
1627
+ #drawCommandBlock(block) {
1628
+ this.#printLines(this.#commandBoxLines(block), block.id);
1629
+ }
1630
+
1631
+ /**
1632
+ * Output of a shell command the user typed (`/bash`, `!cmd`). Unlike a model
1633
+ * tool call this is printed in full, not as a five-line preview: the user ran
1634
+ * it and already knows what it is.
1635
+ */
1636
+ shellResult({ command, stdout = '', stderr = '', exitCode = null, durationMs = 0, truncated = false, signal = null, error = null } = {}) {
1637
+ const { lines, dropped } = commandOutputLines(stdout, stderr);
1638
+ const rows = [''];
1639
+ if (this.plain) rows.push(`$ ${command}`);
1640
+ else rows.push(` ${this.#paint('blue', '$')} ${this.c.bold(this.c.hex(PALETTE.blue)(String(command ?? '')))}`);
1641
+ if (lines.length === 0) rows.push(' (no output)');
1642
+ for (const row of lines) {
1643
+ const text = row.stream === 'stderr' ? `! ${row.text}` : row.text;
1644
+ if (this.plain) rows.push(text);
1645
+ else rows.push(row.stream === 'stderr' ? this.c.dim(this.c.red(text)) : this.c.dim(text));
1646
+ }
1647
+ const exit = Number.isInteger(exitCode) ? `exit ${exitCode}` : signal ? `signal ${signal}` : 'exit ?';
1648
+ const bits = [exit, formatSeconds(durationMs)];
1649
+ if (dropped > 0) bits.push(`${dropped} line(s) dropped by the capture limit`);
1650
+ if (truncated) bits.push('capture truncated');
1651
+ if (error) bits.push(`error: ${error}`);
1652
+ rows.push(this.c.dim(` ${bits.join(' · ')}`));
1653
+ rows.push('');
1654
+ this.#printLines(rows);
1655
+ }
1656
+
1657
+ /** Print rows and keep them in the click map (used by expandable blocks). */
1658
+ #printLines(lines, blockId = null) {
1659
+ const prompt = this.#openScreen();
1660
+ for (const line of lines) {
1661
+ this.#trackScreen(`${line}\n`, blockId);
1662
+ this.stdout.write(`${line}\n`);
1663
+ }
1664
+ this.#paintAfterWrite();
1665
+ this.#closeScreen(prompt);
1666
+ }
1667
+
1668
+ // ── expandable blocks ─────────────────────────────────────────────────────
1669
+
1670
+ /**
1671
+ * The app hands the renderer a way to measure and repaint the live prompt.
1672
+ * The block panel is drawn directly above that prompt, so opening and
1673
+ * closing it never rewrites the transcript above.
1674
+ */
1675
+ setPromptController(controller) {
1676
+ this.promptController = controller || null;
1677
+ }
1678
+
1679
+ #createBlock(data) {
1680
+ const block = { id: this.nextBlockId, ...data };
1681
+ this.nextBlockId += 1;
1682
+ this.blocks.set(block.id, block);
1683
+ return block;
1684
+ }
1685
+
1686
+ hasBlock(id) {
1687
+ return this.blocks.has(Number(id));
1688
+ }
1689
+
1690
+ listBlocks() {
1691
+ return [...this.blocks.values()].sort((a, b) => a.id - b.id);
1692
+ }
1693
+
1694
+ /** Human-readable block index for `/expand` without an argument. */
1695
+ blockListText() {
1696
+ const blocks = this.listBlocks();
1697
+ if (blocks.length === 0) return ' No blocks yet. Reasoning and run_command output are listed here.';
1698
+ const rows = blocks.map((block) => {
1699
+ if (block.kind === 'reasoning') {
1700
+ const chars = block.chars ?? String(block.text || '').length;
1701
+ return ` #${String(block.id).padEnd(3)} reasoning ${chars} chars`;
1702
+ }
1703
+ return ` #${String(block.id).padEnd(3)} run_command ${block.command} · exit ${block.exitCode ?? '?'} · ${block.lines.length} line(s)`;
1704
+ });
1705
+ return [' Expandable blocks (click one, or /expand <id>):', ...rows].join('\n');
1706
+ }
1707
+
1708
+ isBlockViewOpen() {
1709
+ return Boolean(this.blockView);
1710
+ }
1711
+
1712
+ #promptState() {
1713
+ const state = this.promptController?.metrics?.();
1714
+ return state?.active ? state : null;
1715
+ }
1716
+
1717
+ /** Complete content of a block, wrapped for the panel. */
1718
+ #blockContent(block) {
1719
+ const inner = this.#innerWidth();
1720
+ if (block.kind === 'reasoning') {
1721
+ const text = cleanReasoning(block.text || '');
1722
+ const rows = wrapText(text, inner).map((line) => ({ text: line, tone: 'muted' }));
1723
+ return {
1724
+ title: `▾ ◈ reasoning #${block.id}`,
1725
+ meta: `${block.chars ?? [...text].length} chars · open`,
1726
+ rows: rows.length > 0 ? rows : [{ text: '', tone: 'muted' }],
1727
+ };
1728
+ }
1729
+ const rows = [];
1730
+ for (const line of wrapText(`$ ${block.command}`, inner)) rows.push({ text: line, tone: 'command' });
1731
+ rows.push({ text: '', tone: 'muted' });
1732
+ if (block.lines.length === 0) rows.push({ text: '(no output)', tone: 'muted' });
1733
+ for (const row of block.lines) {
1734
+ const prefix = row.stream === 'stderr' ? '! ' : '';
1735
+ for (const line of wrapText(`${prefix}${row.text}`, inner)) {
1736
+ rows.push({ text: line, tone: row.stream === 'stderr' ? 'stderr' : 'muted' });
1737
+ }
1738
+ }
1739
+ if (block.dropped > 0) {
1740
+ rows.push({ text: `… ${block.dropped} line(s) dropped by the capture limit`, tone: 'muted' });
1741
+ }
1742
+ if (block.truncated) rows.push({ text: 'capture truncated', tone: 'muted' });
1743
+ const exit = block.exitCode === null || block.exitCode === undefined
1744
+ ? block.timedOut ? 'timed out' : block.status || 'no exit'
1745
+ : `exit ${block.exitCode}`;
1746
+ return {
1747
+ title: `▾ ⌘ run_command #${block.id}`,
1748
+ meta: `${block.lines.length} line(s) · ${exit} · open`,
1749
+ rows,
1750
+ };
1751
+ }
1752
+
1753
+ /** Rows of the open panel, bounded to the terminal so it can always close. */
1754
+ #panelLines() {
1755
+ const view = this.blockView;
1756
+ const block = view ? this.blocks.get(view.id) : null;
1757
+ if (!view || !block) return [];
1758
+ const { title, meta, rows } = this.#blockContent(block);
1759
+ const prompt = this.#promptState();
1760
+ const promptRows = prompt?.rows || 0;
1761
+ const maxBody = Math.max(1, this.#terminalRows() - promptRows - 4);
1762
+ view.body = Math.max(1, Math.min(maxBody, rows.length));
1763
+ view.maxScroll = Math.max(0, rows.length - view.body);
1764
+ view.scroll = Math.min(Math.max(0, view.scroll), view.maxScroll);
1765
+ const visible = rows.slice(view.scroll, view.scroll + view.body);
1766
+ const lines = [this.#top(title, meta)];
1767
+ for (const row of visible) {
1768
+ const paint = row.tone === 'stderr'
1769
+ ? (text) => this.c.dim(this.c.red(text))
1770
+ : row.tone === 'command'
1771
+ ? (text) => this.c.bold(this.c.hex(PALETTE.blue)(text))
1772
+ : (text) => this.c.dim(text);
1773
+ lines.push(this.#row(paint(row.text)));
1774
+ }
1775
+ lines.push(this.#bottom());
1776
+ const from = rows.length > 0 ? view.scroll + 1 : 0;
1777
+ const to = view.scroll + visible.length;
1778
+ const scrollHint = view.maxScroll > 0 ? ' · ↑↓/PgUp/PgDn/wheel' : '';
1779
+ lines.push(this.c.dim(` ${from}-${to} of ${rows.length} line(s)${scrollHint} · Esc or click to close`));
1780
+ return lines;
1781
+ }
1782
+
1783
+ #drawBlockView() {
1784
+ const view = this.blockView;
1785
+ if (!view) return;
1786
+ const prompt = this.#promptState();
1787
+ const up = view.rows + (prompt ? prompt.cursorRow : 0);
1788
+ this.stdout.write(up > 0 ? `\x1b[${up}A\r` : '\r');
1789
+ const lines = this.#panelLines();
1790
+ for (const line of lines) this.stdout.write(`\x1b[2K${line}\n`);
1791
+ view.rows = lines.length;
1792
+ if (prompt) {
1793
+ this.promptController?.resetCursor?.();
1794
+ this.promptController?.repaint?.();
1795
+ }
1796
+ }
1797
+
1798
+ openBlockView(id) {
1799
+ const key = Number(id);
1800
+ const block = this.blocks.get(key);
1801
+ if (!block) return false;
1802
+ if (this.plain || !this.ansi) {
1803
+ this.#printBlockPlain(block);
1804
+ return true;
1805
+ }
1806
+ if (this.liveLines > 0) this.#clearLive();
1807
+ if (this.blockView) {
1808
+ if (this.blockView.id === key) return true;
1809
+ this.#eraseBlockView();
1810
+ }
1811
+ this.blockView = { id: key, scroll: 0, rows: 0, body: 0, maxScroll: 0 };
1812
+ this.#drawBlockView();
1813
+ return true;
1814
+ }
1815
+
1816
+ /** Click/command toggle; returns whether the block exists and is now open. */
1817
+ toggleBlock(id) {
1818
+ const key = Number(id);
1819
+ if (!this.blocks.has(key)) return { exists: false, open: false };
1820
+ if (this.blockView?.id === key) {
1821
+ this.closeBlockView();
1822
+ return { exists: true, open: false };
1823
+ }
1824
+ this.openBlockView(key);
1825
+ return { exists: true, open: Boolean(this.blockView) };
1826
+ }
1827
+
1828
+ closeBlockView() {
1829
+ if (!this.blockView) return false;
1830
+ this.#eraseBlockView();
1831
+ return true;
1832
+ }
1833
+
1834
+ #eraseBlockView() {
1835
+ const view = this.blockView;
1836
+ if (!view) return;
1837
+ this.blockView = null;
1838
+ if (view.rows <= 0) return;
1839
+ const prompt = this.#promptState();
1840
+ const up = view.rows + (prompt ? prompt.cursorRow : 0);
1841
+ if (up > 0) this.stdout.write(`\x1b[${up}A\r`);
1842
+ if (prompt) {
1843
+ this.promptController?.resetCursor?.();
1844
+ this.promptController?.repaint?.();
1845
+ } else this.stdout.write('\x1b[0J');
1846
+ }
1847
+
1848
+ scrollBlockView(delta) {
1849
+ const view = this.blockView;
1850
+ if (!view) return false;
1851
+ const next = Math.max(0, Math.min(view.scroll + Number(delta || 0), view.maxScroll || 0));
1852
+ if (next === view.scroll) return true;
1853
+ view.scroll = next;
1854
+ this.#drawBlockView();
1855
+ return true;
1856
+ }
1857
+
1858
+ handleBlockViewKey(key) {
1859
+ const view = this.blockView;
1860
+ if (!view) return false;
1861
+ const body = Math.max(1, view.body || 1);
1862
+ switch (key) {
1863
+ case 'close':
1864
+ return this.closeBlockView();
1865
+ case 'up':
1866
+ return this.scrollBlockView(-1);
1867
+ case 'down':
1868
+ return this.scrollBlockView(1);
1869
+ case 'pageup':
1870
+ return this.scrollBlockView(-body);
1871
+ case 'pagedown':
1872
+ return this.scrollBlockView(body);
1873
+ case 'home':
1874
+ view.scroll = 0;
1875
+ this.#drawBlockView();
1876
+ return true;
1877
+ case 'end':
1878
+ view.scroll = view.maxScroll || 0;
1879
+ this.#drawBlockView();
1880
+ return true;
1881
+ default:
1882
+ return false;
1883
+ }
1884
+ }
1885
+
1886
+ /**
1887
+ * Open/close the block under a mouse click. Only rows that are still in the
1888
+ * transcript can be clicked; `/expand <id>` covers the ones scrolled away.
1889
+ */
1890
+ handleMouse(event = {}) {
1891
+ if (!this.ansi || this.plain || !event || this.promptParked) return false;
1892
+ const prompt = this.#promptState();
1893
+ if (!prompt) return false;
1894
+ if (event.wheel) {
1895
+ if (!this.blockView) return false;
1896
+ return this.scrollBlockView(event.wheel === 'up' ? -3 : 3);
1897
+ }
1898
+ if (event.button !== 0 || event.release) return false;
1899
+
1900
+ const transcriptRows = this.#screenRowCount();
1901
+ const panelRows = this.blockView?.rows || 0;
1902
+ const total = transcriptRows + panelRows + prompt.rows;
1903
+ const viewportTop = Math.max(0, total - this.#terminalRows());
1904
+ const absolute = viewportTop + Math.max(0, Number(event.y) - 1);
1905
+
1906
+ if (this.blockView && absolute >= transcriptRows && absolute < transcriptRows + panelRows) {
1907
+ this.closeBlockView();
1908
+ return true;
1909
+ }
1910
+ if (absolute < 0 || absolute >= transcriptRows) return false;
1911
+ const id = this.#blockAtRow(absolute);
1912
+ if (!id) return false;
1913
+ const copy = this.copies.get(id);
1914
+ if (copy) {
1915
+ const result = copyToClipboard(copy.code, { output: this.stdout });
1916
+ if (result.ok) {
1917
+ this.info(`Copied ${result.chars} chars of ${copy.lang ? `${copy.lang} ` : ''}code to the clipboard (OSC 52).`);
1918
+ } else {
1919
+ this.warn('Clipboard copy needs an interactive terminal that supports OSC 52.');
1920
+ }
1921
+ return true;
1922
+ }
1923
+ if (this.blockView?.id === id) {
1924
+ this.closeBlockView();
1925
+ return true;
1926
+ }
1927
+ this.openBlockView(id);
1928
+ return true;
1929
+ }
1930
+
1931
+ #printBlockPlain(block) {
1932
+ this.#write('');
1933
+ this.#write(` #${block.id} ${block.kind === 'reasoning' ? 'reasoning' : `run_command · ${block.command}`}`);
1934
+ if (block.kind === 'reasoning') {
1935
+ for (const line of String(block.text || '').split('\n')) this.#write(this.c.dim(` ${line}`));
1936
+ return;
1937
+ }
1938
+ for (const row of block.lines) this.#write(` ${row.stream === 'stderr' ? `! ${row.text}` : row.text}`);
1939
+ }
1940
+
1941
+ toolResult() {
1942
+ // The activity feed already shows the summary; full output is sent to the model.
1943
+ }
1944
+
1945
+ warning({ message }) {
1946
+ this.warn(message);
1947
+ }
1948
+
1949
+ error({ error }) {
1950
+ if (this.reasoning.active) this.reasoningEnd({ partial: true });
1951
+ if (this.answer.active) this.answerEnd();
1952
+ this.stopActivity();
1953
+ if (this.plain) {
1954
+ this.#write('');
1955
+ this.#write(this.c.red(`[error] ${error?.message || 'Unknown error'}`));
1956
+ if (error?.code) this.#write(this.c.dim(` code: ${error.code}${error.status ? ` · HTTP ${error.status}` : ''}`));
1957
+ const plainHint = ERROR_HINTS[error?.code];
1958
+ if (plainHint) this.#write(this.c.yellow(` hint: ${plainHint}`));
1959
+ if (this.debug && error?.stack) this.#write(this.c.dim(error.stack));
1960
+ return;
1961
+ }
1962
+
1963
+ this.#write('');
1964
+ this.#write(this.#top('✕ runtime fault', error?.code || 'ERROR'));
1965
+ this.#write(this.#row(this.#paint('red', this.c.bold(this.#fit(error?.message || 'Unknown error', this.#innerWidth())))));
1966
+ if (error?.code) {
1967
+ this.#write(this.#row(`${this.#paint('cyan', 'code'.padEnd(10))} ${error.code}${error.status ? ` · HTTP ${error.status}` : ''}`));
1968
+ }
1969
+ const hint = ERROR_HINTS[error?.code];
1970
+ if (hint) this.#write(this.#row(`${this.#paint('amber', 'hint'.padEnd(10))} ${hint}`));
1971
+ this.#write(this.#bottom());
1972
+ if (this.debug && error?.stack) {
1973
+ this.#write(this.c.dim(error.stack));
1974
+ }
1975
+ }
1976
+
1977
+ #finalPlain(summary) {
1978
+ const style = STATUS_STYLE[summary.status] || { label: summary.status, color: 'white' };
1979
+ const c = this.c;
1980
+ this.#write('');
1981
+ this.#write(c.cyan(`══ summary ${'═'.repeat(45)}`));
1982
+ if (summary.finalText) {
1983
+ this.#write('');
1984
+ this.#markdown(summary.finalText, { base: (text) => this.#paint('white', text) });
1985
+ }
1986
+ this.#write('');
1987
+ this.#write(
1988
+ `${c.bold('status')} ${c[style.color](style.label)}${c.dim(` · ${summary.steps} step(s) · ${(summary.durationMs / 1000).toFixed(1)}s`)}`,
1989
+ );
1990
+
1991
+ this.#write(`${c.bold('changed files')} ${summary.changedFiles.length > 0 ? '' : c.dim('none')}`);
1992
+ for (const file of summary.changedFiles.slice(0, 30)) this.#write(c.green(` + ${file}`));
1993
+ if (summary.changedFiles.length > 30) this.#write(c.dim(` … ${summary.changedFiles.length - 30} more`));
1994
+
1995
+ if (summary.commands.length > 0) {
1996
+ this.#write(c.bold('commands'));
1997
+ for (const cmd of summary.commands.slice(-15)) {
1998
+ const argv = Array.isArray(cmd.argv) ? cmd.argv.join(' ') : String(cmd.argv);
1999
+ const ok = cmd.exitCode === 0;
2000
+ this.#write(` ${ok ? c.green('✔') : c.red('✖')} ${c.hex(PALETTE.blue)(argv)} ${c.dim(`· exit ${cmd.exitCode ?? '?'} · ${cmd.durationMs ?? '?'}ms${cmd.timedOut ? ' · timed out' : ''}`)}`);
2001
+ }
2002
+ }
2003
+
2004
+ if (summary.tests.length > 0) {
2005
+ const passed = summary.tests.filter((test) => test.passed).length;
2006
+ this.#write(`${c.bold('tests')} ${passed}/${summary.tests.length} passed ${c.dim('(inferred from command exit codes)')}`);
2007
+ } else {
2008
+ this.#write(`${c.bold('tests')} ${c.dim('none run')}`);
2009
+ }
2010
+
2011
+ if (summary.runningProcesses?.length) {
2012
+ this.#write(c.bold('running processes'));
2013
+ for (const proc of summary.runningProcesses) this.#write(` ${c.cyan('●')} ${proc.id} ${proc.label ? c.dim(proc.label) : ''}`);
2014
+ }
2015
+
2016
+ if (summary.issues.length > 0) {
2017
+ this.#write(c.yellow(`${c.bold('issues')} (${summary.issues.length})`));
2018
+ for (const issue of summary.issues.slice(-10)) this.#write(c.yellow(` - ${issue}`));
2019
+ } else {
2020
+ this.#write(`${c.bold('issues')} ${c.dim('none recorded')}`);
2021
+ }
2022
+
2023
+ this.#write(
2024
+ c.dim(
2025
+ `tokens ${summary.tokenUsage.prompt} prompt / ${summary.tokenUsage.completion} completion · approvals ${summary.approvals.approved} approved / ${summary.approvals.denied} denied` +
2026
+ (summary.undoCount ? ` · /undo available (${summary.undoCount})` : ''),
2027
+ ),
2028
+ );
2029
+ if (summary.metrics) {
2030
+ const m = summary.metrics;
2031
+ this.#write(
2032
+ c.dim(
2033
+ `model ${m.modelCalls ?? 0} call(s) · avg ${formatSeconds(m.avgRequestMs ?? 0)}` +
2034
+ (m.firstTokenMs !== null && m.firstTokenMs !== undefined ? ` · first token ${formatSeconds(m.firstTokenMs)}` : '') +
2035
+ (m.cachedReads ? ` · ${m.cachedReads} read(s) reused` : ''),
2036
+ ),
2037
+ );
2038
+ }
2039
+ if (summary.logFile) this.#write(c.dim(`log ${summary.logFile}`));
2040
+ this.#write('');
2041
+ }
2042
+
2043
+ final(summary) {
2044
+ if (this.reasoning.active) this.reasoningEnd({ partial: summary.status !== 'completed' });
2045
+ this.stopActivity();
2046
+ // The streamed answer already printed as plain text; never print it twice.
2047
+ const streamed = this.streamedResponse();
2048
+ if (this.answer.active) this.answerEnd();
2049
+ if (this.plain) {
2050
+ this.#finalPlain(summary);
2051
+ return;
2052
+ }
2053
+
2054
+ const style = STATUS_STYLE[summary.status] || { label: summary.status, color: 'white' };
2055
+ const c = this.c;
2056
+ const seconds = formatSeconds(summary.durationMs);
2057
+ const totalTokens = formatTokens(summary.tokenUsage.total);
2058
+ const approved = summary.approvals?.approved ?? 0;
2059
+ const denied = summary.approvals?.denied ?? 0;
2060
+
2061
+ this.#write('');
2062
+ this.#write(this.#top('◈ summary // run complete', `${style.label} · ${seconds}`));
2063
+ this.#write(
2064
+ this.#row(
2065
+ `${this.#paint(style.color, '●')} ${c.bold(style.label.toUpperCase())} ` +
2066
+ `${c.dim('steps')} ${summary.steps} ${c.dim('tokens')} ${totalTokens} ` +
2067
+ `${c.dim('approvals')} ${approved}/${denied}`,
2068
+ ),
2069
+ );
2070
+ if (summary.metrics) {
2071
+ const m = summary.metrics;
2072
+ const parts = [`${m.modelCalls ?? 0} model call(s)`, `avg ${formatSeconds(m.avgRequestMs ?? 0)}`];
2073
+ if (m.firstTokenMs !== null && m.firstTokenMs !== undefined) parts.push(`first token ${formatSeconds(m.firstTokenMs)}`);
2074
+ if (m.cachedReads) parts.push(`${m.cachedReads} read(s) reused from the worklog`);
2075
+ this.#write(this.#row(c.dim(parts.join(' · '))));
2076
+ }
2077
+ this.#write(this.#bottom());
2078
+
2079
+ if (summary.finalText && !streamed) {
2080
+ this.#write('');
2081
+ this.#write(` ${this.#paint('violet', '◆')} ${this.c.bold(this.#paint('white', 'RESPONSE'))}`);
2082
+ this.#write('');
2083
+ this.#markdown(summary.finalText, { base: (text) => this.#paint('white', text) });
2084
+ }
2085
+
2086
+ this.#write('');
2087
+ this.#write(this.#top('◈ evidence'));
2088
+ if (summary.changedFiles.length > 0) {
2089
+ this.#write(this.#row(`${this.#paint('green', '▲ changes')} ${c.dim(`(${summary.changedFiles.length})`)}`));
2090
+ for (const file of summary.changedFiles.slice(0, 20)) {
2091
+ this.#write(this.#row(` ${this.#paint('green', '+')} ${file}`));
2092
+ }
2093
+ if (summary.changedFiles.length > 20) {
2094
+ this.#write(this.#row(c.dim(` … ${summary.changedFiles.length - 20} more`)));
2095
+ }
2096
+ } else {
2097
+ this.#write(this.#row(`${c.dim('changes')} ${c.dim('none')}`));
2098
+ }
2099
+
2100
+ if (summary.commands.length > 0) {
2101
+ this.#write(this.#mid(`commands (${summary.commands.length})`));
2102
+ for (const cmd of summary.commands.slice(-12)) {
2103
+ const argv = Array.isArray(cmd.argv) ? cmd.argv.join(' ') : String(cmd.argv);
2104
+ const ok = cmd.exitCode === 0;
2105
+ this.#write(
2106
+ this.#row( `${ok ? this.#paint('green', '✓') : this.#paint('red', '✕')} ${c.hex(PALETTE.blue)(argv)} ` +
2107
+ `${c.dim(`· exit ${cmd.exitCode ?? '?'} · ${cmd.durationMs ?? '?'}ms${cmd.timedOut ? ' · timed out' : ''}`)}`,
2108
+ ),
2109
+ );
2110
+ }
2111
+ }
2112
+
2113
+ if (summary.tests.length > 0) {
2114
+ const passed = summary.tests.filter((test) => test.passed).length;
2115
+ this.#write(this.#row(`${this.#paint(passed === summary.tests.length ? 'green' : 'amber', 'tests')} ${passed}/${summary.tests.length} passed ${c.dim('(inferred)')}`));
2116
+ } else {
2117
+ this.#write(this.#row(`${c.dim('tests')} ${c.dim('none run')}`));
2118
+ }
2119
+
2120
+ if (summary.runningProcesses?.length) {
2121
+ for (const proc of summary.runningProcesses) {
2122
+ this.#write(this.#row(`${this.#paint('cyan', '●')} ${proc.id} ${c.dim(proc.label || '')}`));
2123
+ }
2124
+ }
2125
+
2126
+ if (summary.issues.length > 0) {
2127
+ this.#write(this.#mid(`issues (${summary.issues.length})`));
2128
+ for (const issue of summary.issues.slice(-8)) {
2129
+ this.#write(this.#row(`${this.#paint('amber', '▲')} ${issue}`));
2130
+ }
2131
+ } else {
2132
+ this.#write(this.#row(`${c.dim('issues')} ${c.dim('none recorded')}`));
2133
+ }
2134
+ this.#write(this.#bottom());
2135
+
2136
+ this.#write(
2137
+ c.dim(
2138
+ ` tokens ${summary.tokenUsage.prompt} prompt / ${summary.tokenUsage.completion} completion` +
2139
+ (summary.undoCount ? ` · /undo available (${summary.undoCount})` : '') +
2140
+ (summary.logFile ? ` · log ${summary.logFile}` : ''),
2141
+ ),
2142
+ );
2143
+ this.#write('');
2144
+ }
2145
+
2146
+ goodbye() {
2147
+ if (this.plain) {
2148
+ this.#write(this.c.dim('Goodbye.'));
2149
+ return;
2150
+ }
2151
+ this.#write('');
2152
+ this.#write(` ${this.#grad('◈')} ${this.c.dim('session closed · see you in the next run')}`);
2153
+ this.#write('');
2154
+ }
2155
+ }