@semalt-ai/code 1.8.5 → 1.20.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 (192) hide show
  1. package/.claude/settings.local.json +7 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/ARCHITECTURE.md +6 -95
  4. package/CLAUDE.md +196 -316
  5. package/README.md +148 -4
  6. package/docs/ARCHITECTURE.md +1321 -0
  7. package/docs/CONFIG.md +340 -0
  8. package/docs/HISTORY.md +245 -0
  9. package/examples/embed.js +74 -0
  10. package/index.js +251 -10
  11. package/lib/agent.js +856 -120
  12. package/lib/api.js +239 -50
  13. package/lib/args.js +74 -2
  14. package/lib/audit.js +23 -1
  15. package/lib/background.js +584 -0
  16. package/lib/checkpoints.js +757 -0
  17. package/lib/commands/auth.js +94 -0
  18. package/lib/commands/chat-session.js +489 -0
  19. package/lib/commands/chat-slash.js +415 -0
  20. package/lib/commands/chat-turn.js +669 -0
  21. package/lib/commands/chat.js +407 -0
  22. package/lib/commands/custom.js +157 -0
  23. package/lib/commands/history-utils.js +66 -0
  24. package/lib/commands/index.js +268 -0
  25. package/lib/commands/mcp.js +113 -0
  26. package/lib/commands/oneshot.js +193 -0
  27. package/lib/commands/registry.js +269 -0
  28. package/lib/commands/tasks.js +89 -0
  29. package/lib/compact.js +87 -0
  30. package/lib/config.js +360 -11
  31. package/lib/constants.js +401 -3
  32. package/lib/deny.js +199 -0
  33. package/lib/doctor.js +160 -0
  34. package/lib/headless.js +202 -0
  35. package/lib/hooks.js +286 -0
  36. package/lib/images.js +270 -0
  37. package/lib/internals.js +49 -0
  38. package/lib/mcp/boundary.js +131 -0
  39. package/lib/mcp/client.js +270 -0
  40. package/lib/mcp/oauth.js +134 -0
  41. package/lib/memory.js +209 -0
  42. package/lib/metrics.js +37 -2
  43. package/lib/payload.js +54 -0
  44. package/lib/permission-rules.js +401 -0
  45. package/lib/permissions.js +123 -26
  46. package/lib/pricing.js +67 -0
  47. package/lib/proc.js +62 -0
  48. package/lib/prompts.js +99 -8
  49. package/lib/sandbox.js +568 -0
  50. package/lib/sdk.js +328 -0
  51. package/lib/secrets.js +211 -0
  52. package/lib/skills.js +223 -0
  53. package/lib/subagents.js +516 -0
  54. package/lib/tool_registry.js +2862 -0
  55. package/lib/tool_specs.js +263 -9
  56. package/lib/tools.js +352 -1039
  57. package/lib/ui/anim.js +86 -0
  58. package/lib/ui/ansi.js +17 -27
  59. package/lib/ui/chat-history.js +253 -71
  60. package/lib/ui/create-ui.js +67 -24
  61. package/lib/ui/diff.js +90 -25
  62. package/lib/ui/file-activity.js +236 -0
  63. package/lib/ui/format.js +195 -29
  64. package/lib/ui/input-field.js +21 -11
  65. package/lib/ui/md-stream.js +234 -0
  66. package/lib/ui/render-operation.js +113 -0
  67. package/lib/ui/select.js +1 -4
  68. package/lib/ui/status-bar.js +146 -36
  69. package/lib/ui/stream.js +20 -13
  70. package/lib/ui/theme.js +190 -44
  71. package/lib/ui/tool-operation.js +190 -0
  72. package/lib/ui/utils.js +9 -5
  73. package/lib/ui/web-activity.js +270 -0
  74. package/lib/ui/writer.js +159 -45
  75. package/lib/ui.js +1 -1
  76. package/lib/verify.js +229 -0
  77. package/lib/web-extract.js +213 -0
  78. package/lib/web-summarize.js +68 -0
  79. package/package.json +19 -4
  80. package/scripts/lint.js +57 -0
  81. package/test/agent-loop.test.js +389 -0
  82. package/test/anim-driver.test.js +153 -0
  83. package/test/ask-user-display.test.js +226 -0
  84. package/test/ask-user-gate.test.js +231 -0
  85. package/test/background.test.js +414 -0
  86. package/test/chat-history-nocolor.test.js +155 -0
  87. package/test/chat-relogin.test.js +207 -0
  88. package/test/chat.test.js +114 -0
  89. package/test/checkpoints-agent.test.js +181 -0
  90. package/test/checkpoints.test.js +650 -0
  91. package/test/command-registry.test.js +160 -0
  92. package/test/compact.test.js +116 -0
  93. package/test/completion-lazy.test.js +52 -0
  94. package/test/config-merge.test.js +324 -0
  95. package/test/config-quarantine.test.js +128 -0
  96. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  97. package/test/config-write-guard-skip.test.js +46 -0
  98. package/test/config-write-guard.test.js +153 -0
  99. package/test/context-split.test.js +215 -0
  100. package/test/cost-doctor.test.js +142 -0
  101. package/test/custom-commands-chat.test.js +106 -0
  102. package/test/custom-commands.test.js +230 -0
  103. package/test/defer-detail-band.test.js +403 -0
  104. package/test/deny-windows.test.js +120 -0
  105. package/test/deny.test.js +83 -0
  106. package/test/detail-band-tab-flatten.test.js +242 -0
  107. package/test/download-allow-anywhere.test.js +66 -0
  108. package/test/download-confine.test.js +153 -0
  109. package/test/exec-diff.test.js +268 -0
  110. package/test/executors.test.js +599 -0
  111. package/test/extract-tool-calls.test.js +349 -0
  112. package/test/fetch-url-validation.test.js +219 -0
  113. package/test/file-activity.test.js +522 -0
  114. package/test/fixtures/tool-calls.js +57 -0
  115. package/test/fixtures/web-page.js +91 -0
  116. package/test/git-tools.test.js +384 -0
  117. package/test/grep-glob-serialize.test.js +242 -0
  118. package/test/grep-glob.test.js +268 -0
  119. package/test/grep-path-target.test.js +227 -0
  120. package/test/harness/README.md +57 -0
  121. package/test/harness/chat-harness.js +143 -0
  122. package/test/harness/memwarn-headless-child.js +65 -0
  123. package/test/harness/mock-llm.js +120 -0
  124. package/test/harness/mock-mcp-server.js +142 -0
  125. package/test/harness/sse-server.js +69 -0
  126. package/test/headless.test.js +348 -0
  127. package/test/history-utils.test.js +88 -0
  128. package/test/hooks-agent.test.js +238 -0
  129. package/test/hooks-verify-sandbox.test.js +232 -0
  130. package/test/hooks.test.js +216 -0
  131. package/test/http-get-user-agent.test.js +142 -0
  132. package/test/images-api.test.js +208 -0
  133. package/test/images.test.js +238 -0
  134. package/test/input-field-ctrl-o.test.js +37 -0
  135. package/test/live-height-physical.test.js +281 -0
  136. package/test/max-iterations.test.js +218 -0
  137. package/test/mcp-boundary.test.js +57 -0
  138. package/test/mcp-client.test.js +267 -0
  139. package/test/mcp-oauth.test.js +86 -0
  140. package/test/md-stream.test.js +183 -0
  141. package/test/memory-truncation-warning.test.js +222 -0
  142. package/test/memory.test.js +198 -0
  143. package/test/native-dispatch.test.js +409 -0
  144. package/test/native-live-narration.test.js +254 -0
  145. package/test/output-chokepoint.test.js +188 -0
  146. package/test/output-heredoc-leak.test.js +195 -0
  147. package/test/output-preview.test.js +245 -0
  148. package/test/path-guards.test.js +134 -0
  149. package/test/payload.test.js +99 -0
  150. package/test/permission-rules-agent.test.js +210 -0
  151. package/test/permission-rules.test.js +297 -0
  152. package/test/permissions.test.js +362 -0
  153. package/test/plan-mode.test.js +167 -0
  154. package/test/read-paginate.test.js +275 -0
  155. package/test/readonly-tools.test.js +177 -0
  156. package/test/render-operation.test.js +317 -0
  157. package/test/replay-descriptor-xml.test.js +216 -0
  158. package/test/replay-descriptor.test.js +189 -0
  159. package/test/replay-web-aggregate.test.js +291 -0
  160. package/test/replay-web-persist.test.js +241 -0
  161. package/test/result-cap.test.js +233 -0
  162. package/test/running-glyph-anim.test.js +111 -0
  163. package/test/sandbox-agent.test.js +147 -0
  164. package/test/sandbox-integration.test.js +216 -0
  165. package/test/sandbox.test.js +408 -0
  166. package/test/sdk.test.js +234 -0
  167. package/test/shell-output-cap.test.js +181 -0
  168. package/test/skills-chat.test.js +110 -0
  169. package/test/skills.test.js +295 -0
  170. package/test/smoke.test.js +68 -0
  171. package/test/status-bar-driver.test.js +93 -0
  172. package/test/status-bar-pause.test.js +164 -0
  173. package/test/status-bar-resync.test.js +188 -0
  174. package/test/stream-parser.test.js +171 -0
  175. package/test/subagents-agent.test.js +178 -0
  176. package/test/subagents.test.js +222 -0
  177. package/test/theme-palette.test.js +166 -0
  178. package/test/tool-registry.test.js +85 -0
  179. package/test/trim-budget.test.js +101 -0
  180. package/test/truncate-visible.test.js +78 -0
  181. package/test/verify-agent.test.js +317 -0
  182. package/test/verify.test.js +141 -0
  183. package/test/view-image.test.js +199 -0
  184. package/test/web-activity-ordering.test.js +203 -0
  185. package/test/web-activity.test.js +207 -0
  186. package/test/web-data-extraction-guidance.test.js +71 -0
  187. package/test/web-extract.test.js +185 -0
  188. package/test/web-fetch-agent.test.js +291 -0
  189. package/test/web-fetch-mode.test.js +193 -0
  190. package/test/web-search.test.js +380 -0
  191. package/lib/commands.js +0 -1438
  192. package/path +0 -1
package/lib/ui/anim.js ADDED
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ // Single animation driver (Output Refactor — Phase 3).
4
+ //
5
+ // Before Phase 3 the status bar owned TWO independent setIntervals: a 1 Hz
6
+ // clock tick and a 100 ms spinner-glyph cycle. They never coordinated, each
7
+ // repainted the whole live region on its own, and the per-tool running glyph
8
+ // never animated (its elapsed meter only advanced as an accidental side-effect
9
+ // of whichever of the two timers happened to be firing).
10
+ //
11
+ // This driver replaces both with ONE timer. Subscribers register a per-frame
12
+ // callback; the driver advances a single monotonic frame counter at the base
13
+ // interval and, after running every subscriber, performs AT MOST ONE
14
+ // coordinated repaint — so a tick yields a single writer frame, never two
15
+ // competing ones. A subscriber returns truthy to request the repaint this
16
+ // frame; the driver coalesces those requests into one.
17
+ //
18
+ // The base interval is the finer (spinner) cadence — 100 ms. Coarser
19
+ // consumers (the clock, which only needs ~1 s) gate on
20
+ // `frame % TICKS_PER_SECOND === 0` inside their callback rather than owning a
21
+ // second timer.
22
+ //
23
+ // start()/stop() are idempotent (the 5404bd0 lesson — never stack intervals
24
+ // across pause/resume cycles). The owner decides WHEN to run: while there is
25
+ // something to animate (a running tool / streaming / thinking) or while the
26
+ // clock should tick (not idle-paused). When neither holds, the owner stops the
27
+ // driver so idle = no periodic repaint = the viewport scrolls freely.
28
+
29
+ const BASE_INTERVAL_MS = 100;
30
+ const TICKS_PER_SECOND = Math.round(1000 / BASE_INTERVAL_MS); // 10
31
+
32
+ class AnimDriver {
33
+ constructor(intervalMs) {
34
+ this._interval = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : BASE_INTERVAL_MS;
35
+ this._timer = null;
36
+ this._frame = 0;
37
+ // Subscribers: (frame:number) => boolean. A truthy return requests the
38
+ // single coordinated repaint for this frame.
39
+ this._subs = new Set();
40
+ // The one repaint sink. Set by the owner (the status bar wires it to its
41
+ // _notify → _updateLive → writer.setLive path, which also re-renders the
42
+ // activity rows, so a single repaint covers spinner + clock + running op).
43
+ this._repaint = () => {};
44
+ }
45
+
46
+ get frame() { return this._frame; }
47
+ get intervalMs() { return this._interval; }
48
+ isRunning() { return this._timer !== null; }
49
+
50
+ // Register the single coordinated-repaint sink. Last writer wins.
51
+ onRepaint(fn) { this._repaint = typeof fn === 'function' ? fn : () => {}; }
52
+
53
+ // Register a per-frame subscriber. Returns an unsubscribe thunk.
54
+ subscribe(fn) {
55
+ if (typeof fn !== 'function') return () => {};
56
+ this._subs.add(fn);
57
+ return () => this._subs.delete(fn);
58
+ }
59
+
60
+ // Idempotent: a second start() while running is a no-op (never stacks a
61
+ // timer — the regression 5404bd0 guards against).
62
+ start() {
63
+ if (this._timer) return;
64
+ this._timer = setInterval(() => this._tick(), this._interval);
65
+ // An animation timer must never hold the process open — the interactive
66
+ // chat is kept alive by the stdin listener, not by this. unref() so a bar
67
+ // that's constructed-but-not-destroyed (e.g. in a unit test) can't wedge
68
+ // event-loop drain at exit. Guarded: mock timers may not implement unref.
69
+ if (this._timer && typeof this._timer.unref === 'function') this._timer.unref();
70
+ }
71
+
72
+ stop() {
73
+ if (this._timer) { clearInterval(this._timer); this._timer = null; }
74
+ }
75
+
76
+ _tick() {
77
+ this._frame++;
78
+ let dirty = false;
79
+ for (const fn of this._subs) {
80
+ try { if (fn(this._frame)) dirty = true; } catch {}
81
+ }
82
+ if (dirty) { try { this._repaint(); } catch {} }
83
+ }
84
+ }
85
+
86
+ module.exports = { AnimDriver, BASE_INTERVAL_MS, TICKS_PER_SECOND };
package/lib/ui/ansi.js CHANGED
@@ -1,5 +1,14 @@
1
1
  'use strict';
2
2
 
3
+ // ANSI primitives ONLY. Colour lives in `theme.js` (the single palette table);
4
+ // this file holds the SGR builders, structural box-drawing characters, the
5
+ // syntax-keyword set and the spinner definitions. The legacy colour palette
6
+ // (`THEME` / `FG_*` / code-block colours) is DEFINED in theme.js and re-exported
7
+ // here for back-compat — this file no longer defines any colour of its own.
8
+ //
9
+ // Dependency is one-directional: ansi.js → theme.js. theme.js does not require
10
+ // ansi.js (it has its own private SGR builders), so the two never form a cycle.
11
+
3
12
  const RST = '\x1b[0m';
4
13
  const BOLD = '\x1b[1m';
5
14
  const DIM = '\x1b[2m';
@@ -15,26 +24,6 @@ function hasTruecolor() {
15
24
  return v === 'truecolor' || v === '24bit';
16
25
  }
17
26
 
18
- const THEME = {
19
- user: '\x1b[36m',
20
- agent: '\x1b[32m',
21
- sys: '\x1b[33m',
22
- error: '\x1b[31;1m',
23
- warn: '\x1b[33;1m',
24
- tool: '\x1b[35m',
25
- dim: '\x1b[2m',
26
- reset: '\x1b[0m',
27
- };
28
-
29
- const FG_GRAY = '\x1b[38;5;245m';
30
- const FG_DARK = '\x1b[38;5;240m';
31
- const FG_BLUE = '\x1b[38;5;75m';
32
- const FG_CYAN = '\x1b[38;5;116m';
33
- const FG_GREEN = '\x1b[38;5;114m';
34
- const FG_YELLOW = '\x1b[38;5;222m';
35
- const FG_RED = '\x1b[38;5;203m';
36
- const FG_TEAL = '\x1b[38;5;73m';
37
-
38
27
  const BOX_H = '─';
39
28
  const BOX_V = '│';
40
29
  const BOX_TL = '╭';
@@ -42,13 +31,6 @@ const BOX_TR = '╮';
42
31
  const BOX_BL = '╰';
43
32
  const BOX_BR = '╯';
44
33
 
45
- const FG_CODE_BG = '\x1b[48;5;236m';
46
- const BG_SELECTED = '\x1b[48;5;237m';
47
- const FG_CODE_BORDER = '\x1b[38;5;240m';
48
- const FG_CODE_LANG = '\x1b[38;5;75m';
49
- const FG_TAG = '\x1b[38;5;176m';
50
- const FG_FILEPATH = '\x1b[38;5;222m';
51
-
52
34
  const KEYWORDS = new Set([
53
35
  'def', 'class', 'import', 'from', 'return', 'if', 'else', 'elif',
54
36
  'for', 'while', 'try', 'except', 'finally', 'with', 'as', 'in',
@@ -67,6 +49,14 @@ const SPINNER_DEFS = {
67
49
  waiting_download: { frames: ['⬇ ','⬇⠂','⬇⠆','⬇⠇','⬇⠧','⬇⠷','⬇⠿','⬇⠾','⬇⠼','⬇⠸','⬇⠰','⬇⠠'], color: '\x1b[38;5;75m' },
68
50
  };
69
51
 
52
+ // Re-export the colour palette from its single home (theme.js). One-directional
53
+ // require — theme.js never requires this file — so there is no cycle.
54
+ const {
55
+ THEME,
56
+ FG_GRAY, FG_DARK, FG_BLUE, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_TEAL,
57
+ FG_CODE_BG, BG_SELECTED, FG_CODE_BORDER, FG_CODE_LANG, FG_TAG, FG_FILEPATH,
58
+ } = require('./theme');
59
+
70
60
  module.exports = {
71
61
  RST, BOLD, DIM, EL, THEME,
72
62
  bg256, fg256, bgRGB, fgRGB, hasTruecolor,
@@ -1,11 +1,55 @@
1
1
  'use strict';
2
2
 
3
- const { RST, DIM, BOLD, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_DARK, FG_GRAY } = require('./ansi');
3
+ const { RST, DIM, BOLD, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_GRAY } = require('./ansi');
4
4
  const { getCols, stripAnsi } = require('./utils');
5
- const { UI_THEME, UI_ICONS, TOOL_CATEGORIES } = require('./theme');
6
- const { summarizeToolResult } = require('./format');
5
+ const { UI_THEME, UI_ICONS, TOOL_CATEGORIES, colorize, colorEnabled } = require('./theme');
6
+ const { StreamMarkdown, renderBlock } = require('./md-stream');
7
+ const { summarizeToolResult, formatOutputPreview } = require('./format');
8
+ const { descriptorFromStored } = require('./tool-operation');
9
+ const { isWebCore } = require('./web-activity');
10
+ const { renderOperation } = require('./render-operation');
7
11
  const writer = require('./writer');
8
12
 
13
+ // Gate a raw SGR code through the single NO_COLOR/non-TTY switch (theme.js).
14
+ // Same idiom as diff.js / md-stream.js — wrap every color-constant interpolation
15
+ // in c(...) so UI chrome emits zero ANSI under NO_COLOR while glyphs/layout stay.
16
+ const c = colorize;
17
+
18
+ // Embedded control chars that would break the "one logical line == one physical
19
+ // row" invariant the detail-band erase/commit math rests on. Mirrors the
20
+ // writer's _CONTROL_CHARS (writer.js): \t (TAB) advances to the next 8-col tab
21
+ // stop, \r/C0/DEL move the cursor unpredictably. ESC (0x1B) is EXCLUDED so SGR
22
+ // color survives — _flattenBandLine strips the non-SGR escapes separately.
23
+ const _BAND_CONTROL_CHARS = /[\x00-\x1A\x1C-\x1F\x7F]/g; // eslint-disable-line no-control-regex
24
+ // A CSI sequence: ESC '[' params final-letter. Used to drop the NON-SGR ones.
25
+ const _CSI_SEQ = /\x1b\[[0-9;?]*[A-Za-z]/g; // eslint-disable-line no-control-regex
26
+
27
+ // Flatten an already-fitted output-preview body line so it occupies EXACTLY one
28
+ // physical terminal row — restoring the invariant the detail band's logical-line
29
+ // clamp and the writer's physicalRows erase math both depend on. formatOutputPreview
30
+ // truncated the line to the render width, but it (via termWidth) counts a TAB as
31
+ // 1 column while the terminal advances it to the next 8-col tab stop, so a
32
+ // "fitted" TAB-bearing line still WRAPS to ≥2 rows. Two flattening steps, both
33
+ // no-ops on plain ASCII (so clean output stays byte-identical):
34
+ // 1. Replace every C0/DEL control (TAB included) with a space — 1:1, so the
35
+ // already-fitted visible width is unchanged. Identical to the writer's
36
+ // _fitOneRow control-char pass that every OTHER live row already gets.
37
+ // 2. Drop stray NON-SGR escapes (e.g. `\x1b[K`): displayRows/termWidth strip
38
+ // only `…m` sequences, so a surviving non-'m' CSI would inflate the measured
39
+ // width past cols and re-introduce a phantom second row. SGR (`…m`) is kept
40
+ // so captured color survives. This closes the stripAnsi-only-'m' gap.
41
+ function _flattenBandLine(line) {
42
+ let s = String(line == null ? '' : line);
43
+ if (_BAND_CONTROL_CHARS.test(s)) {
44
+ _BAND_CONTROL_CHARS.lastIndex = 0; // .test on a /g regex is stateful
45
+ s = s.replace(_BAND_CONTROL_CHARS, ' ');
46
+ }
47
+ if (s.indexOf('\x1b') !== -1) {
48
+ s = s.replace(_CSI_SEQ, (m) => (m.endsWith('m') ? m : ''));
49
+ }
50
+ return s;
51
+ }
52
+
9
53
 
10
54
  function safeContent(text) {
11
55
  if (!text) return '';
@@ -31,9 +75,9 @@ function _dimPaths(text) {
31
75
  if (trail) {
32
76
  const core = m.slice(0, -trail[0].length);
33
77
  if (!core) return m;
34
- return `${UI_THEME.subtle}${core}${UI_THEME.default}${trail[0]}`;
78
+ return `${c(UI_THEME.subtle)}${core}${c(UI_THEME.default)}${trail[0]}`;
35
79
  }
36
- return `${UI_THEME.subtle}${m}${UI_THEME.default}`;
80
+ return `${c(UI_THEME.subtle)}${m}${c(UI_THEME.default)}`;
37
81
  });
38
82
  }
39
83
 
@@ -54,9 +98,13 @@ const BG_USER = '\x1b[48;5;237m';
54
98
  // atomic chunk.
55
99
  function _buildUser(content, ts) {
56
100
  const time = _fmtTime(ts);
57
- let out = `\n${FG_CYAN}▸ You${RST} ${DIM}${time}${RST}\n`;
101
+ let out = `\n${c(FG_CYAN)}▸ You${c(RST)} ${c(DIM)}${time}${c(RST)}\n`;
58
102
  for (const line of (content || '').split('\n')) {
59
- out += `${BG_USER} ${line}\x1b[K${RST}\n`;
103
+ // The \x1b[K (erase-to-EOL) only exists to fill the bg tint to the screen
104
+ // edge — pointless with no bg, and it's not an SGR so stripAnsi can't reach
105
+ // it. Drop it when color is off so a NO_COLOR bubble carries ZERO escapes.
106
+ const el = colorEnabled() ? '\x1b[K' : '';
107
+ out += `${c(BG_USER)} ${line}${el}${c(RST)}\n`;
60
108
  }
61
109
  out += '\n';
62
110
  return out;
@@ -64,10 +112,12 @@ function _buildUser(content, ts) {
64
112
 
65
113
  function _buildAI(content, ts) {
66
114
  const time = _fmtTime(ts);
67
- let out = `\n${FG_GREEN}▸ AI-agent${RST} ${DIM}${time}${RST}\n`;
68
- for (const line of (content || '').split('\n')) {
69
- out += ` ${line}\n`;
70
- }
115
+ let out = `\n${colorize(FG_GREEN)}▸ AI-agent${colorize(RST)} ${colorize(DIM)}${time}${colorize(RST)}\n`;
116
+ // Drive the SAME StreamMarkdown the live streaming path uses, fed line-by-line
117
+ // then flushed, so a replayed (--resume / history) assistant turn renders
118
+ // byte-identical to the live one.
119
+ const block = renderBlock(content);
120
+ if (block) out += block + '\n';
71
121
  out += '\n';
72
122
  return out;
73
123
  }
@@ -82,10 +132,18 @@ class ChatHistory {
82
132
  this._didStream = false;
83
133
  this._msgById = {};
84
134
  this._msgLineCount = {};
85
- this._toolExpanded = {};
86
- this._lastExpandableId = null;
87
- this._toolIdCounter = 0;
88
135
  this._messages = [];
136
+ // The single deferred output-preview slot. While set, the held detail lives
137
+ // in the writer's redrawable detail band (not scrollback). Shape: { msg,
138
+ // lines }. Committed once at a turn boundary (or auto-flushed before any new
139
+ // scrollback this class emits), then cleared back to null. The band is
140
+ // installed once and committed once — never redrawn in place.
141
+ this._deferred = null;
142
+ // Per-instance Markdown styler for agent narration. Holds the cross-line
143
+ // fenced-code-block state so a ``` block split across streamToken calls is
144
+ // styled correctly; reset at every turn boundary (clearStreamingContent /
145
+ // clearMessages).
146
+ this._md = new StreamMarkdown();
89
147
  // Callback into the UI orchestrator to refresh the live region (so the
90
148
  // in-progress streaming line and chrome re-render together). Set by
91
149
  // createUI; safe to leave null for headless/test contexts.
@@ -106,6 +164,23 @@ class ChatHistory {
106
164
  isStreaming() { return this._streamActive; }
107
165
 
108
166
  _commit(text) { writer.scrollback(text); }
167
+ // Phase 7b detail-band seams (overridable in tests, like _commit): install /
168
+ // replace the live detail band, and atomically commit it to scrollback.
169
+ _setDetail(lines) { writer.setDetail(lines); }
170
+ _commitDetail(text) { writer.commitDetail(text); }
171
+
172
+ // Commit an ALREADY-STYLED single line straight to scrollback, with no
173
+ // reformatting and without touching `_messages` / live-region state. Used by
174
+ // replay (Phase 6c-ii) to land an aggregated `✓ web · …` web-activity summary
175
+ // exactly as the live path's `writer.endActivity(id, line)` does — both route
176
+ // the same styled string through `writer.scrollback` (which appends the single
177
+ // trailing newline), so a replayed web summary is byte-identical to the live
178
+ // committed one. Deliberately NOT funnelled through summarizeToolResult.
179
+ addRawLine(line) {
180
+ if (line == null || line === '') return;
181
+ this._flushStream();
182
+ this._commit(line);
183
+ }
109
184
 
110
185
  _notifyLive() {
111
186
  if (this._onLiveUpdate) {
@@ -118,6 +193,11 @@ class ChatHistory {
118
193
  const content = safeContent(msg.content || '');
119
194
  if ((msg.role === 'assistant' || msg.role === 'system') && !content.trim()) return;
120
195
 
196
+ // Phase 7b — any held detail band is the PREVIOUS op's output; commit it to
197
+ // scrollback before this new bubble so ordering is preserved (tool output
198
+ // above the message that follows it). No-op when nothing is deferred.
199
+ if (this._deferred) this.commitDeferredDetail();
200
+
121
201
  // Any in-progress stream must close before we emit a new scrollback
122
202
  // block — otherwise the stream's partial line would sit between the
123
203
  // header and body of the new bubble.
@@ -135,10 +215,14 @@ class ChatHistory {
135
215
  } else if (msg.role === 'shell') {
136
216
  const cmd = msg.cmd || '';
137
217
  const shellOut = safeContent(msg.content || '');
138
- out = `\n${DIM} $ ${cmd}${RST}\n`;
218
+ // Replayed shell preview (/history, --resume). Was wholesale-DIM and
219
+ // illegible; now the `$` command line carries the shell category colour
220
+ // and the output is subtle (one step up from dim) so it stays readable.
221
+ const shellColor = (UI_THEME.categories && UI_THEME.categories.shell) || UI_THEME.accent;
222
+ out = `\n ${c(shellColor)}$ ${cmd}${c(RST)}\n`;
139
223
  if (shellOut.trim()) {
140
224
  for (const line of shellOut.split('\n')) {
141
- if (line.trim()) out += `${DIM} ${line}${RST}\n`;
225
+ if (line.trim()) out += ` ${c(UI_THEME.subtle)}${line}${c(RST)}\n`;
142
226
  }
143
227
  }
144
228
  out += '\n';
@@ -154,23 +238,53 @@ class ChatHistory {
154
238
  // to a single live-activity-style line. Callers that pass `output`
155
239
  // (debug blocks, live-activity error pass-through) keep the legacy
156
240
  // header chrome.
157
- if (msg.content && !msg.output) {
241
+ //
242
+ // Phase 6a — replay fidelity. A saved native tool message may carry a
243
+ // serialized display descriptor (`_display`). When present and its
244
+ // version is known, render the result line (and any diff / output detail)
245
+ // through the SAME renderOperation a fresh turn uses — full fidelity
246
+ // (real diff, real duration, real status), not the lossy summary. A
247
+ // missing / unknown-version core yields null here → the legacy
248
+ // summarizeToolResult fall-through below, unchanged.
249
+ // Phase 6c-i — a web op now persists a {v:1,kind:'web',…} core in `_display`
250
+ // (it used to persist nothing). Treat a web-core as "no descriptor" so it
251
+ // takes the SAME legacy summarizeToolResult fall-through a web message took
252
+ // before 6c-i — byte-identical until 6c-ii flips this to aggregation.
253
+ const restored = (msg._display && !isWebCore(msg._display)) ? descriptorFromStored(msg._display) : null;
254
+ if (restored) {
255
+ out = renderOperation(restored, { mode: 'ansi', phase: 'result' }) + '\n';
256
+ lineCount = 1;
257
+ const detail = restored.detail;
258
+ if (detail && detail.kind === 'diff') {
259
+ // The file-edit diff, expanded exactly as the live path commits it.
260
+ const diffStr = renderOperation(restored, { mode: 'ansi', phase: 'detail', maxLines: msg.diffMaxLines });
261
+ if (diffStr) {
262
+ out += diffStr + '\n';
263
+ lineCount += diffStr.split('\n').length;
264
+ }
265
+ } else if (detail && detail.kind === 'output') {
266
+ // Route the shell/MCP/subagent preview body through the SAME output
267
+ // branch below (identical DIM/indent chrome + collapsed hint).
268
+ msg.output = detail.payload.body;
269
+ if (!Number.isInteger(msg.previewLines) || msg.previewLines <= 0) msg.previewLines = 5;
270
+ }
271
+ } else if (msg.content && !msg.output) {
158
272
  const summary = safeContent(summarizeToolResult(msg.content));
159
273
  if (summary) {
160
274
  const indicator = msg.isError
161
- ? `${UI_THEME.error}${UI_ICONS.error}${RST}`
162
- : `${UI_THEME.success}${UI_ICONS.success}${RST}`;
163
- const sep = ` ${DIM}·${RST} `;
275
+ ? `${c(UI_THEME.error)}${UI_ICONS.error}${c(RST)}`
276
+ : `${c(UI_THEME.success)}${UI_ICONS.success}${c(RST)}`;
277
+ const sep = ` ${c(DIM)}·${c(RST)} `;
164
278
  const styled = summary.split(' · ').map((p) => _dimPaths(p)).join(sep);
165
279
  out = ` ${indicator} ${styled}\n`;
166
280
  lineCount = 1;
167
281
  }
168
282
  } else if (content) {
169
283
  const indicator = msg.isError
170
- ? `${UI_THEME.error}${UI_ICONS.error}${RST}`
171
- : `${UI_THEME.success}${UI_ICONS.success}${RST}`;
284
+ ? `${c(UI_THEME.error)}${UI_ICONS.error}${c(RST)}`
285
+ : `${c(UI_THEME.success)}${UI_ICONS.success}${c(RST)}`;
172
286
  const category = TOOL_CATEGORIES[msg.tag] || msg.tag || 'tool';
173
- const head = `${UI_THEME.accent}${category}${UI_THEME.muted}:${RST}`;
287
+ const head = `${c(UI_THEME.accent)}${category}${c(UI_THEME.muted)}:${c(RST)}`;
174
288
  const desc = _dimPaths(content);
175
289
  out = ` ${indicator} ${head} ${desc}\n`;
176
290
  lineCount = 1;
@@ -178,7 +292,18 @@ class ChatHistory {
178
292
  out = '';
179
293
  lineCount = 0;
180
294
  }
181
- if (msg.output) {
295
+ if (msg.output && Number.isInteger(msg.previewLines) && msg.previewLines > 0) {
296
+ // Phase 5 — collapsed output preview (shell/MCP/subagent). The body is
297
+ // committed line-by-line to SCROLLBACK (not the live region), each line
298
+ // fitted to one physical row by formatOutputPreview, so the Phase-4
299
+ // single-row invariant holds. Rendered ONCE, statically: the first
300
+ // previewLines + a `… N more lines` hint, with no interactive affordance.
301
+ const { lines: pvLines } = this._renderOutputPreviewLines(msg);
302
+ for (const ol of pvLines) {
303
+ out += ol + '\n';
304
+ lineCount++;
305
+ }
306
+ } else if (msg.output) {
182
307
  const wrapAt = Math.max(60, getCols() - 8);
183
308
  const outLines = [];
184
309
  for (const raw of msg.output.split('\n')) {
@@ -192,32 +317,19 @@ class ChatHistory {
192
317
  }
193
318
  }
194
319
  const truncatable = outLines.length > MAX_TOOL_DISPLAY;
195
-
196
- if (truncatable && !msg.id) {
197
- msg.id = `tool-${++this._toolIdCounter}`;
198
- }
199
- if (truncatable && msg.id) {
200
- this._msgById[msg.id] = msg;
201
- this._lastExpandableId = msg.id;
202
- }
203
-
204
- const expanded = truncatable && msg.id && this._toolExpanded[msg.id];
205
- const visible = (truncatable && !expanded) ? outLines.slice(0, MAX_TOOL_DISPLAY) : outLines;
320
+ const visible = truncatable ? outLines.slice(0, MAX_TOOL_DISPLAY) : outLines;
206
321
  const remaining = outLines.length - visible.length;
207
322
 
208
- const outerOpen = msg.tag === 'debug' ? ' ' : `${DIM} `;
209
- const outerClose = msg.tag === 'debug' ? '' : RST;
323
+ const outerOpen = msg.tag === 'debug' ? ' ' : `${c(DIM)} `;
324
+ const outerClose = msg.tag === 'debug' ? '' : c(RST);
210
325
  for (const ol of visible) { out += `${outerOpen}${ol}${outerClose}\n`; lineCount++; }
211
326
  if (remaining > 0) {
212
- out += `${DIM} … ${remaining} more lines ${FG_DARK}(ctrl+o to expand ${remaining} rows)${RST}\n`;
213
- lineCount++;
214
- } else if (truncatable && expanded) {
215
- out += `${DIM} ${FG_DARK}(ctrl+o to collapse)${RST}\n`;
327
+ out += `${c(DIM)} … ${remaining} more lines${c(RST)}\n`;
216
328
  lineCount++;
217
329
  }
218
330
  }
219
331
  } else if (msg.role === 'permission') {
220
- out = `\n ${UI_THEME.warning}${UI_ICONS.warn}${RST} ${UI_THEME.warning}Permission required${RST}${UI_THEME.muted}:${RST} ${content}\n`;
332
+ out = `\n ${c(UI_THEME.warning)}${UI_ICONS.warn}${c(RST)} ${c(UI_THEME.warning)}Permission required${c(RST)}${c(UI_THEME.muted)}:${c(RST)} ${content}\n`;
221
333
  } else {
222
334
  // Fallback for role: 'system' and anything unrecognised.
223
335
  const lines = content.split('\n');
@@ -225,25 +337,25 @@ class ChatHistory {
225
337
  const stripGlyph = (s) => s.replace(/^[✓✗✕⚠]\s*/, '');
226
338
  let rendered;
227
339
  if (msg.isError && !msg.isWarning) {
228
- rendered = ` ${UI_THEME.error}${UI_ICONS.error}${RST} ${UI_THEME.error}${stripGlyph(first)}${RST}`;
340
+ rendered = ` ${c(UI_THEME.error)}${UI_ICONS.error}${c(RST)} ${c(UI_THEME.error)}${stripGlyph(first)}${c(RST)}`;
229
341
  } else if (msg.isWarning) {
230
- rendered = ` ${UI_THEME.warning}${UI_ICONS.warn}${RST} ${UI_THEME.warning}${stripGlyph(first)}${RST}`;
342
+ rendered = ` ${c(UI_THEME.warning)}${UI_ICONS.warn}${c(RST)} ${c(UI_THEME.warning)}${stripGlyph(first)}${c(RST)}`;
231
343
  } else if (_isAutoApproveMeta(first)) {
232
- const body = first.replace(/(auto-approv\w*)/gi, (m) => `${UI_THEME.warning}${m}${UI_THEME.subtle}`);
233
- rendered = ` ${UI_THEME.subtle}${body}${RST}`;
344
+ const body = first.replace(/(auto-approv\w*)/gi, (m) => `${c(UI_THEME.warning)}${m}${c(UI_THEME.subtle)}`);
345
+ rendered = ` ${c(UI_THEME.subtle)}${body}${c(RST)}`;
234
346
  } else if (first.startsWith('✓')) {
235
- rendered = ` ${UI_THEME.success}${UI_ICONS.success}${RST} ${first.slice(1).trimStart()}`;
347
+ rendered = ` ${c(UI_THEME.success)}${UI_ICONS.success}${c(RST)} ${first.slice(1).trimStart()}`;
236
348
  } else if (first.startsWith('✗')) {
237
- rendered = ` ${UI_THEME.error}${UI_ICONS.error}${RST} ${UI_THEME.error}${first.slice(1).trimStart()}${RST}`;
349
+ rendered = ` ${c(UI_THEME.error)}${UI_ICONS.error}${c(RST)} ${c(UI_THEME.error)}${first.slice(1).trimStart()}${c(RST)}`;
238
350
  } else {
239
- rendered = ` ${UI_THEME.subtle}${UI_ICONS.bullet}${RST} ${UI_THEME.subtle}${first}${RST}`;
351
+ rendered = ` ${c(UI_THEME.subtle)}${UI_ICONS.bullet}${c(RST)} ${c(UI_THEME.subtle)}${first}${c(RST)}`;
240
352
  }
241
353
  out = `${rendered}\n`;
242
354
  const contColor = (msg.isError && !msg.isWarning) ? UI_THEME.error
243
355
  : msg.isWarning ? UI_THEME.warning
244
356
  : UI_THEME.subtle;
245
357
  for (let i = 1; i < lines.length; i++) {
246
- out += ` ${contColor}${lines[i]}${RST}\n`;
358
+ out += ` ${c(contColor)}${lines[i]}${c(RST)}\n`;
247
359
  }
248
360
  lineCount = lines.length;
249
361
  }
@@ -260,12 +372,15 @@ class ChatHistory {
260
372
  // the live region while it's still in-flight.
261
373
  streamToken(token) {
262
374
  if (!token) return;
375
+ // Phase 7b — the assistant answer is about to flow into scrollback; commit
376
+ // the prior op's held detail band first so it stays above the answer text.
377
+ if (this._deferred) this.commitDeferredDetail();
263
378
  if (!this._streamActive) {
264
379
  // Emit header immediately so users see the new AI turn before any
265
380
  // tokens arrive. No blank line before the header since scrollback
266
381
  // flows continuously.
267
382
  const time = _fmtTime(new Date());
268
- this._commit(`\n${FG_GREEN}▸ AI-agent${RST} ${DIM}${time}${RST}\n`);
383
+ this._commit(`\n${colorize(FG_GREEN)}▸ AI-agent${colorize(RST)} ${colorize(DIM)}${time}${colorize(RST)}\n`);
269
384
  this._streamActive = true;
270
385
  this._streamStart = new Date();
271
386
  this._streamPartial = '';
@@ -274,7 +389,11 @@ class ChatHistory {
274
389
  let nlIdx;
275
390
  while ((nlIdx = this._streamPartial.indexOf('\n')) >= 0) {
276
391
  const line = this._streamPartial.slice(0, nlIdx);
277
- this._commit(' ' + line + '\n');
392
+ // Style each COMPLETE line before committing to scrollback. The styler
393
+ // returns null while buffering a fenced code-block body (the box is
394
+ // emitted whole at the closing ```), so nothing is committed then.
395
+ const styled = this._md.feedLine(line);
396
+ if (styled !== null) this._commit(styled + '\n');
278
397
  this._streamPartial = this._streamPartial.slice(nlIdx + 1);
279
398
  }
280
399
  this._notifyLive();
@@ -283,9 +402,17 @@ class ChatHistory {
283
402
  _flushStream() {
284
403
  if (!this._streamActive) return;
285
404
  if (this._streamPartial !== '') {
286
- this._commit(' ' + this._streamPartial + '\n');
405
+ // The trailing partial is the turn's last (newline-less) line — style it
406
+ // as a complete line, mirroring the live per-line path exactly.
407
+ const styled = this._md.feedLine(this._streamPartial);
408
+ if (styled !== null) this._commit(styled + '\n');
287
409
  this._streamPartial = '';
288
410
  }
411
+ // Close any open inline span / still-buffering fenced code block so no
412
+ // stranded SGR or unclosed box reaches immutable scrollback.
413
+ const tail = this._md.flush();
414
+ if (tail !== null) this._commit(tail + '\n');
415
+ this._md.reset();
289
416
  this._commit('\n');
290
417
  this._streamActive = false;
291
418
  this._streamStart = null;
@@ -294,10 +421,14 @@ class ChatHistory {
294
421
 
295
422
  clearStreamingContent() {
296
423
  this._flushStream();
424
+ this._md.reset();
297
425
  this._notifyLive();
298
426
  }
299
427
 
300
428
  finalizeLastMessage(cleanContent) {
429
+ // Phase 7b — commit any held detail band before the terminal assistant
430
+ // bubble (covers the non-streaming path, where no streamToken fired).
431
+ if (this._deferred) this.commitDeferredDetail();
301
432
  const wasStreaming = this._streamActive;
302
433
  const streamStart = this._streamStart;
303
434
  if (wasStreaming) {
@@ -329,9 +460,12 @@ class ChatHistory {
329
460
  this._didStream = false;
330
461
  this._msgById = {};
331
462
  this._msgLineCount = {};
332
- this._toolExpanded = {};
333
- this._lastExpandableId = null;
334
463
  this._messages = [];
464
+ this._md.reset();
465
+ // Drop any held detail band (it belongs to the cleared session)
466
+ // and erase it from the writer's detail region before the viewport wipe.
467
+ this._deferred = null;
468
+ this._setDetail([]);
335
469
  // Wipe the terminal's visible scrollback and the saved scrollback buffer
336
470
  // so the cleared state feels clean. Serialized through the writer so
337
471
  // the clear can't land inside a pending scrollback burst, then redraw
@@ -344,18 +478,69 @@ class ChatHistory {
344
478
  this._notifyLive();
345
479
  }
346
480
 
347
- // In append-only scrollback we cannot rewrite a previous bubble in place;
348
- // these methods now simply re-render the updated version as new
349
- // scrollback. Expand/collapse prints the full (or summary) view below the
350
- // prior truncated view. This is a small visual regression from the old
351
- // scroll-region mode in exchange for eliminating the torn-frame race.
352
- toggleLastExpand() {
353
- const id = this._lastExpandableId;
354
- if (!id) return;
355
- const msg = this._msgById[id];
356
- if (!msg) return;
357
- this._toolExpanded[id] = !this._toolExpanded[id];
358
- this.addMessage(msg);
481
+ // Render the chrome for a collapsed output-preview body (the Phase-5 DIM /
482
+ // subtle-indent lines + a static `… N more lines` hint) as an ARRAY of styled
483
+ // lines (no trailing newline each). This is the single source for the immediate
484
+ // addMessage commit and the deferred (live-band) preview. The preview is always
485
+ // collapsed and static there is no expand affordance; full viewing is deferred
486
+ // to the transcript viewer.
487
+ _renderOutputPreviewLines(msg) {
488
+ // Fit to the width LESS the 5-col indent so indent+content stays within one
489
+ // physical row at the render width (the Phase-4 single-row invariant). Each
490
+ // emitted preview line is therefore exactly one physical row.
491
+ const preview = formatOutputPreview(msg.output, {
492
+ previewLines: msg.previewLines,
493
+ cols: Math.max(1, getCols() - 5),
494
+ });
495
+ // Flatten each fitted line to exactly one physical row, so the band's
496
+ // logical-line count == its physical-row count (the load-bearing invariant the
497
+ // writer's erase/commit math depends on). Without this a TAB-bearing line
498
+ // counts as 1 here but wraps to ≥2 rows on screen, desyncing the writer's
499
+ // physicalRows erase math → duplication on wrapping output. No-op on plain
500
+ // ASCII, so clean output stays byte-identical. Applied here (the single source
501
+ // for the live band, the immediate/committed scrollback, and replay) so every
502
+ // render path is byte-consistent.
503
+ const bodyLines = preview.lines.map(_flattenBandLine);
504
+ const lines = [];
505
+ for (const ol of bodyLines) {
506
+ lines.push(`${c(DIM)} ${c(UI_THEME.subtle)}${ol}${c(RST)}`);
507
+ }
508
+ if (preview.hiddenCount > 0) {
509
+ const plural = preview.hiddenCount === 1 ? 'line' : 'lines';
510
+ lines.push(`${c(DIM)} … ${preview.hiddenCount} more ${plural}${c(RST)}`);
511
+ }
512
+ return { lines, truncatable: preview.truncatable };
513
+ }
514
+
515
+ // DEFER the output-preview detail into the writer's redrawable detail band
516
+ // instead of committing it straight to immutable scrollback. It stays in the
517
+ // band until a turn boundary commits it once. The collapsed lines are rendered
518
+ // once here and committed verbatim — the band is installed once and committed
519
+ // once, never redrawn in place. Only the output-preview detail is deferred —
520
+ // the result line (endActivity) and the diff (scrollback) still commit
521
+ // immediately in chat-turn.js, unchanged.
522
+ deferToolOutput(msg) {
523
+ // Single slot: a new preview commits any previously-held one first (the
524
+ // next-op boundary normally already did this; belt-and-suspenders).
525
+ if (this._deferred) this.commitDeferredDetail();
526
+ const { lines } = this._renderOutputPreviewLines(msg);
527
+ this._deferred = { msg, lines };
528
+ this._setDetail(lines);
529
+ this._notifyLive();
530
+ }
531
+
532
+ // Commit the held detail band to scrollback and clear the slot, atomically via
533
+ // the writer (no duplicate frame, no stranded rows). No-op when nothing is held.
534
+ // Called at each turn boundary (next-op start, assistant answer, turn end) and
535
+ // auto-fired before any new scrollback this class emits, so ordering is always
536
+ // preserved. The held collapsed lines are committed verbatim — they already
537
+ // carry no interactive affordance.
538
+ commitDeferredDetail() {
539
+ if (!this._deferred) return;
540
+ const lines = this._deferred.lines;
541
+ const text = lines && lines.length ? lines.join('\n') + '\n' : '';
542
+ this._deferred = null;
543
+ this._commitDetail(text);
359
544
  }
360
545
 
361
546
  rerenderById(id) {
@@ -365,17 +550,14 @@ class ChatHistory {
365
550
  }
366
551
 
367
552
  collapseById(id) {
368
- // Nothing to erase in the append-only model — drop the tracking so a
369
- // future expand toggle doesn't target the stale entry.
553
+ // Nothing to erase in the append-only model — drop the tracking.
370
554
  delete this._msgById[id];
371
555
  delete this._msgLineCount[id];
372
- if (this._lastExpandableId === id) this._lastExpandableId = null;
373
556
  }
374
557
 
375
558
  removeById(id) {
376
559
  delete this._msgById[id];
377
560
  delete this._msgLineCount[id];
378
- if (this._lastExpandableId === id) this._lastExpandableId = null;
379
561
  }
380
562
 
381
563
  invalidateCache() {}