@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
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ // Pure renderer for a ToolOperation descriptor (Output Refactor — Phase 1).
4
+ //
5
+ // `renderOperation(descriptor, { mode, phase, maxLines })` → the string for that
6
+ // phase/mode. This is the SINGLE place a tool-call's chrome line is assembled
7
+ // for the interactive path; chat-turn.js builds a descriptor and calls here
8
+ // instead of assembling formatToolLine arguments inline.
9
+ //
10
+ // Phase 1 is a re-routing, not a re-styling: the renderer internally reuses the
11
+ // existing `formatToolLine` (glyph/category/operation/meta assembly) and
12
+ // `buildExecutionDiff` (diff body), so its output is byte-for-byte identical to
13
+ // today's. Do NOT change glyphs, spacing, colours, or wording here — equivalence
14
+ // with the old formatters is the phase's pass/fail.
15
+ //
16
+ // modes: 'ansi' (interactive) — assembles the chrome line via formatToolLine.
17
+ // 'json' (Phase 6d-i) — the pure embeddable per-operation object built
18
+ // from the shared `operationCore` (descriptor-native names, no
19
+ // ANSI/IO/framing); a superset-or-equal of serializeOperation's
20
+ // persistable core, minus the `v` persistence tag.
21
+ // 'event' (Phase 6d-i) — the json object plus the `phase` it rendered
22
+ // for, for a single per-lifecycle emission. Headless (6d-ii)
23
+ // owns any NDJSON envelope / `type` tag around it.
24
+ // phases: 'pending' — the live activity line (with growing timer)
25
+ // 'result' — the committed final line
26
+ // 'detail' — the collapsible body (Phase 5): a file-edit diff (expanded
27
+ // to maxLines) or a shell/MCP/subagent output preview
28
+ // (previewLines + `… N more lines`). Errors carry no detail
29
+ // (expanded elsewhere).
30
+
31
+ const { formatToolLine, formatOutputPreview } = require('./format');
32
+ const { buildExecutionDiff } = require('./diff');
33
+ const { operationCore } = require('./tool-operation');
34
+
35
+ // Map the descriptor's status vocabulary back to formatToolLine's.
36
+ function _formatStatus(descriptor, phase) {
37
+ if (phase === 'pending') return 'pending';
38
+ return descriptor.status === 'error' ? 'failure' : 'success';
39
+ }
40
+
41
+ // Reconstruct the formatToolLine argument bag from the descriptor. Phase 1
42
+ // guarantees identical output by feeding the same fields the old call sites did.
43
+ function _toolLineArgs(descriptor, phase) {
44
+ return {
45
+ status: _formatStatus(descriptor, phase),
46
+ // Colour is resolved from the descriptor's category (Phase 2.5): the
47
+ // renderer hands formatToolLine the already-resolved category so colour is
48
+ // the renderer's job, keyed by the descriptor, not re-derived from the tag.
49
+ category: descriptor.category,
50
+ tag: descriptor.tag,
51
+ arg: descriptor.target,
52
+ attrs: descriptor.attrs,
53
+ durationMs: descriptor.durationMs == null ? 0 : descriptor.durationMs,
54
+ meta: phase === 'pending' ? null : descriptor.meta,
55
+ error: phase === 'pending' ? null : descriptor.error,
56
+ noDuration: descriptor.noDuration,
57
+ };
58
+ }
59
+
60
+ function _renderAnsi(descriptor, opts) {
61
+ const phase = opts.phase || 'result';
62
+ if (phase === 'detail') {
63
+ // The detail body, COLLAPSED per the Phase 5 policy (keyed by detail-kind):
64
+ // - diff → the file-edit diff, EXPANDED to maxLines (unchanged).
65
+ // - output → a shell_preview_lines preview + `… N more lines`. (Errors
66
+ // carry no detail — the error body renders expanded on the
67
+ // chat-history path.)
68
+ const detail = descriptor.detail;
69
+ if (!detail) return '';
70
+ if (detail.kind === 'diff') {
71
+ const diffStr = buildExecutionDiff({ diff: detail.payload, maxLines: opts.maxLines });
72
+ return diffStr || '';
73
+ }
74
+ if (detail.kind === 'output') {
75
+ const { lines, hiddenCount } = formatOutputPreview(detail.payload.body, {
76
+ previewLines: opts.previewLines,
77
+ cols: opts.cols,
78
+ });
79
+ const out = lines.slice();
80
+ if (hiddenCount > 0) {
81
+ out.push(`… ${hiddenCount} more ${hiddenCount === 1 ? 'line' : 'lines'}`);
82
+ }
83
+ return out.join('\n');
84
+ }
85
+ return '';
86
+ }
87
+ return formatToolLine(_toolLineArgs(descriptor, phase));
88
+ }
89
+
90
+ function renderOperation(descriptor, opts) {
91
+ if (!descriptor) return '';
92
+ const o = opts || {};
93
+ const mode = o.mode || 'ansi';
94
+ if (mode === 'ansi') return _renderAnsi(descriptor, o);
95
+ // json/event are PURE structured-data modes (Phase 6d-i): no ANSI, no IO, no
96
+ // framing. Both derive ONLY from the shared `operationCore` mapping — the same
97
+ // one `serializeOperation` uses — so there is exactly one descriptor→data
98
+ // serializer in the codebase.
99
+ // - json : the embeddable per-operation object (descriptor-native field
100
+ // names; the persistable core WITHOUT the `v` persistence tag).
101
+ // - event: the same data shaped for a single per-lifecycle emission — the
102
+ // json object plus the `phase` it was rendered for. The consumer
103
+ // (headless, Phase 6d-ii) owns any envelope/`type` framing.
104
+ if (mode === 'json') return operationCore(descriptor);
105
+ if (mode === 'event') {
106
+ const core = operationCore(descriptor);
107
+ return core && { ...core, phase: o.phase || 'result' };
108
+ }
109
+ // unknown mode — degrade to empty string (never throw).
110
+ return '';
111
+ }
112
+
113
+ module.exports = { renderOperation };
package/lib/ui/select.js CHANGED
@@ -11,7 +11,7 @@ const writer = require('./writer');
11
11
  // Two input paths:
12
12
  // * TUI: opts.captureNavigation(handler) → release fn. Mirrors the
13
13
  // permission picker. The host (input-field) routes prev/next/
14
- // select/cancel/expand actions to handler; release detaches.
14
+ // select/cancel actions to handler; release detaches.
15
15
  // * Non-TUI: direct raw stdin. Used by cmdModels and the permission-
16
16
  // prompt fallback in non-chat command flows.
17
17
  //
@@ -59,8 +59,6 @@ async function interactiveSelect(items, renderItem, options) {
59
59
  writer.clearModal();
60
60
  if (typeof releaseNav === 'function') releaseNav();
61
61
  resolve(action === 'select' ? idx : null);
62
- } else if (action === 'expand') {
63
- if (typeof opts.onExpand === 'function') opts.onExpand();
64
62
  }
65
63
  });
66
64
  });
@@ -93,7 +91,6 @@ async function interactiveSelect(items, renderItem, options) {
93
91
  const onData = (chunk) => {
94
92
  if (done) return;
95
93
  const data = chunk.toString('utf8');
96
- if (data[0] === '\x0f') { if (typeof opts.onExpand === 'function') opts.onExpand(); return; }
97
94
  if (data === '\x03' || data === '\x1b' || data === 'q') { finish(null); return; }
98
95
  if (data === '\r' || data === '\n') { finish(idx); return; }
99
96
  if (data === '\x1b[A' || data === 'k') {
@@ -1,8 +1,26 @@
1
1
  'use strict';
2
2
 
3
- const { RST, DIM, FG_RED, FG_DARK, SPINNER_DEFS } = require('./ansi');
4
- const { UI_THEME } = require('./theme');
3
+ const { RST, DIM, SPINNER_DEFS } = require('./ansi');
4
+ const { UI_THEME, FG_RED, FG_DARK, colorEnabled } = require('./theme');
5
5
  const { stripAnsi, termWidth } = require('./utils');
6
+ const { AnimDriver, TICKS_PER_SECOND } = require('./anim');
7
+
8
+ // States that animate the status-bar spinner (and, in the live region below,
9
+ // the per-tool running glyph + elapsed meter). While in one of these the
10
+ // single driver repaints at the base (100 ms) cadence; otherwise it only
11
+ // repaints for the clock (1 Hz), and when idle-paused it stops entirely.
12
+ const ANIM_STATES = new Set(['thinking', 'streaming', 'tool', 'waiting_download']);
13
+
14
+ // Compact token count for the estimated split (Variant B): the base/working
15
+ // estimates are shown abbreviated (e.g. 12k, 5.6k, 200k) to keep the row short
16
+ // — they are estimates, so sub-thousand precision is noise. The real measured
17
+ // total is rendered separately with full toLocaleString precision (no ~).
18
+ function abbrevTokens(n) {
19
+ const v = Math.max(0, Math.round(Number(n) || 0));
20
+ if (v < 1000) return String(v);
21
+ const k = v / 1000;
22
+ return (k < 10 ? k.toFixed(1) : String(Math.round(k))) + 'k';
23
+ }
6
24
 
7
25
  // Status bar is a *content producer* only. It builds a single line string
8
26
  // and hands it to the UI orchestrator via the onChange callback; the
@@ -27,31 +45,85 @@ class FullStatusBar {
27
45
  this._reportedContext = 0;
28
46
  this._pendingDelta = 0;
29
47
  this._contextLimit = null;
48
+ // Estimated base/working split (Variant B, display-only). Both are char/4
49
+ // estimates of the same measured prompt; rendered ~-prefixed alongside the
50
+ // real total. 0 until the agent loop reports the first estimate.
51
+ this._baseEst = 0;
52
+ this._workingEst = 0;
30
53
  this._speed = 0;
31
54
  this._streamStart = null;
32
55
  this._streamTokens = 0;
33
56
  this._tokensSinceUpdate = 0;
34
57
  this._animIdx = 0;
35
- this._animTimer = null;
36
58
  this._paused = false;
37
- // Clock tick drives the `HH:MM:SS` part of the right-hand side. Every
38
- // tick just notifies the orchestrator to re-push the live region — the
39
- // compound erase+redraw goes through the writer's queue so a tick
40
- // falling mid-bubble can't produce a torn frame.
41
- this._clockTimer = setInterval(() => this._notify(), 1000);
59
+ this._destroyed = false;
60
+ // Single animation driver (Phase 3). Replaces the two independent
61
+ // setIntervals (1 Hz clock + 100 ms spinner) with one timer. Both the
62
+ // clock field and the spinner glyph are now *subscribers*: each tick the
63
+ // driver advances one frame counter, runs both subscribers, and performs
64
+ // exactly one coordinated repaint (through _notify → the orchestrator →
65
+ // writer.setLive, which also re-renders the live-region activity rows, so
66
+ // the per-tool running glyph + elapsed meter ride the same repaint). The
67
+ // driver runs while there's something to animate OR while the clock should
68
+ // tick; when idle-paused with nothing animating it STOPS — preserving the
69
+ // 5404bd0 idle-scroll fix (no periodic redraw → the viewport scrolls).
70
+ this._driver = new AnimDriver();
71
+ this._driver.onRepaint(() => this._notify());
72
+ // Clock subscriber: request a repaint once per second, and only while not
73
+ // idle-paused (the clock tick is what fought user scrollback).
74
+ this._driver.subscribe((frame) => !this._paused && (frame % TICKS_PER_SECOND === 0));
75
+ // Spinner subscriber: while in an animating state, advance the glyph frame
76
+ // every tick and request the repaint (the fine 100 ms cadence).
77
+ this._driver.subscribe((frame) => {
78
+ if (!ANIM_STATES.has(this._state)) return false;
79
+ this._animIdx = frame;
80
+ return true;
81
+ });
82
+ this._syncDriver();
42
83
  }
43
84
 
44
- pause() { this._paused = true; }
45
- resume() { this._paused = false; this._notify(); }
85
+ // Start/stop the single driver to match the current need: run while there is
86
+ // something to animate (ANIM_STATES) OR while the clock should tick (not
87
+ // idle-paused); stop when neither holds. Idempotent (start()/stop() guard
88
+ // against stacking), so repeated pause/resume cycles never leak a timer.
89
+ _syncDriver() {
90
+ if (this._destroyed) return;
91
+ const animating = ANIM_STATES.has(this._state);
92
+ if (!this._paused || animating) this._driver.start();
93
+ else this._driver.stop();
94
+ }
95
+
96
+ // pause() halts the periodic 1 Hz clock repaint so a forced redraw no longer
97
+ // snaps the viewport to the bottom while the user scrolls up. If a tool is
98
+ // mid-flight (an ANIM_STATE) the driver keeps running for the spinner — but
99
+ // the clock subscriber goes quiet, so there's no 1 Hz scroll-fighting tick.
100
+ // When nothing is animating, _syncDriver stops the driver outright (idle =
101
+ // no periodic repaint). Event-driven redraws (update/updateMetrics/setCost)
102
+ // are unaffected — they call _notify directly. resume() restarts the driver
103
+ // and forces one repaint so the viewport returns to the input prompt.
104
+ pause() { this._paused = true; this._syncDriver(); }
105
+ resume() { this._paused = false; this._syncDriver(); this._notify(); }
46
106
 
47
107
  setModel(name) {
48
108
  this._model = name || '';
49
109
  this._notify();
50
110
  }
51
111
 
112
+ // Session cost string (e.g. "$0.0123" or "unknown"), shown when show_cost is
113
+ // on (Task 2.6). null/'' hides the field. The caller computes the value from
114
+ // the price table × usage; the bar just renders it.
115
+ setCost(str) {
116
+ this._cost = str || null;
117
+ this._notify();
118
+ }
119
+
52
120
  update(state, label) {
53
121
  this._state = state || 'idle';
54
122
  if (label !== undefined) this._label = label;
123
+ // A state change means work is happening — keep the not-paused ⇒
124
+ // driver-running invariant. (update('idle') unconditionally restarts the
125
+ // driver; the startup re-sync in chat.js re-pauses it if the field is
126
+ // already idle — see status-bar-resync.test.js.)
55
127
  this._paused = false;
56
128
 
57
129
  if (state === 'streaming') {
@@ -60,12 +132,13 @@ class FullStatusBar {
60
132
  this._streamStart = null; this._streamTokens = 0; this._speed = 0;
61
133
  }
62
134
 
63
- const animStates = ['thinking', 'streaming', 'tool', 'waiting_download'];
64
- if (animStates.includes(state) && !this._animTimer) {
65
- this._animTimer = setInterval(() => { this._animIdx++; this._notify(); }, 100);
66
- } else if (!animStates.includes(state) && this._animTimer) {
67
- clearInterval(this._animTimer); this._animTimer = null; this._animIdx = 0;
68
- }
135
+ // Leaving an animating state resets the spinner frame so the next run
136
+ // starts at frame 0 rather than wherever the counter happened to be.
137
+ if (!ANIM_STATES.has(this._state)) this._animIdx = 0;
138
+ // One driver, started/stopped as a unit (no per-state timer). The spinner
139
+ // subscriber animates while in an ANIM_STATE; the clock subscriber ticks
140
+ // at 1 Hz while not paused.
141
+ this._syncDriver();
69
142
  this._notify();
70
143
  }
71
144
 
@@ -92,6 +165,10 @@ class FullStatusBar {
92
165
  this._reportedContext = data.contextTokens;
93
166
  this._pendingDelta = 0;
94
167
  }
168
+ // Estimated split (Variant B) — recomputed per request by the api client and
169
+ // threaded through here, so it tracks MCP-connect / plan-mode / native-vs-XML.
170
+ if (typeof data.baseEst === 'number') this._baseEst = data.baseEst;
171
+ if (typeof data.workingEst === 'number') this._workingEst = data.workingEst;
95
172
  if (data.tokenLimit && typeof data.tokenLimit.limit === 'number') {
96
173
  this._contextLimit = data.tokenLimit.limit;
97
174
  }
@@ -122,9 +199,9 @@ class FullStatusBar {
122
199
  // to build the live region. ALWAYS returns a string — the status row is a
123
200
  // permanent fixture of the live region, never omitted. Missing data
124
201
  // 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.
202
+ // Pausing (idle scroll) stops the periodic *tick* (the driver is stopped)
203
+ // but the row itself is still produced whenever the composer asks for it —
204
+ // an event-driven _notify still repaints normally.
128
205
  renderLine() {
129
206
  const layout = this._layout;
130
207
  const cols = layout.cols;
@@ -163,18 +240,31 @@ class FullStatusBar {
163
240
  // tokens, keeping the model name readable to the last. Never chop a
164
241
  // field mid-character.
165
242
  const tokenField = this._buildTokenField();
243
+ // Per-field colour (Phase 2.5): the right chrome is no longer wholesale-DIM
244
+ // — the persistent footer was the single most washed-out region. The model
245
+ // (the field that matters most) renders in accent; tokens/time/cost/speed
246
+ // are subtle (one step up from dim). Only the separators stay DIM. Under
247
+ // NO_COLOR/non-TTY every field colour resolves to ''.
248
+ const on = colorEnabled();
249
+ const cSubtle = on ? UI_THEME.subtle : '';
250
+ const cModel = on ? UI_THEME.accent : '';
166
251
  const fields = [
167
- { visible: timePart, ansi: timePart, priority: 2 },
168
- { visible: this._model || '—', ansi: this._model || '—', priority: 4 },
169
- { visible: tokenField.visible, ansi: tokenField.ansi, priority: 3 },
252
+ { visible: timePart, ansi: timePart, color: cSubtle, priority: 2 },
253
+ { visible: this._model || '—', ansi: this._model || '—', color: cModel, priority: 4 },
254
+ { visible: tokenField.visible, ansi: tokenField.ansi, color: cSubtle, priority: 3 },
170
255
  ];
256
+ if (this._cost) {
257
+ const c = `${this._cost}`;
258
+ fields.push({ visible: c, ansi: c, color: cSubtle, priority: 2 });
259
+ }
171
260
  if (state === 'streaming' && this._speed > 0) {
172
261
  const s = `${this._speed} t/s`;
173
- fields.push({ visible: s, ansi: s, priority: 1 });
262
+ fields.push({ visible: s, ansi: s, color: cSubtle, priority: 1 });
174
263
  }
175
264
 
176
265
  const sep = ' · ';
177
- const sepAnsi = ` ${RST}${DIM}·${RST}${DIM} `;
266
+ // Separators stay DIM; fields carry their own colour (no outer DIM wrap).
267
+ const sepAnsi = on ? ` ${DIM}·${RST} ` : ' · ';
178
268
  const leftWidth = termWidth(stripAnsi(left));
179
269
  // Keep at least one space between left and right chrome when left is
180
270
  // non-empty; when idle (left == '') the right can sit at column 0.
@@ -208,7 +298,8 @@ class FullStatusBar {
208
298
  }
209
299
 
210
300
  const rightWidth = widthOf(keep);
211
- const rightAnsi = `${DIM}${keep.map((f) => f.ansi).join(sepAnsi)}${RST}`;
301
+ const R = on ? RST : '';
302
+ const rightAnsi = keep.map((f) => `${f.color || ''}${f.ansi}${R}`).join(sepAnsi);
212
303
  const padding = Math.max(gap > 0 ? gap : 0, maxCols - leftWidth - rightWidth);
213
304
  return left + ' '.repeat(padding) + rightAnsi;
214
305
  }
@@ -221,21 +312,35 @@ class FullStatusBar {
221
312
  _buildTokenField() {
222
313
  const used = Math.max(0, (this._reportedContext | 0) + (this._pendingDelta | 0));
223
314
  const usedStr = used.toLocaleString();
315
+ // Estimated split prefix (Variant B): "~Nk working · ~Nk base · ". Working
316
+ // first (it's the part that grows and matters), base second (fixed-ish
317
+ // reference). Both carry ~ — they're char/4 estimates. The measured
318
+ // total/limit/percent that follows carries NO ~ (it's the truth anchor).
319
+ // Shown only once an estimate has arrived, so a fresh bar has no ~ noise.
320
+ let estPrefix = '';
321
+ if (this._workingEst > 0 || this._baseEst > 0) {
322
+ estPrefix = `~${abbrevTokens(this._workingEst)} working · ~${abbrevTokens(this._baseEst)} base · `;
323
+ }
224
324
  if (!this._contextLimit) {
225
- const s = `${usedStr} tok`;
325
+ const s = `${estPrefix}${usedStr} tok`;
226
326
  return { visible: s, ansi: s };
227
327
  }
228
328
  const limit = this._contextLimit;
229
329
  const pct = limit > 0 ? Math.round((used / limit) * 100) : 0;
230
330
  const limitStr = limit.toLocaleString();
231
- const visible = `${usedStr} / ${limitStr} tok (${pct}%)`;
331
+ const visible = `${estPrefix}${usedStr} / ${limitStr} tok (${pct}%)`;
332
+ // The pct override resets and restores the field's base colour (subtle),
333
+ // not the old outer DIM. Gated on colour: under NO_COLOR/non-TTY the field
334
+ // is plain text so no ANSI leaks into a piped status read.
232
335
  let pctAnsi = `${pct}%`;
233
- if (pct >= 90) {
234
- pctAnsi = `${RST}${UI_THEME.error}${pct}%${RST}${DIM}`;
235
- } else if (pct >= 70) {
236
- pctAnsi = `${RST}${UI_THEME.warning}${pct}%${RST}${DIM}`;
336
+ if (colorEnabled()) {
337
+ if (pct >= 90) {
338
+ pctAnsi = `${RST}${UI_THEME.error}${pct}%${RST}${UI_THEME.subtle}`;
339
+ } else if (pct >= 70) {
340
+ pctAnsi = `${RST}${UI_THEME.warning}${pct}%${RST}${UI_THEME.subtle}`;
341
+ }
237
342
  }
238
- const ansi = `${usedStr} / ${limitStr} tok (${pctAnsi})`;
343
+ const ansi = `${estPrefix}${usedStr} / ${limitStr} tok (${pctAnsi})`;
239
344
  return { visible, ansi };
240
345
  }
241
346
 
@@ -244,7 +349,8 @@ class FullStatusBar {
244
349
  // affecting surrounding rows.
245
350
  renderSeparator() {
246
351
  const cols = this._layout.cols;
247
- return `${FG_DARK}${'─'.repeat(Math.max(0, cols - 1))}${RST}`;
352
+ const rule = '─'.repeat(Math.max(0, cols - 1));
353
+ return colorEnabled() ? `${FG_DARK}${rule}${RST}` : rule;
248
354
  }
249
355
 
250
356
  // Back-compat: older call sites (command.js, create-ui.js) call
@@ -255,13 +361,17 @@ class FullStatusBar {
255
361
  _renderBar() { this._notify(); }
256
362
 
257
363
  _notify() {
258
- if (this._paused) { this._onChange(); return; }
364
+ // Every redraw the driver's coordinated tick AND event-driven
365
+ // (update/updateMetrics/setCost) — flows through here. Pausing is done by
366
+ // stopping the driver (see pause/_syncDriver), NOT by gating here: a guard
367
+ // would also suppress the event-driven repaints that must keep working
368
+ // while idle scroll is paused.
259
369
  this._onChange();
260
370
  }
261
371
 
262
372
  destroy() {
263
- if (this._animTimer) { clearInterval(this._animTimer); this._animTimer = null; }
264
- if (this._clockTimer) { clearInterval(this._clockTimer); this._clockTimer = null; }
373
+ this._destroyed = true;
374
+ this._driver.stop();
265
375
  }
266
376
  }
267
377
 
package/lib/ui/stream.js CHANGED
@@ -8,6 +8,24 @@ const writer = require('./writer');
8
8
 
9
9
  const THINK_OPEN = '<think>', THINK_CLOSE = '</think>';
10
10
 
11
+ // Per-line syntax highlight for a code-block body line. Module-level (uses no
12
+ // instance state) so the TUI styler (lib/ui/md-stream.js) can compose the SAME
13
+ // highlighter the non-TUI StreamRenderer uses — one implementation, no drift.
14
+ // Resets to `${RST}${FG_CODE_BG}` after each span so the caller's code-block
15
+ // background survives to the end of the line (where an EL fills it to the edge).
16
+ function colorizeCode(line) {
17
+ const C_KW = '\x1b[38;5;176m', C_STR = '\x1b[38;5;114m', C_CMT = '\x1b[38;5;242m', C_NUM = '\x1b[38;5;215m', C_RST = `${RST}${FG_CODE_BG}`;
18
+ let result = '', i = 0;
19
+ while (i < line.length) {
20
+ if (line[i] === '#' || (line[i] === '/' && line[i+1] === '/')) { result += `${C_CMT}${line.slice(i)}${C_RST}`; break; }
21
+ if (line[i] === '"' || line[i] === "'") { const quote = line[i]; let j = i+1; while (j < line.length && line[j] !== quote) { if (line[j] === '\\') j++; j++; } j = Math.min(j+1, line.length); result += `${C_STR}${line.slice(i,j)}${C_RST}`; i = j; continue; }
22
+ if (/[a-zA-Z_]/.test(line[i])) { let j = i; while (j < line.length && /\w/.test(line[j])) j++; const word = line.slice(i,j); result += KEYWORDS.has(word) ? `${C_KW}${word}${C_RST}` : word; i = j; continue; }
23
+ if (/\d/.test(line[i])) { let j = i; while (j < line.length && /[\d.]/.test(line[j])) j++; result += `${C_NUM}${line.slice(i,j)}${C_RST}`; i = j; continue; }
24
+ result += line[i++];
25
+ }
26
+ return result;
27
+ }
28
+
11
29
  class StreamRenderer {
12
30
  constructor(options) {
13
31
  const opts = options || {};
@@ -137,18 +155,7 @@ class StreamRenderer {
137
155
  writer.scrollback(` ${FG_TAG}◆ ${name}${RST} ${FG_DARK}${preview}${RST}`);
138
156
  }
139
157
 
140
- _colorizeCode(line) {
141
- const C_KW = '\x1b[38;5;176m', C_STR = '\x1b[38;5;114m', C_CMT = '\x1b[38;5;242m', C_NUM = '\x1b[38;5;215m', C_RST = `${RST}${FG_CODE_BG}`;
142
- let result = '', i = 0;
143
- while (i < line.length) {
144
- if (line[i] === '#' || (line[i] === '/' && line[i+1] === '/')) { result += `${C_CMT}${line.slice(i)}${C_RST}`; break; }
145
- if (line[i] === '"' || line[i] === "'") { const quote = line[i]; let j = i+1; while (j < line.length && line[j] !== quote) { if (line[j] === '\\') j++; j++; } j = Math.min(j+1, line.length); result += `${C_STR}${line.slice(i,j)}${C_RST}`; i = j; continue; }
146
- if (/[a-zA-Z_]/.test(line[i])) { let j = i; while (j < line.length && /\w/.test(line[j])) j++; const word = line.slice(i,j); result += KEYWORDS.has(word) ? `${C_KW}${word}${C_RST}` : word; i = j; continue; }
147
- if (/\d/.test(line[i])) { let j = i; while (j < line.length && /[\d.]/.test(line[j])) j++; result += `${C_NUM}${line.slice(i,j)}${C_RST}`; i = j; continue; }
148
- result += line[i++];
149
- }
150
- return result;
151
- }
158
+ _colorizeCode(line) { return colorizeCode(line); }
152
159
  }
153
160
 
154
- module.exports = { StreamRenderer };
161
+ module.exports = { StreamRenderer, colorizeCode };