@semalt-ai/code 1.19.0 → 1.20.1

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 (83) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/ARCHITECTURE.md +6 -95
  3. package/CLAUDE.md +196 -1874
  4. package/README.md +1 -1
  5. package/docs/ARCHITECTURE.md +1321 -0
  6. package/docs/CONFIG.md +340 -0
  7. package/docs/HISTORY.md +245 -0
  8. package/index.js +1 -1
  9. package/lib/agent.js +145 -16
  10. package/lib/api.js +28 -3
  11. package/lib/commands/chat-session.js +188 -4
  12. package/lib/commands/chat-slash.js +16 -0
  13. package/lib/commands/chat-turn.js +319 -52
  14. package/lib/commands/chat.js +12 -8
  15. package/lib/config.js +27 -0
  16. package/lib/constants.js +30 -1
  17. package/lib/headless.js +36 -1
  18. package/lib/images.js +8 -2
  19. package/lib/permissions.js +23 -16
  20. package/lib/prompts.js +15 -3
  21. package/lib/tool_registry.js +357 -53
  22. package/lib/tool_specs.js +42 -8
  23. package/lib/tools.js +80 -19
  24. package/lib/ui/anim.js +86 -0
  25. package/lib/ui/ansi.js +17 -27
  26. package/lib/ui/chat-history.js +253 -71
  27. package/lib/ui/create-ui.js +67 -24
  28. package/lib/ui/diff.js +90 -25
  29. package/lib/ui/file-activity.js +229 -0
  30. package/lib/ui/format.js +173 -28
  31. package/lib/ui/input-field.js +5 -4
  32. package/lib/ui/md-stream.js +234 -0
  33. package/lib/ui/render-operation.js +113 -0
  34. package/lib/ui/select.js +1 -4
  35. package/lib/ui/status-bar.js +99 -57
  36. package/lib/ui/stream.js +20 -13
  37. package/lib/ui/theme.js +190 -45
  38. package/lib/ui/tool-operation.js +190 -0
  39. package/lib/ui/utils.js +9 -5
  40. package/lib/ui/web-activity.js +58 -6
  41. package/lib/ui/writer.js +159 -45
  42. package/lib/ui.js +1 -1
  43. package/package.json +1 -1
  44. package/test/anim-driver.test.js +153 -0
  45. package/test/ask-user-display.test.js +226 -0
  46. package/test/ask-user-gate.test.js +231 -0
  47. package/test/chat-history-nocolor.test.js +155 -0
  48. package/test/chat-relogin.test.js +207 -0
  49. package/test/defer-detail-band.test.js +403 -0
  50. package/test/detail-band-tab-flatten.test.js +242 -0
  51. package/test/exec-diff.test.js +268 -0
  52. package/test/executors.test.js +250 -13
  53. package/test/extract-tool-calls.test.js +37 -3
  54. package/test/file-activity.test.js +542 -0
  55. package/test/grep-path-target.test.js +227 -0
  56. package/test/harness/chat-harness.js +2 -1
  57. package/test/headless.test.js +146 -1
  58. package/test/input-field-ctrl-o.test.js +37 -0
  59. package/test/live-height-physical.test.js +281 -0
  60. package/test/max-iterations.test.js +9 -7
  61. package/test/md-stream.test.js +183 -0
  62. package/test/narration-ordering.test.js +309 -0
  63. package/test/native-dispatch.test.js +53 -0
  64. package/test/native-live-narration.test.js +254 -0
  65. package/test/output-heredoc-leak.test.js +195 -0
  66. package/test/output-preview.test.js +245 -0
  67. package/test/permission-flush.test.js +302 -0
  68. package/test/permissions.test.js +199 -0
  69. package/test/read-paginate.test.js +1 -1
  70. package/test/render-operation.test.js +317 -0
  71. package/test/replay-descriptor-xml.test.js +216 -0
  72. package/test/replay-descriptor.test.js +189 -0
  73. package/test/replay-web-aggregate.test.js +291 -0
  74. package/test/replay-web-persist.test.js +241 -0
  75. package/test/running-glyph-anim.test.js +111 -0
  76. package/test/status-bar-driver.test.js +93 -0
  77. package/test/status-bar-resync.test.js +188 -0
  78. package/test/stream-parser.test.js +24 -0
  79. package/test/theme-palette.test.js +166 -0
  80. package/test/truncate-visible.test.js +78 -0
  81. package/test/view-image.test.js +199 -0
  82. package/test/web-activity-ordering.test.js +12 -3
  83. package/path +0 -1
package/lib/ui/format.js CHANGED
@@ -16,8 +16,17 @@
16
16
  // duration formatDuration(ms), pending lines trail with "…"
17
17
  // meta type-specific tail — exit codes, byte counts, match counts, …
18
18
 
19
- const { RST, DIM } = require('./ansi');
20
- const { UI_THEME, UI_ICONS, TOOL_CATEGORIES } = require('./theme');
19
+ const { RST, DIM, SPINNER_DEFS } = require('./ansi');
20
+ const { UI_ICONS, categoryForTag, resolveLineColors, colorEnabled } = require('./theme');
21
+ const { truncateVisible, stripAnsi } = require('./utils');
22
+
23
+ // Per-frame cadence for the animated running glyph. Matches the single
24
+ // animation driver's base interval (lib/ui/anim.js BASE_INTERVAL_MS) so that,
25
+ // as the driver repaints a running op every ~100 ms with a fresh elapsed time,
26
+ // the derived spinner frame advances by one. Deriving the frame from the
27
+ // elapsed duration (rather than a shared counter) keeps formatToolLine a pure
28
+ // input→string function — no animation state to thread.
29
+ const RUNNING_GLYPH_INTERVAL_MS = 100;
21
30
 
22
31
  // Adaptive precision. ms < 1s shows "Nms", under a minute shows "N.Ns"
23
32
  // (sub-10s) or "Ns", under an hour shows "MmSs", above uses "HhMm". Never
@@ -90,8 +99,8 @@ function _normalizeTag(tag) {
90
99
  // operation columns; category names of 5 chars ("shell") fit exactly.
91
100
  const CATEGORY_WIDTH = 5;
92
101
 
93
- function _categoryLabel(tag) {
94
- const cat = TOOL_CATEGORIES[_normalizeTag(tag)] || 'tool';
102
+ function _categoryLabel(category) {
103
+ const cat = category || 'tool';
95
104
  return cat.length >= CATEGORY_WIDTH ? cat.slice(0, CATEGORY_WIDTH) : cat.padEnd(CATEGORY_WIDTH);
96
105
  }
97
106
 
@@ -220,10 +229,55 @@ function _metaParts(tag, meta, error) {
220
229
  if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
221
230
  if (meta && meta.kind) out.push(meta.kind);
222
231
  break;
232
+ case 'ask_user':
233
+ // The user's chosen answer, surfaced in scrollback (it was previously only
234
+ // sent to the model). Truncated so a long option keeps the result line on
235
+ // one physical row.
236
+ if (meta && meta.answer != null && String(meta.answer) !== '') {
237
+ out.push(`→ ${_truncate(String(meta.answer), 40)}`);
238
+ }
239
+ break;
223
240
  }
224
241
  return out;
225
242
  }
226
243
 
244
+ // Word-wrap `text` to `cols` columns and clamp to `maxLines`, appending a
245
+ // "… N more lines" tail when truncated. Mirrors the permission picker's
246
+ // MAX_DESC_LINES handling (permissions.js) so a long ask_user question can't
247
+ // overflow the modal band. Pure: returns an array of plain (unstyled) lines;
248
+ // the caller applies any colour. An empty/whitespace `text` returns [] so the
249
+ // caller can skip rendering a header entirely.
250
+ function wrapPromptLines(text, opts) {
251
+ const o = opts || {};
252
+ const cols = (Number.isInteger(o.cols) && o.cols > 0) ? o.cols : 80;
253
+ const maxLines = (Number.isInteger(o.maxLines) && o.maxLines > 0) ? o.maxLines : 12;
254
+ const src = String(text == null ? '' : text);
255
+ if (!src.trim()) return [];
256
+ const wrapped = [];
257
+ for (const para of src.split('\n')) {
258
+ if (para.trim() === '') { wrapped.push(''); continue; }
259
+ let line = '';
260
+ for (const word of para.split(/\s+/).filter(Boolean)) {
261
+ if (line && (line.length + 1 + word.length) > cols) {
262
+ wrapped.push(line);
263
+ line = '';
264
+ }
265
+ if (!line && word.length > cols) {
266
+ // Hard-break a single token longer than the column budget.
267
+ let w = word;
268
+ while (w.length > cols) { wrapped.push(w.slice(0, cols)); w = w.slice(cols); }
269
+ line = w;
270
+ } else {
271
+ line = line ? `${line} ${word}` : word;
272
+ }
273
+ }
274
+ if (line) wrapped.push(line);
275
+ }
276
+ if (wrapped.length <= maxLines) return wrapped;
277
+ const hidden = wrapped.length - maxLines;
278
+ return wrapped.slice(0, maxLines).concat([`… ${hidden} more ${hidden === 1 ? 'line' : 'lines'}`]);
279
+ }
280
+
227
281
  // Build the full styled 4-segment tool line. `status` is one of
228
282
  // 'pending' | 'success' | 'failure'. For 'failure', caller may pass an
229
283
  // Error-shaped `error` and/or partial `meta` — we format both when
@@ -238,44 +292,55 @@ function formatToolLine(args) {
238
292
  meta,
239
293
  error,
240
294
  noDuration,
295
+ category,
241
296
  } = args || {};
242
297
 
298
+ // Colour is keyed by the descriptor's {category, status} via the single
299
+ // resolver in theme.js — not re-derived here. Callers going through the
300
+ // descriptor (render-operation.js) pass `category`; direct callers let us
301
+ // resolve it from the tag. The glyph CHARACTER stays here (status → '●/✓/✗');
302
+ // only its colour comes from the resolver.
303
+ const cat = category || categoryForTag(tag);
304
+ const colors = resolveLineColors(cat, error ? 'error' : status);
305
+ const enabled = colorEnabled();
306
+ const R = enabled ? RST : '';
307
+
243
308
  let glyph;
244
- let glyphColor;
245
309
  if (status === 'pending') {
246
- glyph = UI_ICONS.pending;
247
- glyphColor = UI_THEME.muted;
248
- } else if (status === 'failure' || error) {
249
- glyph = UI_ICONS.error;
250
- glyphColor = UI_THEME.error;
251
- } else {
252
- glyph = UI_ICONS.success;
253
- glyphColor = UI_THEME.success;
254
- }
255
-
256
- const cat = _categoryLabel(tag);
257
- const catColor = (UI_THEME.categories && UI_THEME.categories[_normalizeTag(tag) && (TOOL_CATEGORIES[_normalizeTag(tag)] || 'tool')]) || UI_THEME.accent;
310
+ if (noDuration) {
311
+ // Blocking tools (ask_user) render once and freeze — a ticking spinner
312
+ // would falsely imply work is happening. Keep the static pending dot.
313
+ glyph = UI_ICONS.pending;
314
+ } else {
315
+ // Running op: animate the spinner frames (the `tool` SPINNER_DEF) in the
316
+ // category-tinted pending colour (colors.glyph). The frame is derived
317
+ // from the elapsed duration so it advances each time the driver repaints
318
+ // the row with a fresh elapsedMs — keeping the formatter pure.
319
+ const frames = SPINNER_DEFS.tool.frames;
320
+ const idx = Math.floor(Math.max(0, durationMs || 0) / RUNNING_GLYPH_INTERVAL_MS) % frames.length;
321
+ glyph = frames[idx];
322
+ }
323
+ } else if (status === 'failure' || error) glyph = UI_ICONS.error;
324
+ else glyph = UI_ICONS.success;
258
325
 
326
+ const label = _categoryLabel(cat);
259
327
  const op = _operation(tag, arg, attrs);
260
328
  const metaParts = _metaParts(tag, meta, error);
261
329
 
262
- // Segment-by-segment styling. Each fragment carries its own ANSI codes
263
- // so the " · " separator — neutral DIM — can sit between them without
264
- // leaking the surrounding color into the separator itself.
265
- const sep = ` ${DIM}·${RST} `;
266
-
267
- const durColor = status === 'failure' ? UI_THEME.error : UI_THEME.muted;
268
- const metaColor = status === 'failure' ? UI_THEME.error : UI_THEME.subtle;
330
+ // Segment-by-segment styling. Each fragment carries its own ANSI codes so the
331
+ // " · " separator — neutral DIM — can sit between them without leaking the
332
+ // surrounding colour. Under NO_COLOR/non-TTY the separator and resets go plain.
333
+ const sep = enabled ? ` ${DIM}·${R} ` : ' · ';
269
334
 
270
335
  const segments = [];
271
- segments.push(` ${glyphColor}${glyph}${RST} ${catColor}${cat}${RST}`);
272
- segments.push(`${UI_THEME.default}${op}${RST}`);
336
+ segments.push(` ${colors.glyph}${glyph}${R} ${colors.label}${label}${R}`);
337
+ segments.push(`${colors.op}${op}${R}`);
273
338
  if (!noDuration) {
274
339
  const durStr = formatDuration(durationMs || 0) + (status === 'pending' ? '…' : '');
275
- segments.push(`${durColor}${durStr}${RST}`);
340
+ segments.push(`${colors.dur}${durStr}${R}`);
276
341
  }
277
342
  for (const m of metaParts) {
278
- if (m) segments.push(`${metaColor}${m}${RST}`);
343
+ if (m) segments.push(`${colors.meta}${m}${R}`);
279
344
  }
280
345
  return segments.join(sep);
281
346
  }
@@ -332,6 +397,79 @@ function summarizeToolResult(content) {
332
397
  return trimmed;
333
398
  }
334
399
 
400
+ // ── Collapsible output preview (Output Refactor — Phase 5) ───────────────────
401
+ //
402
+ // Shell / MCP / subagent output is shown in MODERATION in the chrome: a short
403
+ // preview of the leading lines + an exact `… N more lines` hint.
404
+ // These two helpers are PURE (no IO, no config) so the policy is unit-testable
405
+ // and single-sourced: both the descriptor renderer (render-operation.js) and the
406
+ // live commit path (chat-history.js) drive their preview from here.
407
+
408
+ // Default preview budget when a caller passes none. Mirrors
409
+ // constants.DEFAULT_SHELL_PREVIEW_LINES; duplicated as a bare fallback so this
410
+ // module stays config-free (the real value flows in from config via the caller).
411
+ const DEFAULT_PREVIEW_LINES = 5;
412
+
413
+ const _FENCE_OPEN = '<<<UNTRUSTED_EXTERNAL_CONTENT';
414
+ const _FENCE_CLOSE = '<<<END_UNTRUSTED_EXTERNAL_CONTENT>>>';
415
+
416
+ // Recover the human-facing OUTPUT body from a model-facing tool-result string,
417
+ // stripping the framing the model needs but the user has already seen on the
418
+ // result line above:
419
+ // - shell: `Command \`<cmd>\`:\nExit code: <N>\n<body>` → <body>
420
+ // - MCP / subagent: `<prefix>:\n<<<UNTRUSTED…>>>\n<content>\n<<<END…>>>` → <content>
421
+ // Pure. Returns '' when there's nothing worth previewing. NOTE: this NEVER feeds
422
+ // the model — it is chrome only; the model receives the full framed result via
423
+ // boundToolOutput, untouched.
424
+ function extractDisplayBody(result) {
425
+ if (typeof result !== 'string' || !result) return '';
426
+ let body = result;
427
+ const shellMatch = result.match(/^Command `[\s\S]*?`:\nExit code: -?\d+\n([\s\S]*)$/);
428
+ if (shellMatch) body = shellMatch[1];
429
+ // Strip an untrusted fence (MCP/subagent/web): drop everything up to and
430
+ // including the fence-open line, and the closing marker. The model-facing
431
+ // prefix line (before the fence) is dropped with it.
432
+ const open = body.indexOf(_FENCE_OPEN);
433
+ if (open !== -1) {
434
+ const afterOpenNl = body.indexOf('\n', open);
435
+ const close = body.lastIndexOf(_FENCE_CLOSE);
436
+ if (afterOpenNl !== -1 && close > afterOpenNl) {
437
+ body = body.slice(afterOpenNl + 1, close);
438
+ }
439
+ }
440
+ return body.replace(/[ \t\r\n]+$/, '');
441
+ }
442
+
443
+ // Slice `body` into a single-row-fitted preview. Returns:
444
+ // { lines, hiddenCount, total, truncatable }
445
+ // where `lines` is what to show (the first `previewLines` when collapsed, ALL
446
+ // when `expanded`), each truncated to one physical row (cols−1), `total` is the
447
+ // line count, `hiddenCount` is EXACTLY total − previewLines when collapsed (0
448
+ // otherwise), and `truncatable` says whether an affordance applies at all.
449
+ // Pure. Trailing blank lines are dropped so they never inflate the count.
450
+ function formatOutputPreview(body, opts) {
451
+ const o = opts || {};
452
+ const previewLines = (Number.isInteger(o.previewLines) && o.previewLines > 0) ? o.previewLines : DEFAULT_PREVIEW_LINES;
453
+ const expanded = !!o.expanded;
454
+ const cols = (Number.isInteger(o.cols) && o.cols > 0) ? o.cols : 80;
455
+ const max = Math.max(0, cols - 1);
456
+ const raw = String(body == null ? '' : body).replace(/[\r\n]+$/, '');
457
+ const allLines = raw === '' ? [] : raw.split('\n');
458
+ // A child process's own SGR escapes can ride in `body`. Under NO_COLOR (or
459
+ // non-TTY) strip them BEFORE truncateVisible so it receives escape-free input
460
+ // and, by its content-driven gate, appends no reset — leaving the body line
461
+ // byte-clean. With color on we preserve the captured color (mirrors the
462
+ // md-stream.js inline() precedent: colorEnabled() ? styled : stripAnsi).
463
+ const keepColor = colorEnabled();
464
+ const fitted = allLines.map((l) => truncateVisible(keepColor ? l : stripAnsi(l), max));
465
+ const total = fitted.length;
466
+ const truncatable = total > previewLines;
467
+ if (expanded || !truncatable) {
468
+ return { lines: fitted, hiddenCount: 0, total, truncatable };
469
+ }
470
+ return { lines: fitted.slice(0, previewLines), hiddenCount: total - previewLines, total, truncatable };
471
+ }
472
+
335
473
  module.exports = {
336
474
  formatDuration,
337
475
  formatBytes,
@@ -339,4 +477,11 @@ module.exports = {
339
477
  formatToolLine,
340
478
  summarizeToolResult,
341
479
  normalizeCmdForDisplay,
480
+ // The word-boundary-aware single-line truncator (used by the file-activity
481
+ // summary to fit the basename list to the remaining columns). Exported under a
482
+ // public name; the internal `_truncate` is otherwise module-private.
483
+ truncateLine: _truncate,
484
+ extractDisplayBody,
485
+ formatOutputPreview,
486
+ wrapPromptLines,
342
487
  };
@@ -27,7 +27,7 @@ function parseKeySequence(buf) {
27
27
  0x09:'tab', 0x01:'ctrl+a', 0x02:'ctrl+b', 0x05:'ctrl+e',
28
28
  0x06:'ctrl+f', 0x07:'ctrl+g', 0x0b:'ctrl+k', 0x0e:'ctrl+n',
29
29
  0x10:'ctrl+p', 0x12:'ctrl+r', 0x14:'ctrl+t', 0x15:'ctrl+u',
30
- 0x17:'ctrl+w', 0x0c:'ctrl+l', 0x03:'ctrl+c', 0x04:'ctrl+d', 0x0f:'ctrl+o',
30
+ 0x17:'ctrl+w', 0x0c:'ctrl+l', 0x03:'ctrl+c', 0x04:'ctrl+d',
31
31
  };
32
32
  if (SINGLE[b0]) return { key: SINGLE[b0], len: 1 };
33
33
 
@@ -201,6 +201,10 @@ class InputField extends EventEmitter {
201
201
  this._render();
202
202
  }
203
203
  getValue() { return this._chars.join(''); }
204
+ // True once the field has settled into its idle state (the one-shot _goIdle
205
+ // fired) and not since reactivated. Lets startup re-sync the status-bar clock
206
+ // to the field's real idle state after an await may have fired _goIdle early.
207
+ isIdle() { return this._idle; }
204
208
  onSubmit(cb) { this.on('submit', cb); }
205
209
 
206
210
  captureSelect(menu) {
@@ -800,7 +804,6 @@ class InputField extends EventEmitter {
800
804
  case 'ctrl+t': this._transposeChars(); this._render(); break;
801
805
  case 'ctrl+l': this._chatHistory.clearMessages(); break;
802
806
  case 'ctrl+r': this._enterSearchMode(); break;
803
- case 'ctrl+o': if (this._navCapture) this._navCapture('expand'); else this.emit('expand'); break;
804
807
  case 'ctrl+g': this.emit('interrupt'); break;
805
808
  case 'ctrl+c': this._onCtrlC(); break;
806
809
  case 'ctrl+d': this._onCtrlD(); break;
@@ -905,8 +908,6 @@ class InputField extends EventEmitter {
905
908
  this.emit('abort');
906
909
  } else if (buf[0] === 0x04) {
907
910
  // Ctrl+D while agent active: ignored (bash readline semantics).
908
- } else if (buf[0] === 0x0f) {
909
- this.emit('expand');
910
911
  } else if (buf[0] === 0x1b && buf.length === 1) {
911
912
  // Bare ESC while agent is running: buffer and confirm after 20ms so that
912
913
  // escape sequences (arrow keys, etc.) arriving as separate bytes are ignored.
@@ -0,0 +1,234 @@
1
+ 'use strict';
2
+
3
+ // Stateful, line-at-a-time Markdown → ANSI styler for the interactive TUI's
4
+ // agent narration. It COMPOSES the existing hand-rolled helpers rather than
5
+ // reinventing them (zero-dep invariant #13):
6
+ // - diff.js `_mdInline` — inline bold/italic/code span styler
7
+ // - diff.js `_truncateByWidth` — plain-text width truncation (code bodies)
8
+ // - stream.js `colorizeCode` — per-line code syntax highlight
9
+ // - theme.js `colorEnabled` — the single NO_COLOR + non-TTY gate
10
+ // - theme/ansi palette — FG_CODE_BG/BORDER/LANG, RST, EL, BOLD, DIM
11
+ //
12
+ // It is fed COMPLETE lines (the caller splits the token stream on '\n'); inline
13
+ // spans are therefore always line-complete and never strand an open SGR across
14
+ // calls. The only cross-line state is the fenced-code-block buffer: while a
15
+ // ``` block is open, body lines are buffered and the whole width-aware code box
16
+ // is emitted at the closing fence. `flush()` closes a still-open fence cleanly.
17
+ //
18
+ // Both the live streaming path AND the --resume / history replay path drive the
19
+ // SAME implementation (see `renderBlock`), so a replayed turn is byte-identical
20
+ // to the live one.
21
+
22
+ const { colorEnabled, FG_CODE_BG, FG_CODE_BORDER, FG_CODE_LANG, THEME } = require('./theme');
23
+ const { RST, EL, BOLD, DIM } = require('./ansi');
24
+ const { _mdInline, _truncateByWidth } = require('./diff');
25
+ const { colorizeCode } = require('./stream');
26
+ const { getCols, repeatToWidth, termWidth, stripAnsi } = require('./utils');
27
+
28
+ // Gate a raw SGR code through the single NO_COLOR/non-TTY switch.
29
+ function c(code) { return colorEnabled() ? code : ''; }
30
+
31
+ // Inline span styler, color-gated. Reuses diff.js `_mdInline`; under NO_COLOR we
32
+ // strip the SGR it injects, which also drops the `**`/`*`/`` ` `` markers, so the
33
+ // plain output reads as clean prose with no escape codes.
34
+ function inline(text) {
35
+ const styled = _mdInline(text);
36
+ return colorEnabled() ? styled : stripAnsi(styled);
37
+ }
38
+
39
+ class StreamMarkdown {
40
+ constructor(opts) {
41
+ const o = opts || {};
42
+ // Leading indent every emitted line carries (matches the 2-space narration
43
+ // gutter the AI bubble used before styling).
44
+ this.indent = o.indent != null ? String(o.indent) : ' ';
45
+ this.reset();
46
+ }
47
+
48
+ // Drop all per-turn state. Called between turns so a fresh turn never inherits
49
+ // an open fence or buffered body from the previous one.
50
+ reset() {
51
+ this.inCodeBlock = false;
52
+ this.codeLang = '';
53
+ this._codeBuf = [];
54
+ }
55
+
56
+ // Style a COMPLETE line. Returns the styled string to commit (no trailing
57
+ // newline — the caller appends one), or null when there is nothing to emit yet
58
+ // (a buffered code-block body line, or the opening ``` fence). A code block's
59
+ // box is returned in full when its closing ``` arrives.
60
+ feedLine(line) {
61
+ if (this.inCodeBlock) {
62
+ if (line.trim() === '```') {
63
+ const box = this._renderCodeBox();
64
+ this.inCodeBlock = false;
65
+ this.codeLang = '';
66
+ this._codeBuf = [];
67
+ return box;
68
+ }
69
+ this._codeBuf.push(line);
70
+ return null;
71
+ }
72
+ if (line.length >= 3 && line[0] === '`' && line[1] === '`' && line[2] === '`') {
73
+ this.inCodeBlock = true;
74
+ this.codeLang = line.slice(3).trim();
75
+ this._codeBuf = [];
76
+ return null;
77
+ }
78
+ return this._renderProse(line);
79
+ }
80
+
81
+ // Close any open state and return the styled remainder. If a fenced block is
82
+ // still buffering (no closing ```), emit the buffered body as a finished box so
83
+ // scrollback never strands an unclosed fence or a leaked SGR. Returns null when
84
+ // there is nothing pending.
85
+ flush() {
86
+ if (this.inCodeBlock) {
87
+ const box = this._renderCodeBox();
88
+ this.inCodeBlock = false;
89
+ this.codeLang = '';
90
+ this._codeBuf = [];
91
+ return box;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ // Style one prose/heading/list/blockquote/rule line. Mirrors diff.js
97
+ // `renderMarkdown`'s block grammar, but every SGR routes through `c()` and the
98
+ // configured indent prefixes each line. Always ends with a reset so no ANSI
99
+ // bleeds into the next immutable scrollback line.
100
+ _renderProse(line) {
101
+ const ind = this.indent;
102
+ const barW = Math.max(1, getCols() - ind.length);
103
+ const trimmed = line.trim();
104
+
105
+ if (trimmed === '') return ind;
106
+
107
+ // Horizontal rule.
108
+ if (trimmed === '---' || trimmed === '===') {
109
+ return ind + c(THEME.dim) + '─'.repeat(barW) + c(RST);
110
+ }
111
+
112
+ // ATX heading (levels 1–3), optionally underlined like renderMarkdown.
113
+ let level = 0;
114
+ while (level < line.length && line[level] === '#') level++;
115
+ if (level >= 1 && level <= 3 && level < line.length && line[level] === ' ') {
116
+ const htext = line.slice(level + 1);
117
+ if (level === 1) {
118
+ return ind + c(THEME.agent) + c(BOLD) + htext + c(RST)
119
+ + '\n' + ind + c(THEME.dim) + '═'.repeat(barW) + c(RST);
120
+ }
121
+ if (level === 2) {
122
+ return ind + c(BOLD) + htext + c(RST)
123
+ + '\n' + ind + c(THEME.dim) + '─'.repeat(barW) + c(RST);
124
+ }
125
+ return ind + c(BOLD) + htext + c(RST);
126
+ }
127
+
128
+ // Blockquote.
129
+ if (line[0] === '>') {
130
+ const inner = line.length > 1 && line[1] === ' ' ? line.slice(2) : line.slice(1);
131
+ return ind + c(THEME.tool) + '│' + c(RST) + ' ' + inline(inner) + c(RST);
132
+ }
133
+
134
+ // Unordered list item (with simple nesting by leading-space depth).
135
+ let lead = 0;
136
+ while (lead < line.length && line[lead] === ' ') lead++;
137
+ const rest = line.slice(lead);
138
+ if (rest.length > 2 && (rest[0] === '-' || rest[0] === '*') && rest[1] === ' ') {
139
+ const nest = ' '.repeat(Math.floor(lead / 2));
140
+ return ind + nest + c(THEME.agent) + '❯' + c(RST) + ' ' + inline(rest.slice(2)) + c(RST);
141
+ }
142
+
143
+ // Ordered list item.
144
+ let ni = 0;
145
+ while (ni < line.length && line[ni] >= '0' && line[ni] <= '9') ni++;
146
+ if (ni > 0 && ni < line.length - 1 && line[ni] === '.' && line[ni + 1] === ' ') {
147
+ return ind + c(THEME.dim) + line.slice(0, ni) + '.' + c(RST) + ' ' + inline(line.slice(ni + 2)) + c(RST);
148
+ }
149
+
150
+ // Plain prose.
151
+ return ind + inline(line) + c(RST);
152
+ }
153
+
154
+ // Render the buffered code-block body as a width-aware box: a top border with
155
+ // the language label, syntax-highlighted body lines whose background fills to
156
+ // the physical right edge via EL (the diff.js technique), and a bottom border.
157
+ // The body is capped at the existing `max_output_lines` config (no new limit).
158
+ _renderCodeBox() {
159
+ const ind = this.indent;
160
+ const cols = getCols();
161
+ const { loadConfig } = require('../config');
162
+ const maxLines = (loadConfig().max_output_lines) || 50;
163
+
164
+ let body = this._codeBuf;
165
+ let overflow = 0;
166
+ if (body.length > maxLines) {
167
+ overflow = body.length - maxLines;
168
+ body = body.slice(0, maxLines);
169
+ }
170
+
171
+ const lines = [];
172
+
173
+ // Top border with language label, reaching the current width.
174
+ const label = this.codeLang ? ` ${this.codeLang} ` : '';
175
+ const prefix = '╭─── ';
176
+ const used = ind.length + termWidth(prefix) + termWidth(label);
177
+ const topFill = repeatToWidth('─', cols, used);
178
+ lines.push(
179
+ ind + c(FG_CODE_BORDER) + prefix + c(RST)
180
+ + c(FG_CODE_LANG) + label + c(RST)
181
+ + c(FG_CODE_BORDER) + topFill + c(RST)
182
+ );
183
+
184
+ // Body. Truncate the PLAIN text to the code width first (diff.js order), THEN
185
+ // colorize, so the syntax spans never get cut mid-escape; EL then fills the
186
+ // background to the edge.
187
+ const CODE_W = Math.max(1, cols - (ind.length + 2)); // │ + space
188
+ for (const raw of body) {
189
+ const disp = (raw || '').replace(/\t/g, ' ');
190
+ const code = _truncateByWidth(disp, CODE_W);
191
+ const colored = colorEnabled() ? colorizeCode(code) : code;
192
+ lines.push(
193
+ ind + c(FG_CODE_BORDER) + '│' + c(RST) + ' '
194
+ + c(FG_CODE_BG) + colored + c(EL) + c(RST)
195
+ );
196
+ }
197
+
198
+ if (overflow > 0) {
199
+ lines.push(ind + c(DIM) + `[... ${overflow} more lines]` + c(RST));
200
+ }
201
+
202
+ // Bottom border.
203
+ const botFill = repeatToWidth('─', cols, ind.length + 1);
204
+ lines.push(ind + c(FG_CODE_BORDER) + '╰' + botFill + c(RST));
205
+
206
+ return lines.join('\n');
207
+ }
208
+ }
209
+
210
+ // Render a complete stored content block through a fresh StreamMarkdown, line by
211
+ // line, EXACTLY as the live path does: feed every line but the last via
212
+ // feedLine; feed the trailing line only when non-empty (the live partial path
213
+ // skips an empty partial); then flush. Returns the joined styled body with NO
214
+ // trailing newline. This is the single seam that keeps --resume / history replay
215
+ // byte-identical to live narration.
216
+ function renderBlock(content, opts) {
217
+ const md = new StreamMarkdown(opts);
218
+ const lines = String(content == null ? '' : content).split('\n');
219
+ const partial = lines.pop();
220
+ const out = [];
221
+ for (const line of lines) {
222
+ const s = md.feedLine(line);
223
+ if (s !== null) out.push(s);
224
+ }
225
+ if (partial !== '') {
226
+ const s = md.feedLine(partial);
227
+ if (s !== null) out.push(s);
228
+ }
229
+ const tail = md.flush();
230
+ if (tail !== null) out.push(tail);
231
+ return out.join('\n');
232
+ }
233
+
234
+ module.exports = { StreamMarkdown, renderBlock };
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ // Pure renderer for a ToolOperation descriptor (Output Refactor — Phase 1).
4
+ //
5
+ // `renderOperation(descriptor, { mode, phase, maxLines })` → the string for that
6
+ // phase/mode. This is the SINGLE place a tool-call's chrome line is assembled
7
+ // for the interactive path; chat-turn.js builds a descriptor and calls here
8
+ // instead of assembling formatToolLine arguments inline.
9
+ //
10
+ // Phase 1 is a re-routing, not a re-styling: the renderer internally reuses the
11
+ // existing `formatToolLine` (glyph/category/operation/meta assembly) and
12
+ // `buildExecutionDiff` (diff body), so its output is byte-for-byte identical to
13
+ // today's. Do NOT change glyphs, spacing, colours, or wording here — equivalence
14
+ // with the old formatters is the phase's pass/fail.
15
+ //
16
+ // modes: 'ansi' (interactive) — assembles the chrome line via formatToolLine.
17
+ // 'json' (Phase 6d-i) — the pure embeddable per-operation object built
18
+ // from the shared `operationCore` (descriptor-native names, no
19
+ // ANSI/IO/framing); a superset-or-equal of serializeOperation's
20
+ // persistable core, minus the `v` persistence tag.
21
+ // 'event' (Phase 6d-i) — the json object plus the `phase` it rendered
22
+ // for, for a single per-lifecycle emission. Headless (6d-ii)
23
+ // owns any NDJSON envelope / `type` tag around it.
24
+ // phases: 'pending' — the live activity line (with growing timer)
25
+ // 'result' — the committed final line
26
+ // 'detail' — the collapsible body (Phase 5): a file-edit diff (expanded
27
+ // to maxLines) or a shell/MCP/subagent output preview
28
+ // (previewLines + `… N more lines`). Errors carry no detail
29
+ // (expanded elsewhere).
30
+
31
+ const { formatToolLine, formatOutputPreview } = require('./format');
32
+ const { buildExecutionDiff } = require('./diff');
33
+ const { operationCore } = require('./tool-operation');
34
+
35
+ // Map the descriptor's status vocabulary back to formatToolLine's.
36
+ function _formatStatus(descriptor, phase) {
37
+ if (phase === 'pending') return 'pending';
38
+ return descriptor.status === 'error' ? 'failure' : 'success';
39
+ }
40
+
41
+ // Reconstruct the formatToolLine argument bag from the descriptor. Phase 1
42
+ // guarantees identical output by feeding the same fields the old call sites did.
43
+ function _toolLineArgs(descriptor, phase) {
44
+ return {
45
+ status: _formatStatus(descriptor, phase),
46
+ // Colour is resolved from the descriptor's category (Phase 2.5): the
47
+ // renderer hands formatToolLine the already-resolved category so colour is
48
+ // the renderer's job, keyed by the descriptor, not re-derived from the tag.
49
+ category: descriptor.category,
50
+ tag: descriptor.tag,
51
+ arg: descriptor.target,
52
+ attrs: descriptor.attrs,
53
+ durationMs: descriptor.durationMs == null ? 0 : descriptor.durationMs,
54
+ meta: phase === 'pending' ? null : descriptor.meta,
55
+ error: phase === 'pending' ? null : descriptor.error,
56
+ noDuration: descriptor.noDuration,
57
+ };
58
+ }
59
+
60
+ function _renderAnsi(descriptor, opts) {
61
+ const phase = opts.phase || 'result';
62
+ if (phase === 'detail') {
63
+ // The detail body, COLLAPSED per the Phase 5 policy (keyed by detail-kind):
64
+ // - diff → the file-edit diff, EXPANDED to maxLines (unchanged).
65
+ // - output → a shell_preview_lines preview + `… N more lines`. (Errors
66
+ // carry no detail — the error body renders expanded on the
67
+ // chat-history path.)
68
+ const detail = descriptor.detail;
69
+ if (!detail) return '';
70
+ if (detail.kind === 'diff') {
71
+ const diffStr = buildExecutionDiff({ diff: detail.payload, maxLines: opts.maxLines });
72
+ return diffStr || '';
73
+ }
74
+ if (detail.kind === 'output') {
75
+ const { lines, hiddenCount } = formatOutputPreview(detail.payload.body, {
76
+ previewLines: opts.previewLines,
77
+ cols: opts.cols,
78
+ });
79
+ const out = lines.slice();
80
+ if (hiddenCount > 0) {
81
+ out.push(`… ${hiddenCount} more ${hiddenCount === 1 ? 'line' : 'lines'}`);
82
+ }
83
+ return out.join('\n');
84
+ }
85
+ return '';
86
+ }
87
+ return formatToolLine(_toolLineArgs(descriptor, phase));
88
+ }
89
+
90
+ function renderOperation(descriptor, opts) {
91
+ if (!descriptor) return '';
92
+ const o = opts || {};
93
+ const mode = o.mode || 'ansi';
94
+ if (mode === 'ansi') return _renderAnsi(descriptor, o);
95
+ // json/event are PURE structured-data modes (Phase 6d-i): no ANSI, no IO, no
96
+ // framing. Both derive ONLY from the shared `operationCore` mapping — the same
97
+ // one `serializeOperation` uses — so there is exactly one descriptor→data
98
+ // serializer in the codebase.
99
+ // - json : the embeddable per-operation object (descriptor-native field
100
+ // names; the persistable core WITHOUT the `v` persistence tag).
101
+ // - event: the same data shaped for a single per-lifecycle emission — the
102
+ // json object plus the `phase` it was rendered for. The consumer
103
+ // (headless, Phase 6d-ii) owns any envelope/`type` framing.
104
+ if (mode === 'json') return operationCore(descriptor);
105
+ if (mode === 'event') {
106
+ const core = operationCore(descriptor);
107
+ return core && { ...core, phase: o.phase || 'result' };
108
+ }
109
+ // unknown mode — degrade to empty string (never throw).
110
+ return '';
111
+ }
112
+
113
+ module.exports = { renderOperation };
package/lib/ui/select.js CHANGED
@@ -11,7 +11,7 @@ const writer = require('./writer');
11
11
  // Two input paths:
12
12
  // * TUI: opts.captureNavigation(handler) → release fn. Mirrors the
13
13
  // permission picker. The host (input-field) routes prev/next/
14
- // select/cancel/expand actions to handler; release detaches.
14
+ // select/cancel actions to handler; release detaches.
15
15
  // * Non-TUI: direct raw stdin. Used by cmdModels and the permission-
16
16
  // prompt fallback in non-chat command flows.
17
17
  //
@@ -59,8 +59,6 @@ async function interactiveSelect(items, renderItem, options) {
59
59
  writer.clearModal();
60
60
  if (typeof releaseNav === 'function') releaseNav();
61
61
  resolve(action === 'select' ? idx : null);
62
- } else if (action === 'expand') {
63
- if (typeof opts.onExpand === 'function') opts.onExpand();
64
62
  }
65
63
  });
66
64
  });
@@ -93,7 +91,6 @@ async function interactiveSelect(items, renderItem, options) {
93
91
  const onData = (chunk) => {
94
92
  if (done) return;
95
93
  const data = chunk.toString('utf8');
96
- if (data[0] === '\x0f') { if (typeof opts.onExpand === 'function') opts.onExpand(); return; }
97
94
  if (data === '\x03' || data === '\x1b' || data === 'q') { finish(null); return; }
98
95
  if (data === '\r' || data === '\n') { finish(idx); return; }
99
96
  if (data === '\x1b[A' || data === 'k') {