@semalt-ai/code 1.8.5 → 1.19.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 (146) hide show
  1. package/.claude/settings.local.json +6 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/CLAUDE.md +1584 -26
  4. package/README.md +147 -3
  5. package/examples/embed.js +74 -0
  6. package/index.js +251 -10
  7. package/lib/agent.js +711 -104
  8. package/lib/api.js +213 -49
  9. package/lib/args.js +74 -2
  10. package/lib/audit.js +23 -1
  11. package/lib/background.js +584 -0
  12. package/lib/checkpoints.js +757 -0
  13. package/lib/commands/auth.js +94 -0
  14. package/lib/commands/chat-session.js +306 -0
  15. package/lib/commands/chat-slash.js +399 -0
  16. package/lib/commands/chat-turn.js +446 -0
  17. package/lib/commands/chat.js +403 -0
  18. package/lib/commands/custom.js +157 -0
  19. package/lib/commands/history-utils.js +66 -0
  20. package/lib/commands/index.js +268 -0
  21. package/lib/commands/mcp.js +113 -0
  22. package/lib/commands/oneshot.js +193 -0
  23. package/lib/commands/registry.js +269 -0
  24. package/lib/commands/tasks.js +89 -0
  25. package/lib/compact.js +87 -0
  26. package/lib/config.js +333 -11
  27. package/lib/constants.js +372 -3
  28. package/lib/deny.js +199 -0
  29. package/lib/doctor.js +160 -0
  30. package/lib/headless.js +167 -0
  31. package/lib/hooks.js +286 -0
  32. package/lib/images.js +264 -0
  33. package/lib/internals.js +49 -0
  34. package/lib/mcp/boundary.js +131 -0
  35. package/lib/mcp/client.js +270 -0
  36. package/lib/mcp/oauth.js +134 -0
  37. package/lib/memory.js +209 -0
  38. package/lib/metrics.js +37 -2
  39. package/lib/payload.js +54 -0
  40. package/lib/permission-rules.js +401 -0
  41. package/lib/permissions.js +100 -10
  42. package/lib/pricing.js +67 -0
  43. package/lib/proc.js +62 -0
  44. package/lib/prompts.js +84 -5
  45. package/lib/sandbox.js +568 -0
  46. package/lib/sdk.js +328 -0
  47. package/lib/secrets.js +211 -0
  48. package/lib/skills.js +223 -0
  49. package/lib/subagents.js +516 -0
  50. package/lib/tool_registry.js +2558 -0
  51. package/lib/tool_specs.js +222 -2
  52. package/lib/tools.js +272 -1020
  53. package/lib/ui/format.js +22 -1
  54. package/lib/ui/input-field.js +16 -7
  55. package/lib/ui/status-bar.js +79 -11
  56. package/lib/ui/theme.js +1 -0
  57. package/lib/ui/web-activity.js +218 -0
  58. package/lib/verify.js +229 -0
  59. package/lib/web-extract.js +213 -0
  60. package/lib/web-summarize.js +68 -0
  61. package/package.json +19 -4
  62. package/scripts/lint.js +57 -0
  63. package/test/agent-loop.test.js +389 -0
  64. package/test/background.test.js +414 -0
  65. package/test/chat.test.js +114 -0
  66. package/test/checkpoints-agent.test.js +181 -0
  67. package/test/checkpoints.test.js +650 -0
  68. package/test/command-registry.test.js +160 -0
  69. package/test/compact.test.js +116 -0
  70. package/test/completion-lazy.test.js +52 -0
  71. package/test/config-merge.test.js +324 -0
  72. package/test/config-quarantine.test.js +128 -0
  73. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  74. package/test/config-write-guard-skip.test.js +46 -0
  75. package/test/config-write-guard.test.js +153 -0
  76. package/test/context-split.test.js +215 -0
  77. package/test/cost-doctor.test.js +142 -0
  78. package/test/custom-commands-chat.test.js +106 -0
  79. package/test/custom-commands.test.js +230 -0
  80. package/test/deny-windows.test.js +120 -0
  81. package/test/deny.test.js +83 -0
  82. package/test/download-allow-anywhere.test.js +66 -0
  83. package/test/download-confine.test.js +153 -0
  84. package/test/executors.test.js +362 -0
  85. package/test/extract-tool-calls.test.js +315 -0
  86. package/test/fetch-url-validation.test.js +219 -0
  87. package/test/fixtures/tool-calls.js +57 -0
  88. package/test/fixtures/web-page.js +91 -0
  89. package/test/git-tools.test.js +384 -0
  90. package/test/grep-glob-serialize.test.js +242 -0
  91. package/test/grep-glob.test.js +268 -0
  92. package/test/harness/README.md +57 -0
  93. package/test/harness/chat-harness.js +142 -0
  94. package/test/harness/memwarn-headless-child.js +65 -0
  95. package/test/harness/mock-llm.js +120 -0
  96. package/test/harness/mock-mcp-server.js +142 -0
  97. package/test/harness/sse-server.js +69 -0
  98. package/test/headless.test.js +203 -0
  99. package/test/history-utils.test.js +88 -0
  100. package/test/hooks-agent.test.js +238 -0
  101. package/test/hooks-verify-sandbox.test.js +232 -0
  102. package/test/hooks.test.js +216 -0
  103. package/test/http-get-user-agent.test.js +142 -0
  104. package/test/images-api.test.js +208 -0
  105. package/test/images.test.js +238 -0
  106. package/test/max-iterations.test.js +216 -0
  107. package/test/mcp-boundary.test.js +57 -0
  108. package/test/mcp-client.test.js +267 -0
  109. package/test/mcp-oauth.test.js +86 -0
  110. package/test/memory-truncation-warning.test.js +222 -0
  111. package/test/memory.test.js +198 -0
  112. package/test/native-dispatch.test.js +356 -0
  113. package/test/output-chokepoint.test.js +188 -0
  114. package/test/path-guards.test.js +134 -0
  115. package/test/payload.test.js +99 -0
  116. package/test/permission-rules-agent.test.js +210 -0
  117. package/test/permission-rules.test.js +297 -0
  118. package/test/permissions.test.js +163 -0
  119. package/test/plan-mode.test.js +167 -0
  120. package/test/read-paginate.test.js +275 -0
  121. package/test/readonly-tools.test.js +177 -0
  122. package/test/result-cap.test.js +233 -0
  123. package/test/sandbox-agent.test.js +147 -0
  124. package/test/sandbox-integration.test.js +216 -0
  125. package/test/sandbox.test.js +408 -0
  126. package/test/sdk.test.js +234 -0
  127. package/test/shell-output-cap.test.js +181 -0
  128. package/test/skills-chat.test.js +110 -0
  129. package/test/skills.test.js +295 -0
  130. package/test/smoke.test.js +68 -0
  131. package/test/status-bar-pause.test.js +164 -0
  132. package/test/stream-parser.test.js +147 -0
  133. package/test/subagents-agent.test.js +178 -0
  134. package/test/subagents.test.js +222 -0
  135. package/test/tool-registry.test.js +85 -0
  136. package/test/trim-budget.test.js +101 -0
  137. package/test/verify-agent.test.js +317 -0
  138. package/test/verify.test.js +141 -0
  139. package/test/web-activity-ordering.test.js +194 -0
  140. package/test/web-activity.test.js +207 -0
  141. package/test/web-data-extraction-guidance.test.js +71 -0
  142. package/test/web-extract.test.js +185 -0
  143. package/test/web-fetch-agent.test.js +291 -0
  144. package/test/web-fetch-mode.test.js +193 -0
  145. package/test/web-search.test.js +380 -0
  146. package/lib/commands.js +0 -1438
package/lib/ui/format.js CHANGED
@@ -153,7 +153,28 @@ function _operation(tag, arg, attrs) {
153
153
  case 'recall_memory': return _truncate(`recall ${a.key || arg || ''}`, max);
154
154
  case 'list_memories': return 'list memories';
155
155
  case 'system_info': return 'system info';
156
- default: return _truncate(arg ? `${tag} ${arg}` : tag, max);
156
+ default:
157
+ if (_normalizeTag(tag).startsWith('git_')) return _truncate(_gitOperation(_normalizeTag(tag), a), max);
158
+ // arg may be a structured options object for newer tools — avoid rendering
159
+ // "[object Object]" by only appending string/number args.
160
+ return _truncate((arg && typeof arg !== 'object') ? `${tag} ${arg}` : tag, max);
161
+ }
162
+ }
163
+
164
+ // Human-readable one-liner for the native git tools (Task 5.1). `a` is the
165
+ // options object (attrs) carried by the call.
166
+ function _gitOperation(tag, a) {
167
+ const paths = Array.isArray(a.paths) ? a.paths.join(' ') : (a.paths || '');
168
+ switch (tag) {
169
+ case 'git_status': return 'git status';
170
+ case 'git_diff': return `git diff${a.staged ? ' --staged' : ''}${a.path ? ' ' + a.path : ''}`;
171
+ case 'git_log': return `git log${a.count ? ' -n ' + a.count : ''}${a.path ? ' ' + a.path : ''}`;
172
+ case 'git_add': return `git add ${a.all ? '-A' : paths}`.trim();
173
+ case 'git_commit': return `git commit${a.all ? ' -a' : ''} -m "${normalizeCmdForDisplay(a.message || '')}"`;
174
+ case 'git_branch': return a.name ? `git branch ${a.delete ? '-d ' : ''}${a.name}` : 'git branch';
175
+ case 'git_checkout': return `git checkout ${a.create ? '-b ' : ''}${a.name || ''}`.trim();
176
+ case 'git_worktree': return `git worktree ${a.op || 'list'}${a.path ? ' ' + a.path : ''}`;
177
+ default: return tag;
157
178
  }
158
179
  }
159
180
 
@@ -7,10 +7,13 @@ const { RST, DIM, FG_CYAN, FG_GREEN, FG_YELLOW } = require('./ansi');
7
7
  const { stripAnsi, termWidth } = require('./utils');
8
8
  const writer = require('./writer');
9
9
 
10
- const SLASH_CMDS = [
11
- '/help','/file','/new','/model','/models','/shell','/compact',
12
- '/clear','/approve','/debug','/config','/history','/login','/whoami','/logout','/chats',
13
- ];
10
+ // Tab-completion command list is generated from the slash-command registry —
11
+ // the single source of truth — so it can never drift from the dispatcher.
12
+ // Resolved LIVE (not snapshotted at load) so commands registered after this
13
+ // module loads — notably the Markdown custom commands discovered at chat
14
+ // startup (Task 3.1) — appear in completion. The exported `SLASH_CMDS` is kept
15
+ // for back-compat (since 1.3) but backed by the same live getter.
16
+ const { completionNames } = require('../commands/registry');
14
17
 
15
18
  // ─── Key sequence parser ──────────────────────────────────────────────────────
16
19
 
@@ -571,7 +574,7 @@ class InputField extends EventEmitter {
571
574
  sources.push({ type: 'history', text });
572
575
  for (const item of this._searchExtraItems)
573
576
  sources.push(item);
574
- for (const cmd of SLASH_CMDS)
577
+ for (const cmd of completionNames())
575
578
  sources.push({ type: 'command', text: cmd });
576
579
  return sources;
577
580
  }
@@ -635,7 +638,7 @@ class InputField extends EventEmitter {
635
638
  }
636
639
 
637
640
  if (!val.startsWith('/')) return;
638
- const matches = SLASH_CMDS.filter(c => c.startsWith(val));
641
+ const matches = completionNames().filter(c => c.startsWith(val));
639
642
  if (!matches.length) return;
640
643
  if (matches.length === 1) {
641
644
  this._setValue(matches[0] + ' ');
@@ -1266,4 +1269,10 @@ class InputField extends EventEmitter {
1266
1269
  }
1267
1270
  }
1268
1271
 
1269
- module.exports = { InputField, parseKeySequence, SLASH_CMDS };
1272
+ module.exports = { InputField, parseKeySequence };
1273
+ // SLASH_CMDS kept for back-compat (since 1.3), but now a live getter so callers
1274
+ // reading it after custom-command registration see the current set.
1275
+ Object.defineProperty(module.exports, 'SLASH_CMDS', {
1276
+ enumerable: true,
1277
+ get() { return completionNames(); },
1278
+ });
@@ -4,6 +4,17 @@ const { RST, DIM, FG_RED, FG_DARK, SPINNER_DEFS } = require('./ansi');
4
4
  const { UI_THEME } = require('./theme');
5
5
  const { stripAnsi, termWidth } = require('./utils');
6
6
 
7
+ // Compact token count for the estimated split (Variant B): the base/working
8
+ // estimates are shown abbreviated (e.g. 12k, 5.6k, 200k) to keep the row short
9
+ // — they are estimates, so sub-thousand precision is noise. The real measured
10
+ // total is rendered separately with full toLocaleString precision (no ~).
11
+ function abbrevTokens(n) {
12
+ const v = Math.max(0, Math.round(Number(n) || 0));
13
+ if (v < 1000) return String(v);
14
+ const k = v / 1000;
15
+ return (k < 10 ? k.toFixed(1) : String(Math.round(k))) + 'k';
16
+ }
17
+
7
18
  // Status bar is a *content producer* only. It builds a single line string
8
19
  // and hands it to the UI orchestrator via the onChange callback; the
9
20
  // orchestrator composes the full live region (status + input + hints) and
@@ -27,6 +38,11 @@ class FullStatusBar {
27
38
  this._reportedContext = 0;
28
39
  this._pendingDelta = 0;
29
40
  this._contextLimit = null;
41
+ // Estimated base/working split (Variant B, display-only). Both are char/4
42
+ // estimates of the same measured prompt; rendered ~-prefixed alongside the
43
+ // real total. 0 until the agent loop reports the first estimate.
44
+ this._baseEst = 0;
45
+ this._workingEst = 0;
30
46
  this._speed = 0;
31
47
  this._streamStart = null;
32
48
  this._streamTokens = 0;
@@ -37,22 +53,53 @@ class FullStatusBar {
37
53
  // Clock tick drives the `HH:MM:SS` part of the right-hand side. Every
38
54
  // tick just notifies the orchestrator to re-push the live region — the
39
55
  // compound erase+redraw goes through the writer's queue so a tick
40
- // falling mid-bubble can't produce a torn frame.
56
+ // falling mid-bubble can't produce a torn frame. The tick is what fights
57
+ // user scrollback while idle, so pause()/resume() START/STOP this timer
58
+ // (see _startClock/_stopClock) rather than gating _notify with a flag.
59
+ this._clockTimer = null;
60
+ this._startClock();
61
+ }
62
+
63
+ // Idempotent clock control. pause() stops the once-per-second redraw so the
64
+ // terminal viewport can scroll freely while idle; resume() restarts it.
65
+ // Guarded so repeated pause()/resume() cycles never stack a second timer.
66
+ _startClock() {
67
+ if (this._clockTimer) return;
41
68
  this._clockTimer = setInterval(() => this._notify(), 1000);
42
69
  }
43
70
 
44
- pause() { this._paused = true; }
45
- resume() { this._paused = false; this._notify(); }
71
+ _stopClock() {
72
+ if (this._clockTimer) { clearInterval(this._clockTimer); this._clockTimer = null; }
73
+ }
74
+
75
+ // pause() genuinely halts the periodic redraw (clears the clock timer) so a
76
+ // forced redraw no longer snaps the viewport to the bottom while the user
77
+ // scrolls up. Event-driven redraws (update/updateMetrics/setCost/spinner)
78
+ // are unaffected — they call _notify directly. resume() restarts the clock
79
+ // and forces one repaint so the viewport returns to the input prompt.
80
+ pause() { this._paused = true; this._stopClock(); }
81
+ resume() { this._paused = false; this._startClock(); this._notify(); }
46
82
 
47
83
  setModel(name) {
48
84
  this._model = name || '';
49
85
  this._notify();
50
86
  }
51
87
 
88
+ // Session cost string (e.g. "$0.0123" or "unknown"), shown when show_cost is
89
+ // on (Task 2.6). null/'' hides the field. The caller computes the value from
90
+ // the price table × usage; the bar just renders it.
91
+ setCost(str) {
92
+ this._cost = str || null;
93
+ this._notify();
94
+ }
95
+
52
96
  update(state, label) {
53
97
  this._state = state || 'idle';
54
98
  if (label !== undefined) this._label = label;
99
+ // A state change means work is happening — keep the not-paused ⇒
100
+ // clock-running invariant (idempotent, so no stacked timer).
55
101
  this._paused = false;
102
+ this._startClock();
56
103
 
57
104
  if (state === 'streaming') {
58
105
  if (!this._streamStart) { this._streamStart = Date.now(); this._streamTokens = 0; }
@@ -92,6 +139,10 @@ class FullStatusBar {
92
139
  this._reportedContext = data.contextTokens;
93
140
  this._pendingDelta = 0;
94
141
  }
142
+ // Estimated split (Variant B) — recomputed per request by the api client and
143
+ // threaded through here, so it tracks MCP-connect / plan-mode / native-vs-XML.
144
+ if (typeof data.baseEst === 'number') this._baseEst = data.baseEst;
145
+ if (typeof data.workingEst === 'number') this._workingEst = data.workingEst;
95
146
  if (data.tokenLimit && typeof data.tokenLimit.limit === 'number') {
96
147
  this._contextLimit = data.tokenLimit.limit;
97
148
  }
@@ -122,9 +173,9 @@ class FullStatusBar {
122
173
  // to build the live region. ALWAYS returns a string — the status row is a
123
174
  // permanent fixture of the live region, never omitted. Missing data
124
175
  // renders as a short placeholder ("—") so the row width is stable.
125
- // `_paused` is honored by suppressing the `_notify` *tick* (no forced
126
- // redraws while idle) but the row itself is still present when the
127
- // composer asks for it.
176
+ // Pausing (idle scroll) stops the periodic *tick* (the clock timer is
177
+ // cleared) but the row itself is still produced whenever the composer asks
178
+ // for it an event-driven _notify still repaints normally.
128
179
  renderLine() {
129
180
  const layout = this._layout;
130
181
  const cols = layout.cols;
@@ -168,6 +219,10 @@ class FullStatusBar {
168
219
  { visible: this._model || '—', ansi: this._model || '—', priority: 4 },
169
220
  { visible: tokenField.visible, ansi: tokenField.ansi, priority: 3 },
170
221
  ];
222
+ if (this._cost) {
223
+ const c = `${this._cost}`;
224
+ fields.push({ visible: c, ansi: c, priority: 2 });
225
+ }
171
226
  if (state === 'streaming' && this._speed > 0) {
172
227
  const s = `${this._speed} t/s`;
173
228
  fields.push({ visible: s, ansi: s, priority: 1 });
@@ -221,21 +276,30 @@ class FullStatusBar {
221
276
  _buildTokenField() {
222
277
  const used = Math.max(0, (this._reportedContext | 0) + (this._pendingDelta | 0));
223
278
  const usedStr = used.toLocaleString();
279
+ // Estimated split prefix (Variant B): "~Nk working · ~Nk base · ". Working
280
+ // first (it's the part that grows and matters), base second (fixed-ish
281
+ // reference). Both carry ~ — they're char/4 estimates. The measured
282
+ // total/limit/percent that follows carries NO ~ (it's the truth anchor).
283
+ // Shown only once an estimate has arrived, so a fresh bar has no ~ noise.
284
+ let estPrefix = '';
285
+ if (this._workingEst > 0 || this._baseEst > 0) {
286
+ estPrefix = `~${abbrevTokens(this._workingEst)} working · ~${abbrevTokens(this._baseEst)} base · `;
287
+ }
224
288
  if (!this._contextLimit) {
225
- const s = `${usedStr} tok`;
289
+ const s = `${estPrefix}${usedStr} tok`;
226
290
  return { visible: s, ansi: s };
227
291
  }
228
292
  const limit = this._contextLimit;
229
293
  const pct = limit > 0 ? Math.round((used / limit) * 100) : 0;
230
294
  const limitStr = limit.toLocaleString();
231
- const visible = `${usedStr} / ${limitStr} tok (${pct}%)`;
295
+ const visible = `${estPrefix}${usedStr} / ${limitStr} tok (${pct}%)`;
232
296
  let pctAnsi = `${pct}%`;
233
297
  if (pct >= 90) {
234
298
  pctAnsi = `${RST}${UI_THEME.error}${pct}%${RST}${DIM}`;
235
299
  } else if (pct >= 70) {
236
300
  pctAnsi = `${RST}${UI_THEME.warning}${pct}%${RST}${DIM}`;
237
301
  }
238
- const ansi = `${usedStr} / ${limitStr} tok (${pctAnsi})`;
302
+ const ansi = `${estPrefix}${usedStr} / ${limitStr} tok (${pctAnsi})`;
239
303
  return { visible, ansi };
240
304
  }
241
305
 
@@ -255,13 +319,17 @@ class FullStatusBar {
255
319
  _renderBar() { this._notify(); }
256
320
 
257
321
  _notify() {
258
- if (this._paused) { this._onChange(); return; }
322
+ // Every redraw periodic tick AND event-driven (update/updateMetrics/
323
+ // setCost/spinner) — flows through here. Pausing is done by stopping the
324
+ // clock timer (see pause/_stopClock), NOT by gating here: a guard would
325
+ // also suppress the event-driven repaints that must keep working while
326
+ // idle scroll is paused.
259
327
  this._onChange();
260
328
  }
261
329
 
262
330
  destroy() {
263
331
  if (this._animTimer) { clearInterval(this._animTimer); this._animTimer = null; }
264
- if (this._clockTimer) { clearInterval(this._clockTimer); this._clockTimer = null; }
332
+ this._stopClock();
265
333
  }
266
334
  }
267
335
 
package/lib/ui/theme.js CHANGED
@@ -21,6 +21,7 @@ const UI_THEME = {
21
21
  // values produced by TOOL_CATEGORIES so a lookup is categories[cat].
22
22
  categories: {
23
23
  net: fg256(110), // dusty blue — http, download, upload
24
+ web: fg256(80), // aqua — collapsed web-activity summary (W.3)
24
25
  file: fg256(151), // soft sage — file ops
25
26
  cmd: fg256(180), // warm tan — shell / exec
26
27
  user: fg256(217), // pale rose — ask_user
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+
3
+ // Web-activity process summary (Task W.3, Part 1).
4
+ //
5
+ // A web task runs `web_search` (find candidate pages) then targeted `http_get`
6
+ // (read the relevant ones). By default each operation printed its own tool line
7
+ // (one "tool · web_search" / "net · GET …" row per call), which reads as a noisy
8
+ // list rather than one coherent process. This module collapses a run of
9
+ // consecutive web operations into a SINGLE compact process-summary line —
10
+ //
11
+ // ✓ web · search "коррупционные скандалы…" · 2 queries · 3 sources read · 1 blocked
12
+ //
13
+ // — while `--debug` keeps the full per-operation lines (in debug mode chat-turn.js
14
+ // bypasses this collapser and renders each op the normal way).
15
+ //
16
+ // Display only: the audit log still records every individual operation (that
17
+ // happens in the executors, untouched here), and NON-web tools render exactly as
18
+ // before. Scope: `web_search` + `http_get`. `download` is a file-save, not a page
19
+ // read for the search→fetch flow, so it keeps its own line.
20
+
21
+ const { UI_THEME, UI_ICONS } = require('./theme');
22
+ const { RST, DIM } = require('./ansi');
23
+ const { formatDuration } = require('./format');
24
+
25
+ // The tools collapsed into the web-activity summary.
26
+ const WEB_TOOLS = new Set(['web_search', 'http_get']);
27
+
28
+ function isWebTool(tag) { return WEB_TOOLS.has(tag); }
29
+
30
+ function _truncate(text, max) {
31
+ const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
32
+ if (s.length <= max) return s;
33
+ return s.slice(0, Math.max(0, max - 1)) + '…';
34
+ }
35
+
36
+ // Whether a finished web op counts as a success. A `web_search` is ok unless the
37
+ // executor flagged an error (backend down). An `http_get` is ok only when it both
38
+ // avoided a transport error (timeout/DNS — surfaced as `op.error`) AND the server
39
+ // answered < 400: a 403/406 is a real "blocked" even though the fetch itself
40
+ // completed and returned a status code.
41
+ function opSucceeded(op) {
42
+ if (!op) return false;
43
+ if (op.error) return false;
44
+ if (op.tag === 'http_get' && typeof op.status === 'number' && op.status >= 400) return false;
45
+ return true;
46
+ }
47
+
48
+ // Pure: fold the recorded op list into the counts the summary needs.
49
+ function aggregateWebOps(ops) {
50
+ const state = {
51
+ searchCount: 0, searchFailed: 0, queries: [],
52
+ fetchCount: 0, fetchOk: 0, fetchFailed: 0,
53
+ };
54
+ for (const op of (ops || [])) {
55
+ if (!op) continue;
56
+ const ok = opSucceeded(op);
57
+ if (op.tag === 'web_search') {
58
+ state.searchCount += 1;
59
+ if (!ok) state.searchFailed += 1;
60
+ if (op.query) state.queries.push(op.query);
61
+ } else if (op.tag === 'http_get') {
62
+ state.fetchCount += 1;
63
+ if (ok) state.fetchOk += 1; else state.fetchFailed += 1;
64
+ }
65
+ }
66
+ return state;
67
+ }
68
+
69
+ // Pure: the plain-text segments of the summary (no ANSI). Each segment is tagged
70
+ // with a `kind` so the styled renderer can colour failures distinctly. Exposed
71
+ // for tests and reused by the styled renderer. Failures (a blocked source / a
72
+ // failed search) are ALWAYS represented so the compact view never silently drops
73
+ // a source that didn't load.
74
+ function webSummarySegments(state) {
75
+ const segs = [];
76
+ if (state.searchCount > 0) {
77
+ const q = state.queries[0] ? `"${_truncate(state.queries[0], 48)}"` : '';
78
+ segs.push({ kind: 'lead', text: `search ${q}`.trim() });
79
+ if (state.searchCount > 1) segs.push({ kind: 'count', text: `${state.searchCount} queries` });
80
+ if (state.searchFailed > 0) {
81
+ segs.push({ kind: 'fail', text: `${state.searchFailed} search${state.searchFailed === 1 ? '' : 'es'} failed` });
82
+ }
83
+ }
84
+ if (state.fetchCount > 0) {
85
+ segs.push({ kind: state.searchCount > 0 ? 'count' : 'lead', text: `${state.fetchOk} ${state.fetchOk === 1 ? 'source' : 'sources'} read` });
86
+ if (state.fetchFailed > 0) segs.push({ kind: 'fail', text: `${state.fetchFailed} blocked` });
87
+ }
88
+ if (segs.length === 0) segs.push({ kind: 'lead', text: 'web' });
89
+ return segs;
90
+ }
91
+
92
+ // Plain-text one-liner (no ANSI). The text the tests assert on.
93
+ function webSummaryText(state) {
94
+ return webSummarySegments(state).map((s) => s.text).join(' · ');
95
+ }
96
+
97
+ // Styled, chrome-consistent line for the writer's activity region / scrollback.
98
+ // Mirrors formatToolLine's "<glyph> <category> · <segments…>" layout so the
99
+ // summary reads as a peer of the other tool lines, not a foreign widget.
100
+ function formatWebSummaryLine(state, opts) {
101
+ const { pending = false, durationMs = 0 } = opts || {};
102
+ const glyph = pending ? UI_ICONS.pending : UI_ICONS.success;
103
+ const glyphColor = pending ? UI_THEME.muted : UI_THEME.success;
104
+ const cat = 'web'.padEnd(5);
105
+ const catColor = (UI_THEME.categories && UI_THEME.categories.web) || UI_THEME.accent;
106
+ const sep = ` ${DIM}·${RST} `;
107
+
108
+ const out = [` ${glyphColor}${glyph}${RST} ${catColor}${cat}${RST}`];
109
+ for (const seg of webSummarySegments(state)) {
110
+ let color = UI_THEME.subtle;
111
+ if (seg.kind === 'lead') color = UI_THEME.default;
112
+ else if (seg.kind === 'fail') color = UI_THEME.warning;
113
+ out.push(`${color}${seg.text}${RST}`);
114
+ }
115
+ if (pending) out.push(`${UI_THEME.muted}${formatDuration(durationMs)}…${RST}`);
116
+ return out.join(sep);
117
+ }
118
+
119
+ // Batch renderer / policy seam (used by tests, and documents the runtime split):
120
+ // debug → one full per-operation tool line per op (nothing hidden), built
121
+ // with the SAME formatToolLine the runtime uses.
122
+ // default → a single collapsed summary line.
123
+ function renderWebActivity(ops, opts) {
124
+ const { debug = false, formatToolLine } = opts || {};
125
+ if (debug) {
126
+ return (ops || []).map((op) => formatToolLine({
127
+ status: opSucceeded(op) ? 'success' : 'failure',
128
+ tag: op.tag,
129
+ arg: op.query || op.url || '',
130
+ attrs: op.tag === 'web_search' ? { query: op.query } : { url: op.url },
131
+ durationMs: op.durationMs,
132
+ meta: op.tag === 'http_get' ? { status_code: op.status, bytes: op.bytes } : null,
133
+ error: op.error ? { message: String(op.error) } : null,
134
+ }));
135
+ }
136
+ return [formatWebSummaryLine(aggregateWebOps(ops), { pending: false })];
137
+ }
138
+
139
+ // Stateful runtime collapser. Owns one writer "activity" entry per group of
140
+ // consecutive web ops, updating it in place as ops complete and committing a
141
+ // single final summary line to scrollback on flush(). Tools run sequentially in
142
+ // the agent loop, so at most one group is ever open and there is no concurrency.
143
+ function createWebActivityTracker(deps) {
144
+ const { writerModule } = deps || {};
145
+ let groupId = null;
146
+ let seq = 0;
147
+ let ended = [];
148
+ let current = null; // the in-flight op, shown in the pending line before it ends
149
+
150
+ function _render(durationMs) {
151
+ const state = aggregateWebOps(current ? ended.concat([current]) : ended);
152
+ return formatWebSummaryLine(state, { pending: true, durationMs });
153
+ }
154
+
155
+ function _refresh() {
156
+ if (groupId === null) return;
157
+ writerModule.updateActivity(groupId, (elapsedMs) => _render(elapsedMs));
158
+ }
159
+
160
+ return {
161
+ isWeb: isWebTool,
162
+ isOpen() { return groupId !== null; },
163
+
164
+ start(tag, input) {
165
+ current = {
166
+ tag,
167
+ query: tag === 'web_search' ? input : undefined,
168
+ url: tag === 'http_get' ? input : undefined,
169
+ };
170
+ if (groupId === null) {
171
+ groupId = `web-${seq++}`;
172
+ writerModule.startActivity(groupId, (elapsedMs) => _render(elapsedMs));
173
+ } else {
174
+ _refresh();
175
+ }
176
+ },
177
+
178
+ end(tag, result, durationMs, toolCtx) {
179
+ const meta = toolCtx && toolCtx.meta;
180
+ const attrs = toolCtx && toolCtx.attrs;
181
+ ended.push({
182
+ tag,
183
+ durationMs,
184
+ query: (attrs && attrs.query) || (current && current.query),
185
+ url: (attrs && attrs.url) || (current && current.url),
186
+ status: meta && typeof meta.status_code === 'number' ? meta.status_code : undefined,
187
+ bytes: meta && typeof meta.bytes === 'number' ? meta.bytes : undefined,
188
+ error: toolCtx && toolCtx.error ? (toolCtx.error.message || String(toolCtx.error)) : undefined,
189
+ });
190
+ current = null;
191
+ _refresh();
192
+ },
193
+
194
+ // Commit the collapsed summary for the current group to scrollback and reset.
195
+ // A no-op when no group is open.
196
+ flush() {
197
+ if (groupId === null) return;
198
+ const id = groupId;
199
+ const line = formatWebSummaryLine(aggregateWebOps(ended), { pending: false });
200
+ groupId = null;
201
+ ended = [];
202
+ current = null;
203
+ writerModule.endActivity(id, line);
204
+ },
205
+ };
206
+ }
207
+
208
+ module.exports = {
209
+ WEB_TOOLS,
210
+ isWebTool,
211
+ opSucceeded,
212
+ aggregateWebOps,
213
+ webSummarySegments,
214
+ webSummaryText,
215
+ formatWebSummaryLine,
216
+ renderWebActivity,
217
+ createWebActivityTracker,
218
+ };