@semalt-ai/code 1.8.4 → 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 (151) hide show
  1. package/.claude/settings.local.json +8 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/CLAUDE.md +1588 -27
  4. package/README.md +147 -3
  5. package/TECHNICAL_DEBT.md +66 -0
  6. package/examples/embed.js +74 -0
  7. package/index.js +259 -11
  8. package/lib/agent.js +935 -181
  9. package/lib/api.js +308 -55
  10. package/lib/args.js +96 -2
  11. package/lib/audit.js +23 -1
  12. package/lib/background.js +584 -0
  13. package/lib/checkpoints.js +757 -0
  14. package/lib/commands/auth.js +94 -0
  15. package/lib/commands/chat-session.js +306 -0
  16. package/lib/commands/chat-slash.js +399 -0
  17. package/lib/commands/chat-turn.js +446 -0
  18. package/lib/commands/chat.js +403 -0
  19. package/lib/commands/custom.js +157 -0
  20. package/lib/commands/history-utils.js +66 -0
  21. package/lib/commands/index.js +268 -0
  22. package/lib/commands/mcp.js +113 -0
  23. package/lib/commands/oneshot.js +193 -0
  24. package/lib/commands/registry.js +269 -0
  25. package/lib/commands/tasks.js +89 -0
  26. package/lib/compact.js +87 -0
  27. package/lib/config.js +346 -11
  28. package/lib/constants.js +372 -3
  29. package/lib/debug.js +106 -0
  30. package/lib/deny.js +199 -0
  31. package/lib/doctor.js +160 -0
  32. package/lib/headless.js +167 -0
  33. package/lib/hooks.js +286 -0
  34. package/lib/images.js +264 -0
  35. package/lib/internals.js +49 -0
  36. package/lib/mcp/boundary.js +131 -0
  37. package/lib/mcp/client.js +270 -0
  38. package/lib/mcp/oauth.js +134 -0
  39. package/lib/memory.js +209 -0
  40. package/lib/metrics.js +37 -2
  41. package/lib/payload.js +54 -0
  42. package/lib/permission-rules.js +401 -0
  43. package/lib/permissions.js +100 -10
  44. package/lib/pricing.js +67 -0
  45. package/lib/proc.js +158 -0
  46. package/lib/prompts.js +88 -8
  47. package/lib/sandbox.js +568 -0
  48. package/lib/sdk.js +328 -0
  49. package/lib/secrets.js +211 -0
  50. package/lib/skills.js +223 -0
  51. package/lib/subagents.js +516 -0
  52. package/lib/tool_registry.js +2558 -0
  53. package/lib/tool_specs.js +236 -9
  54. package/lib/tools.js +370 -944
  55. package/lib/ui/chat-history.js +19 -1
  56. package/lib/ui/format.js +101 -6
  57. package/lib/ui/input-field.js +16 -7
  58. package/lib/ui/status-bar.js +79 -11
  59. package/lib/ui/terminal.js +10 -4
  60. package/lib/ui/theme.js +1 -0
  61. package/lib/ui/web-activity.js +218 -0
  62. package/lib/ui/writer.js +7 -9
  63. package/lib/verify.js +229 -0
  64. package/lib/web-extract.js +213 -0
  65. package/lib/web-summarize.js +68 -0
  66. package/package.json +19 -4
  67. package/scripts/lint.js +57 -0
  68. package/test/agent-loop.test.js +389 -0
  69. package/test/background.test.js +414 -0
  70. package/test/chat.test.js +114 -0
  71. package/test/checkpoints-agent.test.js +181 -0
  72. package/test/checkpoints.test.js +650 -0
  73. package/test/command-registry.test.js +160 -0
  74. package/test/compact.test.js +116 -0
  75. package/test/completion-lazy.test.js +52 -0
  76. package/test/config-merge.test.js +324 -0
  77. package/test/config-quarantine.test.js +128 -0
  78. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  79. package/test/config-write-guard-skip.test.js +46 -0
  80. package/test/config-write-guard.test.js +153 -0
  81. package/test/context-split.test.js +215 -0
  82. package/test/cost-doctor.test.js +142 -0
  83. package/test/custom-commands-chat.test.js +106 -0
  84. package/test/custom-commands.test.js +230 -0
  85. package/test/deny-windows.test.js +120 -0
  86. package/test/deny.test.js +83 -0
  87. package/test/download-allow-anywhere.test.js +66 -0
  88. package/test/download-confine.test.js +153 -0
  89. package/test/executors.test.js +362 -0
  90. package/test/extract-tool-calls.test.js +315 -0
  91. package/test/fetch-url-validation.test.js +219 -0
  92. package/test/fixtures/tool-calls.js +57 -0
  93. package/test/fixtures/web-page.js +91 -0
  94. package/test/git-tools.test.js +384 -0
  95. package/test/grep-glob-serialize.test.js +242 -0
  96. package/test/grep-glob.test.js +268 -0
  97. package/test/harness/README.md +57 -0
  98. package/test/harness/chat-harness.js +142 -0
  99. package/test/harness/memwarn-headless-child.js +65 -0
  100. package/test/harness/mock-llm.js +120 -0
  101. package/test/harness/mock-mcp-server.js +142 -0
  102. package/test/harness/sse-server.js +69 -0
  103. package/test/headless.test.js +203 -0
  104. package/test/history-utils.test.js +88 -0
  105. package/test/hooks-agent.test.js +238 -0
  106. package/test/hooks-verify-sandbox.test.js +232 -0
  107. package/test/hooks.test.js +216 -0
  108. package/test/http-get-user-agent.test.js +142 -0
  109. package/test/images-api.test.js +208 -0
  110. package/test/images.test.js +238 -0
  111. package/test/max-iterations.test.js +216 -0
  112. package/test/mcp-boundary.test.js +57 -0
  113. package/test/mcp-client.test.js +267 -0
  114. package/test/mcp-oauth.test.js +86 -0
  115. package/test/memory-truncation-warning.test.js +222 -0
  116. package/test/memory.test.js +198 -0
  117. package/test/native-dispatch.test.js +356 -0
  118. package/test/output-chokepoint.test.js +188 -0
  119. package/test/path-guards.test.js +134 -0
  120. package/test/payload.test.js +99 -0
  121. package/test/permission-rules-agent.test.js +210 -0
  122. package/test/permission-rules.test.js +297 -0
  123. package/test/permissions.test.js +163 -0
  124. package/test/plan-mode.test.js +167 -0
  125. package/test/read-paginate.test.js +275 -0
  126. package/test/readonly-tools.test.js +177 -0
  127. package/test/result-cap.test.js +233 -0
  128. package/test/sandbox-agent.test.js +147 -0
  129. package/test/sandbox-integration.test.js +216 -0
  130. package/test/sandbox.test.js +408 -0
  131. package/test/sdk.test.js +234 -0
  132. package/test/shell-output-cap.test.js +181 -0
  133. package/test/skills-chat.test.js +110 -0
  134. package/test/skills.test.js +295 -0
  135. package/test/smoke.test.js +68 -0
  136. package/test/status-bar-pause.test.js +164 -0
  137. package/test/stream-parser.test.js +147 -0
  138. package/test/subagents-agent.test.js +178 -0
  139. package/test/subagents.test.js +222 -0
  140. package/test/tool-registry.test.js +85 -0
  141. package/test/trim-budget.test.js +101 -0
  142. package/test/verify-agent.test.js +317 -0
  143. package/test/verify.test.js +141 -0
  144. package/test/web-activity-ordering.test.js +194 -0
  145. package/test/web-activity.test.js +207 -0
  146. package/test/web-data-extraction-guidance.test.js +71 -0
  147. package/test/web-extract.test.js +185 -0
  148. package/test/web-fetch-agent.test.js +291 -0
  149. package/test/web-fetch-mode.test.js +193 -0
  150. package/test/web-search.test.js +380 -0
  151. package/lib/commands.js +0 -1288
@@ -3,6 +3,7 @@
3
3
  const { RST, DIM, BOLD, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_DARK, FG_GRAY } = require('./ansi');
4
4
  const { getCols, stripAnsi } = require('./utils');
5
5
  const { UI_THEME, UI_ICONS, TOOL_CATEGORIES } = require('./theme');
6
+ const { summarizeToolResult } = require('./format');
6
7
  const writer = require('./writer');
7
8
 
8
9
 
@@ -147,7 +148,24 @@ class ChatHistory {
147
148
  // scrollback via endActivity — when a caller supplies an empty
148
149
  // `content` they're signalling that the header is already present
149
150
  // and only the expandable output body should render here.
150
- if (content) {
151
+ //
152
+ // History-loaded tool messages arrive with the raw stored payload as
153
+ // `content` and no `output` — collapse them through summarizeToolResult
154
+ // to a single live-activity-style line. Callers that pass `output`
155
+ // (debug blocks, live-activity error pass-through) keep the legacy
156
+ // header chrome.
157
+ if (msg.content && !msg.output) {
158
+ const summary = safeContent(summarizeToolResult(msg.content));
159
+ if (summary) {
160
+ 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} `;
164
+ const styled = summary.split(' · ').map((p) => _dimPaths(p)).join(sep);
165
+ out = ` ${indicator} ${styled}\n`;
166
+ lineCount = 1;
167
+ }
168
+ } else if (content) {
151
169
  const indicator = msg.isError
152
170
  ? `${UI_THEME.error}${UI_ICONS.error}${RST}`
153
171
  : `${UI_THEME.success}${UI_ICONS.success}${RST}`;
package/lib/ui/format.js CHANGED
@@ -95,11 +95,28 @@ function _categoryLabel(tag) {
95
95
  return cat.length >= CATEGORY_WIDTH ? cat.slice(0, CATEGORY_WIDTH) : cat.padEnd(CATEGORY_WIDTH);
96
96
  }
97
97
 
98
- function _truncate(text, max) {
98
+ // Display-only normalizer for tool argument text. Collapses every run of
99
+ // whitespace (including \n from heredocs and `\<NL>` line continuations)
100
+ // to a single space and trims the ends, so the summary line never spills
101
+ // across multiple physical rows. Pure: never mutates the value used for
102
+ // execution or sent to the model.
103
+ function normalizeCmdForDisplay(text) {
99
104
  if (text == null) return '';
100
- const s = String(text);
105
+ return String(text).replace(/\s+/g, ' ').trim();
106
+ }
107
+
108
+ // Truncate to fit `max` visible chars, normalizing first so a multi-line
109
+ // command becomes a single visual line. When the cut would land mid-word,
110
+ // back up to the nearest space — but only if it doesn't sacrifice more
111
+ // than ~30% of the available width (otherwise prefer the harder cut).
112
+ function _truncate(text, max) {
113
+ const s = normalizeCmdForDisplay(text);
101
114
  if (s.length <= max) return s;
102
- return s.slice(0, Math.max(0, max - 1)) + '…';
115
+ const cap = Math.max(0, max - 1);
116
+ let cut = s.slice(0, cap);
117
+ const lastSpace = cut.lastIndexOf(' ');
118
+ if (lastSpace > cap * 0.7) cut = cut.slice(0, lastSpace);
119
+ return cut + '…';
103
120
  }
104
121
 
105
122
  // Verb + target string. Never more than ~80 chars visible — longer URLs,
@@ -136,7 +153,28 @@ function _operation(tag, arg, attrs) {
136
153
  case 'recall_memory': return _truncate(`recall ${a.key || arg || ''}`, max);
137
154
  case 'list_memories': return 'list memories';
138
155
  case 'system_info': return 'system info';
139
- 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;
140
178
  }
141
179
  }
142
180
 
@@ -199,6 +237,7 @@ function formatToolLine(args) {
199
237
  durationMs,
200
238
  meta,
201
239
  error,
240
+ noDuration,
202
241
  } = args || {};
203
242
 
204
243
  let glyph;
@@ -218,7 +257,6 @@ function formatToolLine(args) {
218
257
  const catColor = (UI_THEME.categories && UI_THEME.categories[_normalizeTag(tag) && (TOOL_CATEGORIES[_normalizeTag(tag)] || 'tool')]) || UI_THEME.accent;
219
258
 
220
259
  const op = _operation(tag, arg, attrs);
221
- const durStr = formatDuration(durationMs || 0) + (status === 'pending' ? '…' : '');
222
260
  const metaParts = _metaParts(tag, meta, error);
223
261
 
224
262
  // Segment-by-segment styling. Each fragment carries its own ANSI codes
@@ -232,16 +270,73 @@ function formatToolLine(args) {
232
270
  const segments = [];
233
271
  segments.push(` ${glyphColor}${glyph}${RST} ${catColor}${cat}${RST}`);
234
272
  segments.push(`${UI_THEME.default}${op}${RST}`);
235
- segments.push(`${durColor}${durStr}${RST}`);
273
+ if (!noDuration) {
274
+ const durStr = formatDuration(durationMs || 0) + (status === 'pending' ? '…' : '');
275
+ segments.push(`${durColor}${durStr}${RST}`);
276
+ }
236
277
  for (const m of metaParts) {
237
278
  if (m) segments.push(`${metaColor}${m}${RST}`);
238
279
  }
239
280
  return segments.join(sep);
240
281
  }
241
282
 
283
+ // Collapse a stored tool-result string to a single-line summary in the same
284
+ // shape as the live activity bubble ("net · GET https://x · 200 · 256 KB").
285
+ // Pure: no ANSI, no IO, no allocation beyond the returned string. Idempotent
286
+ // — a value that already looks like a summary is returned untouched so the
287
+ // helper survives double-application once a separate display field lands in
288
+ // storage.
289
+ function summarizeToolResult(content) {
290
+ if (typeof content !== 'string' || !content) return '';
291
+ const trimmed = content.trim();
292
+ if (!trimmed) return '';
293
+
294
+ if (!trimmed.includes('\n') && trimmed.includes(' · ') && trimmed.length < 200) {
295
+ return trimmed;
296
+ }
297
+
298
+ // HTTP: agent.js formats as `HTTP <VERB> <url> (<status>):\n<body>`.
299
+ const httpMatch = content.match(/^HTTP\s+(\w+)\s+(\S+)\s+\((\d+)\)/);
300
+ if (httpMatch) {
301
+ const parts = ['net', `${httpMatch[1]} ${httpMatch[2]}`, httpMatch[3]];
302
+ const bytes = formatBytes(Buffer.byteLength(content, 'utf8'));
303
+ if (bytes) parts.push(bytes);
304
+ return parts.join(' · ');
305
+ }
306
+
307
+ // Exec: `Command \`<cmd>\`:\nExit code: <N>\n<output>`. The cmd may span
308
+ // multiple lines if the user passed a heredoc — non-greedy capture stops
309
+ // at the first `\`:` boundary, then we collapse whitespace for the preview.
310
+ const cmdMatch = content.match(/^Command `([\s\S]+?)`:/);
311
+ const exitMatch = content.match(/^Exit code: (-?\d+)$/m);
312
+ if (cmdMatch && exitMatch) {
313
+ const cmd = cmdMatch[1].replace(/\s+/g, ' ').trim();
314
+ const preview = cmd.length > 60 ? cmd.slice(0, 59) + '…' : cmd;
315
+ return `exec · ${preview} · exit ${exitMatch[1]}`;
316
+ }
317
+
318
+ const lines = content.split('\n');
319
+ if (lines.length <= 3 && /^(Wrote|Read|Created|Deleted|Moved|Renamed)\b/.test(lines[0])) {
320
+ return lines[0];
321
+ }
322
+
323
+ if (lines.length > 1 || content.length > 120) {
324
+ const firstLine = lines[0] || '';
325
+ const preview = firstLine.length > 100 ? firstLine.slice(0, 99) + '…' : firstLine;
326
+ const parts = ['tool', preview];
327
+ const bytes = formatBytes(Buffer.byteLength(content, 'utf8'));
328
+ if (bytes) parts.push(bytes);
329
+ return parts.join(' · ');
330
+ }
331
+
332
+ return trimmed;
333
+ }
334
+
242
335
  module.exports = {
243
336
  formatDuration,
244
337
  formatBytes,
245
338
  formatHttpErrorTag,
246
339
  formatToolLine,
340
+ summarizeToolResult,
341
+ normalizeCmdForDisplay,
247
342
  };
@@ -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
 
@@ -7,6 +7,7 @@
7
7
  // the process dies.
8
8
 
9
9
  const writer = require('./writer');
10
+ const dbg = require('../debug');
10
11
 
11
12
  let _registered = false;
12
13
 
@@ -30,20 +31,24 @@ function registerTerminalCleanup() {
30
31
 
31
32
  // Normal exit + process.exit(): fires synchronously, last thing Node does.
32
33
  // Catches every path that doesn't already manually call teardown.
33
- process.on('exit', () => { try { writer.teardown(); } catch {} });
34
+ process.on('exit', () => {
35
+ try { writer.teardown(); } catch {}
36
+ try { dbg.close(); } catch {}
37
+ });
34
38
 
35
39
  // Signals that should terminate the app. Cleanup first, then exit with
36
40
  // the conventional 128+signum code. In TUI raw mode, Ctrl+C is consumed
37
41
  // at the byte level and SIGINT is not delivered, so this handler only
38
42
  // trips in non-raw contexts (one-shot commands, readline prompts, etc.).
39
- process.on('SIGINT', () => { try { writer.teardown(); } catch {} process.exit(130); });
40
- process.on('SIGTERM', () => { try { writer.teardown(); } catch {} process.exit(143); });
41
- process.on('SIGHUP', () => { try { writer.teardown(); } catch {} process.exit(129); });
43
+ process.on('SIGINT', () => { try { writer.teardown(); } catch {} try { dbg.close(); } catch {} process.exit(130); });
44
+ process.on('SIGTERM', () => { try { writer.teardown(); } catch {} try { dbg.close(); } catch {} process.exit(143); });
45
+ process.on('SIGHUP', () => { try { writer.teardown(); } catch {} try { dbg.close(); } catch {} process.exit(129); });
42
46
 
43
47
  // Last-chance net: if something throws outside a try/catch, still
44
48
  // restore terminal state before the stack trace prints.
45
49
  process.on('uncaughtException', (err) => {
46
50
  try { writer.teardown(); } catch {}
51
+ try { dbg.close(); } catch {}
47
52
  // audit: allowed — crash handler stderr after writer teardown.
48
53
  try { console.error(err && err.stack ? err.stack : err); } catch {}
49
54
  process.exit(1);
@@ -51,6 +56,7 @@ function registerTerminalCleanup() {
51
56
 
52
57
  process.on('unhandledRejection', (reason) => {
53
58
  try { writer.teardown(); } catch {}
59
+ try { dbg.close(); } catch {}
54
60
  // audit: allowed — crash handler stderr after writer teardown.
55
61
  try { console.error(reason && reason.stack ? reason.stack : reason); } catch {}
56
62
  process.exit(1);
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