@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
@@ -5,8 +5,15 @@ const readline = require('readline');
5
5
  const { RST, FG_YELLOW, FG_CYAN, FG_GRAY } = require('./ansi');
6
6
  const { ChatHistory } = require('./chat-history');
7
7
  const writer = require('./writer');
8
+ const { getCols } = require('./utils');
9
+ const { wrapPromptLines } = require('./format');
8
10
  const { registerTerminalCleanup } = require('./terminal');
9
11
 
12
+ // Cap a long ask_user question to this many wrapped lines in the modal header
13
+ // (mirrors the permission picker's MAX_DESC_LINES in permissions.js) so it can
14
+ // never overflow the live modal band; the rest collapses to "… N more lines".
15
+ const MAX_PROMPT_LINES = 12;
16
+
10
17
  function _createNoOpUI() {
11
18
  const chatHistory = {
12
19
  addMessage: (msg) => {
@@ -19,6 +26,10 @@ function _createNoOpUI() {
19
26
  clearStreamingContent: () => {},
20
27
  finalizeLastMessage: () => {},
21
28
  clearMessages: () => {},
29
+ // Phase 7b — no live region in the non-TTY no-op UI, so the deferred detail
30
+ // band is inert; the output preview just never displays. Present so the
31
+ // chat-turn boundary calls are safe in a non-TTY interactive run.
32
+ deferToolOutput: () => {}, commitDeferredDetail: () => {},
22
33
  scrollUp: () => {}, scrollDown: () => {},
23
34
  rerenderById: () => {},
24
35
  removeById: () => {},
@@ -62,7 +73,6 @@ function createUI(opts) {
62
73
  const { LayoutManager } = require('./layout');
63
74
  const { FullStatusBar } = require('./status-bar');
64
75
  const { InputField } = require('./input-field');
65
- const { interactiveSelect } = require('./select');
66
76
 
67
77
  const layout = new LayoutManager();
68
78
  const chatHistory = new ChatHistory();
@@ -153,34 +163,67 @@ function createUI(opts) {
153
163
 
154
164
  // ── captureSelect (modal menu) ───────────────────────────────────────────────
155
165
  //
156
- // Numbered-options picker for tools like ask_user. interactiveSelect
157
- // renders each frame into the writer's modal region (above status,
158
- // below scrollback) and routes keys through the input field's
159
- // captureNavigation API so the menu cohabits with the live region
160
- // instead of taking over the screen, and Enter/Esc resolve in place.
166
+ // ask_user-PRIVATE numbered-options picker (the model/rewind/permission
167
+ // pickers call interactiveSelect directly this wrapper is NOT on that path).
168
+ // It manages its OWN modal frame (mirroring permissions.js requestPermission)
169
+ // rather than going through interactiveSelect, because it prepends the QUESTION
170
+ // as NON-SELECTABLE header rows above the options: navigation only ever cycles
171
+ // the options, so the prompt rows can never be landed on or returned. The frame
172
+ // redraws in place in the writer's modal region (above status, below
173
+ // scrollback) and keys route through the input field's captureNavigation API,
174
+ // so the menu cohabits with the live region instead of taking over the screen.
175
+ //
176
+ // `menu` = { prompt, options }. `prompt` (the question prose, already stripped
177
+ // of the numbered options by the executor) is word-wrapped to terminal width
178
+ // and clamped to MAX_PROMPT_LINES so a long question can't overflow the band.
161
179
  inputField.captureSelect = (menu) => new Promise((resolve) => {
180
+ const options = (menu && Array.isArray(menu.options)) ? menu.options : [];
162
181
  if (!process.stdin.isTTY) {
163
- resolve(menu.options[0]);
182
+ resolve(options[0]);
164
183
  return;
165
184
  }
166
- interactiveSelect(
167
- menu.options,
168
- (opt, isSelected) => isSelected
169
- ? ` ${FG_YELLOW}❯${RST} ${FG_CYAN}${opt}${RST}`
170
- : ` ${FG_GRAY}${opt}${RST}`,
171
- {
172
- initialIndex: 0,
173
- onExpand: () => chatHistory.toggleLastExpand(),
174
- captureNavigation: (handler) => {
175
- inputField.captureNavigation(handler);
176
- return () => inputField.releaseNavigation();
177
- },
185
+ if (options.length === 0) {
186
+ resolve(null);
187
+ return;
188
+ }
189
+
190
+ // Wrap + clamp the question into non-selectable header rows.
191
+ const promptLines = wrapPromptLines(menu && menu.prompt, {
192
+ cols: Math.max(20, getCols() - 4),
193
+ maxLines: MAX_PROMPT_LINES,
194
+ });
195
+
196
+ let idx = 0;
197
+ const buildLines = () => {
198
+ const lines = [];
199
+ for (const p of promptLines) lines.push(` ${FG_GRAY}${p}${RST}`);
200
+ if (promptLines.length) lines.push('');
201
+ for (let i = 0; i < options.length; i++) {
202
+ lines.push(i === idx
203
+ ? ` ${FG_YELLOW}❯${RST} ${FG_CYAN}${options[i]}${RST}`
204
+ : ` ${FG_GRAY}${options[i]}${RST}`);
205
+ }
206
+ return lines;
207
+ };
208
+
209
+ writer.setModal(buildLines());
210
+ inputField.captureNavigation((action) => {
211
+ if (action === 'prev') {
212
+ idx = (idx - 1 + options.length) % options.length;
213
+ writer.setModal(buildLines());
214
+ } else if (action === 'next') {
215
+ idx = (idx + 1) % options.length;
216
+ writer.setModal(buildLines());
217
+ } else if (action === 'select' || action === 'cancel') {
218
+ // Order matters (mirrors interactiveSelect): clearModal first while the
219
+ // caret is still suppressed, then releaseNavigation so the host's render
220
+ // hooks restore the input caret on the next setLive frame.
221
+ writer.clearModal();
222
+ inputField.releaseNavigation();
223
+ // Cancel → last option (typically "No"/decline) so callers don't need to
224
+ // special-case cancellation — matches the prior contract.
225
+ resolve(action === 'select' ? options[idx] : options[options.length - 1]);
178
226
  }
179
- ).then((idx) => {
180
- // Cancel returns null. Match the prior contract: pick the last
181
- // option (typically "No"/decline) so callers don't need to
182
- // special-case cancellation.
183
- resolve(idx === null ? menu.options[menu.options.length - 1] : menu.options[idx]);
184
227
  });
185
228
  });
186
229
 
package/lib/ui/diff.js CHANGED
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
- const { FG_DARK, FG_RED, FG_GREEN, FG_GRAY, FG_YELLOW, FG_TEAL, RST, THEME, EL, hasTruecolor } = require('./ansi');
3
+ const { RST, EL, hasTruecolor } = require('./ansi');
4
4
  const { getCols, stripAnsi, termWidth } = require('./utils');
5
- const { DIFF_THEME, UI_THEME } = require('./theme');
5
+ const { DIFF_THEME, UI_THEME, THEME, FG_CODE_LANG, colorEnabled } = require('./theme');
6
6
  const writer = require('./writer');
7
7
 
8
8
  function diffLines(oldLines, newLines) {
@@ -67,7 +67,9 @@ function renderDiff(oldText, newText, filePath, opts) {
67
67
  // Resolve the palette once per render. Truecolor is used when the terminal
68
68
  // advertises it via COLORTERM; otherwise fall back to 256-color.
69
69
  const useTC = hasTruecolor();
70
- const a = (code) => isTTY ? code : '';
70
+ // Strip colour for non-TTY AND NO_COLOR (colorEnabled folds in both); the
71
+ // diff body then renders as plain text with no ANSI leaking into piped output.
72
+ const a = (code) => colorEnabled() ? code : '';
71
73
  const R = a(RST);
72
74
  const EL_ = a(EL);
73
75
  const pickBg = (t) => useTC && t.bgTC ? t.bgTC : t.bg256;
@@ -77,7 +79,7 @@ function renderDiff(oldText, newText, filePath, opts) {
77
79
  context: { bg: '', signFg: '', sign: DIFF_THEME.context.sign },
78
80
  ln: a(DIFF_THEME.lineNumber),
79
81
  code: a(DIFF_THEME.code),
80
- hdr: a(DIFF_THEME.header),
82
+ // (P.hdr removed in Phase 2 D3 — the diff no longer emits a path header.)
81
83
  frame: a(DIFF_THEME.frame),
82
84
  };
83
85
 
@@ -85,7 +87,7 @@ function renderDiff(oldText, newText, filePath, opts) {
85
87
  const newLines = newText.split('\n');
86
88
  const isNewFile = oldText === '';
87
89
 
88
- let diff;
90
+ let diff = null;
89
91
  if (!isNewFile) {
90
92
  diff = (oldLines.length > 200 || newLines.length > 200)
91
93
  ? diffLinesHashed(oldLines, newLines) : diffLines(oldLines, newLines);
@@ -129,27 +131,67 @@ function renderDiff(oldText, newText, filePath, opts) {
129
131
  return `${GUTTER}${P.frame}${label}${R}`;
130
132
  }
131
133
 
132
- const out = [];
133
- // Minimal file header bold blue path, no box. The surrounding UI owns
134
- // any tool-label framing; this line is the only header the diff emits.
135
- out.push(`${GUTTER}${P.hdr}${filePath}${R}`);
136
-
134
+ // Flatten the change into per-line entries with line numbers resolved. Both
135
+ // the full hunk renderer and the capped head+tail renderer consume this. A
136
+ // new file is every-line-added; an existing file annotates the LCS diff.
137
+ let annotated;
137
138
  if (isNewFile) {
138
- // Meta-label "(new file)" rendered in the shared subtle palette so it
139
- // recedes relative to the hunk marker itself.
140
- const subtle = a(UI_THEME.subtle);
141
- out.push(`${GUTTER}${P.frame}@@ -0,0 +1,${newLines.length} @@ ${R}${subtle}(new file)${R}`);
142
- let ln = 1;
143
- for (const line of newLines) out.push(makeLine(String(ln++), 'added', line));
139
+ annotated = newLines.map((text, i) => ({ type: 'add', text, oldLine: null, newLine: i + 1 }));
144
140
  } else {
145
141
  let oldLn = 1, newLn = 1;
146
- const annotated = diff.map((d) => {
142
+ annotated = diff.map((d) => {
147
143
  const e = { type: d.type, text: d.text, oldLine: null, newLine: null };
148
144
  if (d.type !== 'add') e.oldLine = oldLn++;
149
145
  if (d.type !== 'del') e.newLine = newLn++;
150
146
  return e;
151
147
  });
148
+ }
149
+
150
+ const renderEntry = (e) => {
151
+ if (e.type === 'del') return makeLine(String(e.oldLine), 'removed', e.text);
152
+ if (e.type === 'add') return makeLine(String(e.newLine), 'added', e.text);
153
+ return makeLine(String(e.newLine), 'context', e.text);
154
+ };
155
+
156
+ // Capped path (execution-time diffs). When the caller passes a positive
157
+ // `maxLines` and the edit touches MORE changed (+/-) lines than that, show the
158
+ // first DIFF_HEAD_RATIO of the budget and the last (1-ratio), eliding the
159
+ // middle with a `… K more changed lines (N total)` notice — mirroring the W.6
160
+ // shell head+tail discipline. A small edit (or a series of small edits, each
161
+ // short) never trips this: it renders in full via the hunk path below.
162
+ const DIFF_HEAD_RATIO = 0.6;
163
+ const maxLines = (opts && Number.isInteger(opts.maxLines) && opts.maxLines > 0) ? opts.maxLines : 0;
164
+ if (maxLines) {
165
+ const changed = annotated.filter((e) => e.type !== 'same');
166
+ if (changed.length > maxLines) {
167
+ const head = Math.max(1, Math.round(maxLines * DIFF_HEAD_RATIO));
168
+ const tail = Math.max(1, maxLines - head);
169
+ const elided = changed.length - head - tail;
170
+ // D3 (Phase 2): no path header — the result line above already states the
171
+ // path. The body opens directly with the first changed line.
172
+ const capped = [];
173
+ for (let k = 0; k < head; k++) capped.push(renderEntry(changed[k]));
174
+ capped.push(isTTY
175
+ ? `${GUTTER}${P.frame}… ${elided} more changed lines (${changed.length} total)${R}`
176
+ : `… ${elided} more changed lines (${changed.length} total)`);
177
+ for (let k = changed.length - tail; k < changed.length; k++) capped.push(renderEntry(changed[k]));
178
+ return capped.join('\n');
179
+ }
180
+ }
152
181
 
182
+ const out = [];
183
+ // D3 (Phase 2): the diff no longer emits a path header — the result line
184
+ // above already states the path (the descriptor's `target`), so restating it
185
+ // here was pure duplication. The body opens with the @@ hunk ranges (and the
186
+ // "(new file)" marker for a new file), which carry information the path does not.
187
+
188
+ if (isNewFile) {
189
+ // Meta-label "(new file)" rendered in the shared subtle palette so it
190
+ // recedes relative to the hunk marker itself.
191
+ const subtle = a(UI_THEME.subtle);
192
+ out.push(`${GUTTER}${P.frame}@@ -0,0 +1,${newLines.length} @@ ${R}${subtle}(new file)${R}`);
193
+ for (const e of annotated) out.push(makeLine(String(e.newLine), 'added', e.text));
194
+ } else {
153
195
  const changedIdx = [];
154
196
  annotated.forEach((d, i) => { if (d.type !== 'same') changedIdx.push(i); });
155
197
 
@@ -172,11 +214,7 @@ function renderDiff(oldText, newText, filePath, opts) {
172
214
  const newCnt = hunk.filter((e) => e.newLine !== null).length;
173
215
  out.push(hunkSep(`@@ -${oldStart},${oldCnt} +${newStart},${newCnt} @@`));
174
216
 
175
- for (const e of hunk) {
176
- if (e.type === 'del') out.push(makeLine(String(e.oldLine), 'removed', e.text));
177
- else if (e.type === 'add') out.push(makeLine(String(e.newLine), 'added', e.text));
178
- else out.push(makeLine(String(e.newLine), 'context', e.text));
179
- }
217
+ for (const e of hunk) out.push(renderEntry(e));
180
218
  }
181
219
  }
182
220
 
@@ -192,11 +230,38 @@ function renderDiff(oldText, newText, filePath, opts) {
192
230
  return out.join('\n');
193
231
  }
194
232
 
233
+ // Execution-time file-edit diff. This is the SINGLE rendering site for the full
234
+ // diff of a mutating edit (write/append/edit_file/replace_in_file): the agent
235
+ // loop calls it from onToolEnd after the edit executes, so the diff renders for
236
+ // EVERY edit regardless of approval state (manual-approved, auto-approved) or
237
+ // entry mode (fresh / --resume / /history / /chats). The permission modal no
238
+ // longer carries the full diff, so the user sees it exactly once, here.
239
+ //
240
+ // `payload` carries { before, after, path } captured by the executor; `maxLines`
241
+ // is config.diff_max_lines (the changed-line cap). Returns the rendered diff
242
+ // string, or null when there is nothing to show — a failed edit (`error`), a
243
+ // missing/ malformed payload (e.g. a loaded-history turn, which never carries
244
+ // one, so past turns are NOT replayed), or a no-op edit.
245
+ function buildExecutionDiff(opts) {
246
+ const o = opts || {};
247
+ if (o.error) return null;
248
+ const p = o.diff;
249
+ if (!p || typeof p.before !== 'string' || typeof p.after !== 'string') return null;
250
+ if (p.before === p.after) return null;
251
+ const renderOpts = {
252
+ maxLines: (Number.isInteger(o.maxLines) && o.maxLines > 0) ? o.maxLines : 50,
253
+ };
254
+ if (Number.isInteger(o.inset) && o.inset >= 0) renderOpts.inset = o.inset;
255
+ const rendered = renderDiff(p.before, p.after, p.path || '', renderOpts);
256
+ if (!rendered || rendered === ' No changes detected') return null;
257
+ return rendered;
258
+ }
259
+
195
260
  function _mdInline(text) {
196
261
  let out = '', i = 0;
197
262
  while (i < text.length) {
198
263
  const c = text[i], c1 = i + 1 < text.length ? text[i+1] : '';
199
- if (c === '`') { const end = text.indexOf('`', i+1); if (end !== -1) { out += '\x1b[7m' + text.slice(i+1, end) + '\x1b[27m'; i = end+1; continue; } }
264
+ if (c === '`') { const end = text.indexOf('`', i+1); if (end !== -1) { out += FG_CODE_LANG + text.slice(i+1, end) + '\x1b[39m'; i = end+1; continue; } }
200
265
  if (c === '*' && c1 === '*') { const end = text.indexOf('**', i+2); if (end !== -1) { out += '\x1b[1m' + text.slice(i+2, end) + '\x1b[22m'; i = end+2; continue; } }
201
266
  if (c === '_' && c1 === '_') { const end = text.indexOf('__', i+2); if (end !== -1) { out += '\x1b[1m' + text.slice(i+2, end) + '\x1b[22m'; i = end+2; continue; } }
202
267
  if (c === '*' && c1 !== '*') { let end = -1; for (let j = i+1; j < text.length; j++) { if (text[j] === '*' && (j+1 >= text.length || text[j+1] !== '*')) { end = j; break; } } if (end !== -1) { out += '\x1b[3m' + text.slice(i+1, end) + '\x1b[23m'; i = end+1; continue; } }
@@ -225,7 +290,7 @@ function renderMarkdown(text) {
225
290
  if (inCode) {
226
291
  const t = line.trim();
227
292
  if (t.length === 3 && t[0] === '`' && t[1] === '`' && t[2] === '`') { output.push('└' + '─'.repeat(Math.max(1, cols - 2))); inCode = false; }
228
- else { output.push('│ ' + THEME.dim + line + THEME.reset); }
293
+ else { output.push('│ ' + line); }
229
294
  continue;
230
295
  }
231
296
  const trimmed = line.trim();
@@ -253,4 +318,4 @@ function renderMarkdown(text) {
253
318
  if (overflow > 0) writer.scrollback(THEME.dim + '[... ' + overflow + ' more lines]' + THEME.reset);
254
319
  }
255
320
 
256
- module.exports = { renderDiff, renderMarkdown, _mdInline };
321
+ module.exports = { renderDiff, buildExecutionDiff, renderMarkdown, _mdInline, _truncateByWidth };
@@ -0,0 +1,236 @@
1
+ 'use strict';
2
+
3
+ // File-activity process summary (a SECOND INSTANCE of the web-activity pattern).
4
+ //
5
+ // A warm-up phase of an agent turn often fires a long run of pure file reads
6
+ // (read_file) or directory listings (list_dir) back-to-back. By default each
7
+ // committed its own tool line ("✓ file · read index.html", "✓ file · read
8
+ // battlecity.js", …), flooding scrollback with one row per op. This module
9
+ // collapses a run of CONSECUTIVE same-type file ops into a SINGLE compact
10
+ // process-summary line —
11
+ //
12
+ // ✓ file · read ×10 (index.html, battlecity.js, …)
13
+ //
14
+ // — exactly the way `web-activity.js` collapses web_search/http_get. It is a
15
+ // parallel, independent instance of `createWebActivityTracker`; the web tracker
16
+ // is untouched.
17
+ //
18
+ // Scope (deliberately narrow — DECISION 3): ONLY `read_file` + `list_dir`. Both
19
+ // are pure reads with no diff and no output preview (their descriptors carry
20
+ // `detail: null`), so grouping them sidesteps the deferred-detail-band ordering.
21
+ // Other file tools keep their own per-op line.
22
+ //
23
+ // Two DIVERGENCES from the web tracker, both required by the append-only
24
+ // scrollback model:
25
+ // • GROUP KEY = a single shared key for BOTH read_file and list_dir, so a
26
+ // mixed read/list exploration phase collapses into ONE summary instead of
27
+ // fragmenting on every read↔list switch. A homogeneous run keeps its specific
28
+ // verb ("read ×N" / "list ×N"); a genuinely mixed run uses the neutral "file
29
+ // ×N". Any OTHER tool still breaks the run. The web tracker has a single key.
30
+ // • THRESHOLD decided at flush time. A group of 1–2 ops commits each op as its
31
+ // own normal result line (byte-identical to today); a group of 3+ commits ONE
32
+ // summary line. The web tracker always collapses. We can't retroactively pull
33
+ // already-committed lines into a group, so ALL commits defer to flush() where
34
+ // the final count is known.
35
+
36
+ const { UI_ICONS, resolveLineColors } = require('./theme');
37
+ const { RST, DIM } = require('./ansi');
38
+ const { getCols, termWidth, stripAnsi } = require('./utils');
39
+ const { truncateLine } = require('./format');
40
+ const { renderOperation } = require('./render-operation');
41
+ const { isWebCore } = require('./web-activity');
42
+
43
+ // Below this many ops in a group, commit individual per-op lines (today's
44
+ // output); at or above it, commit ONE collapsed summary line.
45
+ const GROUP_THRESHOLD = 3;
46
+
47
+ // Native and XML rails BOTH emit `read` as the action for a read_file call
48
+ // (tool_registry: read_file fromParams/_parseReadTag → ['read', …]); list_dir
49
+ // stays `list_dir`. Normalize so the action tag and the registry tag name agree
50
+ // — mirrors format.js / theme.js ACTION_TO_TAG, scoped to the groupable set.
51
+ const ACTION_TO_TAG = { read: 'read_file' };
52
+
53
+ function normalizeFileTag(tag) {
54
+ return ACTION_TO_TAG[tag] || tag || '';
55
+ }
56
+
57
+ // The tools collapsed into the file-activity summary (DECISION 3).
58
+ const FILE_GROUP_TAGS = new Set(['read_file', 'list_dir']);
59
+
60
+ function isGroupableFileTag(tag) {
61
+ return FILE_GROUP_TAGS.has(normalizeFileTag(tag));
62
+ }
63
+
64
+ // Group key. read_file and list_dir share ONE key (`file:access`) so consecutive
65
+ // reads and lists accumulate into a SINGLE group regardless of order — they no
66
+ // longer flush each other on a read↔list switch. (Any non-groupable tag keeps a
67
+ // per-tag key, but in practice only groupable tags ever reach this — the caller
68
+ // gates on isGroupable.)
69
+ function fileGroupKey(tag) {
70
+ return isGroupableFileTag(tag) ? 'file:access' : `file:${normalizeFileTag(tag)}`;
71
+ }
72
+
73
+ // Final path segment, for the compact basename list. Trailing slashes (a dir
74
+ // path) are stripped first so `/a/b/` → `b`; `.` and bare names pass through.
75
+ function _basename(p) {
76
+ const s = String(p == null ? '' : p).replace(/[/\\]+$/, '');
77
+ const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'));
78
+ const base = i >= 0 ? s.slice(i + 1) : s;
79
+ return base || s || '.';
80
+ }
81
+
82
+ // Pure: fold a list of file ops (ToolOperation descriptors OR persisted cores —
83
+ // both expose `tag`/`target`) into the fields the summary needs. read_file and
84
+ // list_dir now share one group, so a group may be MIXED. The verb reflects the
85
+ // group's composition: homogeneous reads → "read"/"reading…", homogeneous lists
86
+ // → "list"/"listing…", a genuinely mixed group → the neutral "file"/"accessing…".
87
+ function fileSummaryState(ops) {
88
+ const list = (ops || []).filter(Boolean);
89
+ let hasRead = false, hasList = false;
90
+ for (const o of list) {
91
+ if (normalizeFileTag(o.tag) === 'list_dir') hasList = true;
92
+ else hasRead = true;
93
+ }
94
+ const mixed = hasRead && hasList;
95
+ const isList = hasList && !hasRead;
96
+ return {
97
+ verb: mixed ? 'file' : (isList ? 'list' : 'read'),
98
+ gerund: mixed ? 'accessing…' : (isList ? 'listing…' : 'reading…'),
99
+ count: list.length,
100
+ basenames: list.map((o) => _basename(o.target)),
101
+ };
102
+ }
103
+
104
+ // Styled, chrome-consistent summary line. Mirrors `formatWebSummaryLine`'s
105
+ // "<glyph> <category> · <operation>" layout so the file summary reads as a peer
106
+ // of the other tool lines. The ×N count sits in the FIXED prefix BEFORE the
107
+ // truncatable basename list, so it ALWAYS shows even when the basenames are cut
108
+ // to fit. One physical row (the Phase-4 single-row invariant): the basename list
109
+ // is truncated to the remaining columns at the CURRENT terminal width.
110
+ function formatFileSummaryLine(state, opts) {
111
+ const { pending = false } = opts || {};
112
+ const colors = resolveLineColors('file', pending ? 'pending' : 'success');
113
+ const glyph = pending ? UI_ICONS.pending : UI_ICONS.success;
114
+ const cat = 'file'.padEnd(5);
115
+ const sep = ` ${DIM}·${RST} `;
116
+
117
+ const head = ` ${colors.glyph}${glyph}${RST} ${colors.label}${cat}${RST}`;
118
+ const verb = pending ? state.gerund : state.verb;
119
+ const fixed = `${verb} ×${state.count} (`;
120
+
121
+ // Width budget for the basename list: total columns minus the styled prefix
122
+ // (measured plain), the fixed "verb ×N (" lead, and the trailing ")".
123
+ const cols = getCols();
124
+ const used = termWidth(stripAnsi(head)) + termWidth(stripAnsi(sep)) + termWidth(fixed) + 1;
125
+ const budget = cols - used;
126
+ const joined = state.basenames.join(', ');
127
+ const list = budget > 0 ? truncateLine(joined, budget) : '…';
128
+
129
+ const opSeg = `${colors.op}${fixed}${list})${RST}`;
130
+ return [head, opSeg].join(sep);
131
+ }
132
+
133
+ // Predicate: a persisted display core is a GROUPABLE file-op core (a normal
134
+ // descriptor core for a successful read_file/list_dir). Replay buffers these and
135
+ // re-groups them at the same boundaries the live path flushes. A web core or a
136
+ // non-file / errored / unknown core fails the gate. Tolerant of any input.
137
+ function isGroupableFileCore(core) {
138
+ if (!core || typeof core !== 'object' || core.v !== 1) return false;
139
+ if (isWebCore(core)) return false;
140
+ if (core.status && core.status !== 'ok') return false;
141
+ return isGroupableFileTag(core.tag);
142
+ }
143
+
144
+ // Stateful runtime collapser. Owns one writer "activity" entry per group of
145
+ // consecutive same-key file ops, updating it in place as ops complete and
146
+ // committing on flush(): a single summary line (≥3 ops) or the individual per-op
147
+ // lines (1–2 ops). Tools run sequentially in the agent loop, so at most one group
148
+ // is ever open and there is no concurrency. A SECOND INSTANCE of the web tracker.
149
+ function createFileActivityTracker(deps) {
150
+ const { writerModule } = deps || {};
151
+ let groupId = null;
152
+ let seq = 0;
153
+ let currentKey = null;
154
+ let ended = []; // successful op descriptors committed into this group
155
+ let current = null; // the in-flight op, shown in the live aggregate line
156
+
157
+ function _render() {
158
+ const ops = current ? ended.concat([current]) : ended;
159
+ return formatFileSummaryLine(fileSummaryState(ops), { pending: true });
160
+ }
161
+
162
+ function _refresh() {
163
+ if (groupId === null) return;
164
+ writerModule.updateActivity(groupId, () => _render());
165
+ }
166
+
167
+ const api = {
168
+ isGroupable: isGroupableFileTag,
169
+ isOpen() { return groupId !== null; },
170
+
171
+ // Open or extend the live group for a starting op. read_file and list_dir
172
+ // share one key, so a read↔list switch does NOT flush — both accumulate into
173
+ // the same group (the key only changes for a different category, which never
174
+ // reaches here). The live row is a growing web-style aggregate: "● file ·
175
+ // reading… ×N (a, b, …)" (or "accessing… ×N" once mixed). `input` is the op's
176
+ // path (used for the live basename).
177
+ start(tag, input) {
178
+ const key = fileGroupKey(tag);
179
+ if (groupId !== null && key !== currentKey) api.flush();
180
+ current = { tag, target: input };
181
+ if (groupId === null) {
182
+ currentKey = key;
183
+ groupId = `file-${seq++}`;
184
+ writerModule.startActivity(groupId, () => _render());
185
+ } else {
186
+ _refresh();
187
+ }
188
+ },
189
+
190
+ // Record a SUCCESSFUL op (a prebuilt ToolOperation descriptor) into the group
191
+ // and re-render the live aggregate. Errored ops are NOT routed here (the
192
+ // caller flushes the group and renders the error standalone).
193
+ end(operation) {
194
+ ended.push(operation);
195
+ current = null;
196
+ _refresh();
197
+ },
198
+
199
+ // Commit the group to scrollback and reset. THRESHOLD decided here: <3 ops →
200
+ // each as its own normal result line (byte-identical to today's per-op
201
+ // output); ≥3 → one collapsed summary line. Exactly one endActivity call, so
202
+ // the commit happens once; a no-op when no group is open (the double-flush
203
+ // guard the boundary+finally flush sites rely on).
204
+ flush() {
205
+ if (groupId === null) return;
206
+ const id = groupId;
207
+ const ops = ended;
208
+ groupId = null;
209
+ currentKey = null;
210
+ ended = [];
211
+ current = null;
212
+ let line = '';
213
+ if (ops.length >= GROUP_THRESHOLD) {
214
+ line = formatFileSummaryLine(fileSummaryState(ops), { pending: false });
215
+ } else {
216
+ line = ops
217
+ .map((op) => renderOperation(op, { mode: 'ansi', phase: 'result' }))
218
+ .join('\n');
219
+ }
220
+ writerModule.endActivity(id, line);
221
+ },
222
+ };
223
+ return api;
224
+ }
225
+
226
+ module.exports = {
227
+ GROUP_THRESHOLD,
228
+ FILE_GROUP_TAGS,
229
+ normalizeFileTag,
230
+ isGroupableFileTag,
231
+ fileGroupKey,
232
+ fileSummaryState,
233
+ formatFileSummaryLine,
234
+ isGroupableFileCore,
235
+ createFileActivityTracker,
236
+ };