@semalt-ai/code 1.19.0 → 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 (81) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/ARCHITECTURE.md +6 -95
  3. package/CLAUDE.md +196 -1874
  4. package/README.md +1 -1
  5. package/docs/ARCHITECTURE.md +1321 -0
  6. package/docs/CONFIG.md +340 -0
  7. package/docs/HISTORY.md +245 -0
  8. package/index.js +1 -1
  9. package/lib/agent.js +145 -16
  10. package/lib/api.js +28 -3
  11. package/lib/commands/chat-session.js +187 -4
  12. package/lib/commands/chat-slash.js +16 -0
  13. package/lib/commands/chat-turn.js +272 -49
  14. package/lib/commands/chat.js +12 -8
  15. package/lib/config.js +27 -0
  16. package/lib/constants.js +30 -1
  17. package/lib/headless.js +36 -1
  18. package/lib/images.js +8 -2
  19. package/lib/permissions.js +23 -16
  20. package/lib/prompts.js +15 -3
  21. package/lib/tool_registry.js +357 -53
  22. package/lib/tool_specs.js +42 -8
  23. package/lib/tools.js +80 -19
  24. package/lib/ui/anim.js +86 -0
  25. package/lib/ui/ansi.js +17 -27
  26. package/lib/ui/chat-history.js +253 -71
  27. package/lib/ui/create-ui.js +67 -24
  28. package/lib/ui/diff.js +90 -25
  29. package/lib/ui/file-activity.js +236 -0
  30. package/lib/ui/format.js +173 -28
  31. package/lib/ui/input-field.js +5 -4
  32. package/lib/ui/md-stream.js +234 -0
  33. package/lib/ui/render-operation.js +113 -0
  34. package/lib/ui/select.js +1 -4
  35. package/lib/ui/status-bar.js +99 -57
  36. package/lib/ui/stream.js +20 -13
  37. package/lib/ui/theme.js +190 -45
  38. package/lib/ui/tool-operation.js +190 -0
  39. package/lib/ui/utils.js +9 -5
  40. package/lib/ui/web-activity.js +58 -6
  41. package/lib/ui/writer.js +159 -45
  42. package/lib/ui.js +1 -1
  43. package/package.json +1 -1
  44. package/test/anim-driver.test.js +153 -0
  45. package/test/ask-user-display.test.js +226 -0
  46. package/test/ask-user-gate.test.js +231 -0
  47. package/test/chat-history-nocolor.test.js +155 -0
  48. package/test/chat-relogin.test.js +207 -0
  49. package/test/defer-detail-band.test.js +403 -0
  50. package/test/detail-band-tab-flatten.test.js +242 -0
  51. package/test/exec-diff.test.js +268 -0
  52. package/test/executors.test.js +250 -13
  53. package/test/extract-tool-calls.test.js +37 -3
  54. package/test/file-activity.test.js +522 -0
  55. package/test/grep-path-target.test.js +227 -0
  56. package/test/harness/chat-harness.js +2 -1
  57. package/test/headless.test.js +146 -1
  58. package/test/input-field-ctrl-o.test.js +37 -0
  59. package/test/live-height-physical.test.js +281 -0
  60. package/test/max-iterations.test.js +9 -7
  61. package/test/md-stream.test.js +183 -0
  62. package/test/native-dispatch.test.js +53 -0
  63. package/test/native-live-narration.test.js +254 -0
  64. package/test/output-heredoc-leak.test.js +195 -0
  65. package/test/output-preview.test.js +245 -0
  66. package/test/permissions.test.js +199 -0
  67. package/test/read-paginate.test.js +1 -1
  68. package/test/render-operation.test.js +317 -0
  69. package/test/replay-descriptor-xml.test.js +216 -0
  70. package/test/replay-descriptor.test.js +189 -0
  71. package/test/replay-web-aggregate.test.js +291 -0
  72. package/test/replay-web-persist.test.js +241 -0
  73. package/test/running-glyph-anim.test.js +111 -0
  74. package/test/status-bar-driver.test.js +93 -0
  75. package/test/status-bar-resync.test.js +188 -0
  76. package/test/stream-parser.test.js +24 -0
  77. package/test/theme-palette.test.js +166 -0
  78. package/test/truncate-visible.test.js +78 -0
  79. package/test/view-image.test.js +199 -0
  80. package/test/web-activity-ordering.test.js +12 -3
  81. package/path +0 -1
@@ -1,8 +1,15 @@
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']);
6
13
 
7
14
  // Compact token count for the estimated split (Variant B): the base/working
8
15
  // estimates are shown abbreviated (e.g. 12k, 5.6k, 200k) to keep the row short
@@ -48,37 +55,54 @@ class FullStatusBar {
48
55
  this._streamTokens = 0;
49
56
  this._tokensSinceUpdate = 0;
50
57
  this._animIdx = 0;
51
- this._animTimer = null;
52
58
  this._paused = false;
53
- // Clock tick drives the `HH:MM:SS` part of the right-hand side. Every
54
- // tick just notifies the orchestrator to re-push the live region — the
55
- // compound erase+redraw goes through the writer's queue so a tick
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;
68
- 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();
69
83
  }
70
84
 
71
- _stopClock() {
72
- if (this._clockTimer) { clearInterval(this._clockTimer); this._clockTimer = null; }
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();
73
94
  }
74
95
 
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
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
79
103
  // 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(); }
104
+ pause() { this._paused = true; this._syncDriver(); }
105
+ resume() { this._paused = false; this._syncDriver(); this._notify(); }
82
106
 
83
107
  setModel(name) {
84
108
  this._model = name || '';
@@ -97,9 +121,10 @@ class FullStatusBar {
97
121
  this._state = state || 'idle';
98
122
  if (label !== undefined) this._label = label;
99
123
  // A state change means work is happening — keep the not-paused ⇒
100
- // clock-running invariant (idempotent, so no stacked timer).
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.)
101
127
  this._paused = false;
102
- this._startClock();
103
128
 
104
129
  if (state === 'streaming') {
105
130
  if (!this._streamStart) { this._streamStart = Date.now(); this._streamTokens = 0; }
@@ -107,12 +132,13 @@ class FullStatusBar {
107
132
  this._streamStart = null; this._streamTokens = 0; this._speed = 0;
108
133
  }
109
134
 
110
- const animStates = ['thinking', 'streaming', 'tool', 'waiting_download'];
111
- if (animStates.includes(state) && !this._animTimer) {
112
- this._animTimer = setInterval(() => { this._animIdx++; this._notify(); }, 100);
113
- } else if (!animStates.includes(state) && this._animTimer) {
114
- clearInterval(this._animTimer); this._animTimer = null; this._animIdx = 0;
115
- }
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();
116
142
  this._notify();
117
143
  }
118
144
 
@@ -173,9 +199,9 @@ class FullStatusBar {
173
199
  // to build the live region. ALWAYS returns a string — the status row is a
174
200
  // permanent fixture of the live region, never omitted. Missing data
175
201
  // renders as a short placeholder ("—") so the row width is stable.
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.
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.
179
205
  renderLine() {
180
206
  const layout = this._layout;
181
207
  const cols = layout.cols;
@@ -214,22 +240,31 @@ class FullStatusBar {
214
240
  // tokens, keeping the model name readable to the last. Never chop a
215
241
  // field mid-character.
216
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 : '';
217
251
  const fields = [
218
- { visible: timePart, ansi: timePart, priority: 2 },
219
- { visible: this._model || '—', ansi: this._model || '—', priority: 4 },
220
- { 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 },
221
255
  ];
222
256
  if (this._cost) {
223
257
  const c = `${this._cost}`;
224
- fields.push({ visible: c, ansi: c, priority: 2 });
258
+ fields.push({ visible: c, ansi: c, color: cSubtle, priority: 2 });
225
259
  }
226
260
  if (state === 'streaming' && this._speed > 0) {
227
261
  const s = `${this._speed} t/s`;
228
- fields.push({ visible: s, ansi: s, priority: 1 });
262
+ fields.push({ visible: s, ansi: s, color: cSubtle, priority: 1 });
229
263
  }
230
264
 
231
265
  const sep = ' · ';
232
- 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} ` : ' · ';
233
268
  const leftWidth = termWidth(stripAnsi(left));
234
269
  // Keep at least one space between left and right chrome when left is
235
270
  // non-empty; when idle (left == '') the right can sit at column 0.
@@ -263,7 +298,8 @@ class FullStatusBar {
263
298
  }
264
299
 
265
300
  const rightWidth = widthOf(keep);
266
- 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);
267
303
  const padding = Math.max(gap > 0 ? gap : 0, maxCols - leftWidth - rightWidth);
268
304
  return left + ' '.repeat(padding) + rightAnsi;
269
305
  }
@@ -293,11 +329,16 @@ class FullStatusBar {
293
329
  const pct = limit > 0 ? Math.round((used / limit) * 100) : 0;
294
330
  const limitStr = limit.toLocaleString();
295
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.
296
335
  let pctAnsi = `${pct}%`;
297
- if (pct >= 90) {
298
- pctAnsi = `${RST}${UI_THEME.error}${pct}%${RST}${DIM}`;
299
- } else if (pct >= 70) {
300
- 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
+ }
301
342
  }
302
343
  const ansi = `${estPrefix}${usedStr} / ${limitStr} tok (${pctAnsi})`;
303
344
  return { visible, ansi };
@@ -308,7 +349,8 @@ class FullStatusBar {
308
349
  // affecting surrounding rows.
309
350
  renderSeparator() {
310
351
  const cols = this._layout.cols;
311
- 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;
312
354
  }
313
355
 
314
356
  // Back-compat: older call sites (command.js, create-ui.js) call
@@ -319,17 +361,17 @@ class FullStatusBar {
319
361
  _renderBar() { this._notify(); }
320
362
 
321
363
  _notify() {
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.
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.
327
369
  this._onChange();
328
370
  }
329
371
 
330
372
  destroy() {
331
- if (this._animTimer) { clearInterval(this._animTimer); this._animTimer = null; }
332
- this._stopClock();
373
+ this._destroyed = true;
374
+ this._driver.stop();
333
375
  }
334
376
  }
335
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 };
package/lib/ui/theme.js CHANGED
@@ -1,40 +1,113 @@
1
1
  'use strict';
2
2
 
3
- const { bg256, fg256, bgRGB, fgRGB } = require('./ansi');
3
+ // THE single palette table (Output Refactor Phase 2.5).
4
+ //
5
+ // Every colour the chrome surfaces use is defined here: tool-status lines,
6
+ // the status bar, diff bodies, the web-activity summary, debug/meta blocks,
7
+ // and the legacy message-role palette (`THEME` / `FG_*`) that the rest of the
8
+ // app still imports. `ansi.js` holds only the ANSI *primitives* (the SGR
9
+ // builders + spinners) and re-exports this palette for back-compat.
10
+ //
11
+ // Consolidation note: this module deliberately does NOT `require('./ansi')`.
12
+ // It defines its own private SGR builders below. That keeps the dependency
13
+ // one-directional (ansi.js → theme.js) so the two files never form a require
14
+ // cycle, regardless of which one an entrypoint loads first.
15
+
16
+ // Private SGR builders — not exported (ansi.js owns the public ones).
17
+ const fg = (n) => `\x1b[38;5;${n}m`;
18
+ const bg = (n) => `\x1b[48;5;${n}m`;
19
+ const fgRGB = (r, g, b) => `\x1b[38;2;${r};${g};${b}m`;
20
+ const bgRGB = (r, g, b) => `\x1b[48;2;${r};${g};${b}m`;
21
+
22
+ // ── NO_COLOR + non-TTY gate ─────────────────────────────────────────────────
23
+ // The single place colour is switched off. `colorEnabled()` is dynamic (read
24
+ // per call) so the same process can answer differently for a piped child or a
25
+ // test that sets NO_COLOR. Per the NO_COLOR spec, ANY non-empty value disables
26
+ // colour; an empty string does not. `colorize()` is the resolver-side helper —
27
+ // it returns the code when colour is on, '' otherwise.
28
+ function colorEnabled() {
29
+ return process.stdout.isTTY === true && !process.env.NO_COLOR;
30
+ }
31
+ function colorize(code) {
32
+ return colorEnabled() ? code : '';
33
+ }
34
+
35
+ // ── Legacy message-role palette (relocated verbatim from ansi.js) ───────────
36
+ // Values are unchanged — these drive message roles, menus, prompts and syntax
37
+ // chrome, which are out of scope for the Phase 2.5 saturation pass. They live
38
+ // here only so colour has a single home; ansi.js re-exports them.
39
+ const THEME = {
40
+ user: '\x1b[36m',
41
+ agent: '\x1b[32m',
42
+ sys: '\x1b[33m',
43
+ error: '\x1b[31;1m',
44
+ warn: '\x1b[33;1m',
45
+ tool: '\x1b[35m',
46
+ dim: '\x1b[2m',
47
+ reset: '\x1b[0m',
48
+ };
49
+
50
+ const FG_GRAY = fg(245);
51
+ const FG_DARK = fg(240);
52
+ const FG_BLUE = fg(75);
53
+ const FG_CYAN = fg(116);
54
+ const FG_GREEN = fg(114);
55
+ const FG_YELLOW = fg(222);
56
+ const FG_RED = fg(203);
57
+ const FG_TEAL = fg(73);
58
+
59
+ const FG_CODE_BG = bg(236);
60
+ const BG_SELECTED = bg(237);
61
+ const FG_CODE_BORDER = fg(240);
62
+ const FG_CODE_LANG = fg(75);
63
+ const FG_TAG = fg(176);
64
+ const FG_FILEPATH = fg(222);
4
65
 
5
- // Shared chrome palette. Used by tool-status lines, debug blocks, meta
6
- // messages, and any other non-content surface. Diff bodies have their own
7
- // background palette below. Keep palette choices out of renderers if a
8
- // colour is added to a status line, it should come from here.
66
+ // ── Saturated chrome palette (Phase 2.5) ────────────────────────────────────
67
+ // Dark-terminal assumption: full saturation, differentiate categories. Tunable
68
+ // here every colour the tool line / status bar / web summary read resolves
69
+ // through `resolveLineColors`, so a category that reads wrong is a one-line edit.
9
70
  const UI_THEME = {
10
- success: fg256(114), // bright mint
11
- warning: fg256(179), // amber
12
- error: fg256(174), // salmon
13
- info: fg256(75), // light blue
14
- muted: fg256(240), // dim gray — frames, metadata
15
- subtle: fg256(244), // slightly less dim secondary text
16
- accent: fg256(141), // soft purple — tool names, IDs
71
+ success: fg(40), // vivid green — ok glyph, replay-success indicator
72
+ warning: fg(214), // amber
73
+ error: fg(203), // alarming red (not pink)
74
+ info: fg(75), // light blue
75
+ muted: fg(240), // dim gray — frames, separators
76
+ subtle: fg(244), // secondary textdurations, meta, tokens
77
+ accent: fg(141), // purple — model name, IDs
17
78
  default: '\x1b[39m', // terminal default fg
18
- // Per-category accent variations for tool-line chrome. Chosen from the
19
- // pastel/light bands (100–180 range) so concurrent pending lines read
20
- // as a coordinated palette rather than a rainbow. Keys mirror the
21
- // values produced by TOOL_CATEGORIES so a lookup is categories[cat].
79
+ // Per-category accent. Keyed by the category STRING produced by
80
+ // `categoryForTag` (shell/file/net/…), so a lookup is `categories[cat]`.
81
+ // Saturated + differentiated; `git`/`mcp` are first-class (no longer folded
82
+ // into the `tool` fallback); `tool` is demoted to gray so real categories pop.
22
83
  categories: {
23
- net: fg256(110), // dusty blue — http, download, upload
24
- web: fg256(80), // aqua — collapsed web-activity summary (W.3)
25
- file: fg256(151), // soft sage — file ops
26
- cmd: fg256(180), // warm tan — shell / exec
27
- user: fg256(217), // pale rose — ask_user
28
- memory: fg256(183), // light lavender — memory
29
- env: fg256(186), // pale gold — env vars
30
- system: fg256(109), // steel blue — system_info
31
- debug: fg256(244), // muted gray — debug blocks
32
- tool: fg256(141), // accent purple — fallback
84
+ shell: fg(214), // orange
85
+ file: fg(77), // green
86
+ net: fg(39), // bright blue
87
+ web: fg(44), // cyan
88
+ git: fg(170), // magenta-pink
89
+ mcp: fg(141), // purple
90
+ user: fg(211), // pink
91
+ memory: fg(183), // lavender
92
+ env: fg(186), // pale gold
93
+ system: fg(109), // steel blue
94
+ debug: fg(244), // gray
95
+ tool: fg(245), // gray — fallback, demoted so categories differentiate
33
96
  },
34
97
  };
35
98
 
36
- // Indicator glyphs for status lines. Separated from colour so the renderer
37
- // can swap either independently (e.g. an ASCII-only fallback later).
99
+ // Status glyph colour. Saturated; the running indicator is never gray (it's
100
+ // the most time-sensitive element Phase 3 animates it, but even static it
101
+ // must read as live).
102
+ const STATUS_COLORS = {
103
+ ok: fg(40), // vivid green ✓
104
+ error: fg(203), // red ✗ (alarming, not pink)
105
+ running: fg(39), // cyan ● — fallback when a category has no vivid tint
106
+ warning: fg(214), // amber ⚠
107
+ };
108
+
109
+ // Indicator glyphs for status lines. Separated from colour so the renderer can
110
+ // swap either independently (e.g. an ASCII-only fallback later).
38
111
  const UI_ICONS = {
39
112
  pending: '●',
40
113
  success: '✓',
@@ -45,19 +118,19 @@ const UI_ICONS = {
45
118
  };
46
119
 
47
120
  // Diff rendering palette. Each change-type carries both a 256-color and a
48
- // truecolor background so the renderer can pick based on COLORTERM at start.
49
- // Only this file names colors keep magic numbers out of diff.js.
121
+ // truecolor background so the renderer picks based on COLORTERM at start.
122
+ // Foregrounds saturated (Phase 2.5); gutters lifted one step so structure shows.
50
123
  const DIFF_THEME = {
51
124
  added: {
52
- bg256: bg256(22), // deep forest green
125
+ bg256: bg(22), // deep forest green
53
126
  bgTC: bgRGB(14, 40, 23),
54
- signFg: fg256(114), // bright mint
127
+ signFg: fg(40), // vivid green
55
128
  sign: '+',
56
129
  },
57
130
  removed: {
58
- bg256: bg256(52), // deep burgundy
131
+ bg256: bg(52), // deep burgundy
59
132
  bgTC: bgRGB(50, 18, 20),
60
- signFg: fg256(174), // bright salmon
133
+ signFg: fg(203), // red
61
134
  sign: '-',
62
135
  },
63
136
  context: {
@@ -67,20 +140,16 @@ const DIFF_THEME = {
67
140
  sign: ' ',
68
141
  },
69
142
  gutter: { bg256: '', bgTC: '' },
70
- lineNumber: fg256(240), // dim gray
143
+ lineNumber: fg(244), // lifted from 240 — visible gutter
71
144
  code: '\x1b[39m', // terminal default fg
72
145
  header: '\x1b[1;38;5;75m', // bold light blue
73
- frame: fg256(238), // very dim gray
146
+ frame: fg(242), // lifted from 238 — visible structure
74
147
  };
75
148
 
76
- // Maps an agent-loop tool tag onto the short category shown before the
77
- // operation in a tool line ("file", "net", "shell"). Renderers should
78
- // fall back to the tag itself if the tag isn't listed here.
79
- //
80
- // Keys include BOTH the XML tag names (read_file, write_file, shell) and
81
- // the internal action names emitted by the native-function mapper (read,
82
- // write, exec). Normalization happens at lookup time so neither side needs
83
- // to know about the other's naming.
149
+ // Maps a tool tag onto the short category shown before the operation in a tool
150
+ // line. Keys include BOTH XML tag names (read_file, shell) and native action
151
+ // names (read, exec) normalized at lookup. `git_*` tags map to `git`; MCP
152
+ // tools (`mcp__server__tool`) resolve via a prefix rule in `categoryForTag`.
84
153
  const TOOL_CATEGORIES = {
85
154
  exec: 'shell', shell: 'shell', run: 'shell', run_command: 'shell', bash: 'shell',
86
155
  read: 'file', write: 'file', append: 'file',
@@ -94,7 +163,83 @@ const TOOL_CATEGORIES = {
94
163
  store_memory: 'memory', recall_memory: 'memory', list_memories: 'memory',
95
164
  get_env: 'env', set_env: 'env',
96
165
  system_info: 'system',
166
+ git_status: 'git', git_diff: 'git', git_log: 'git', git_add: 'git',
167
+ git_commit: 'git', git_branch: 'git', git_checkout: 'git', git_worktree: 'git',
97
168
  debug: 'debug',
98
169
  };
99
170
 
100
- module.exports = { DIFF_THEME, UI_THEME, UI_ICONS, TOOL_CATEGORIES };
171
+ // The XML extractor and native mapper hand *action* names (read, exec) that
172
+ // differ from *tag* names (read_file, shell). Normalize so category lookups
173
+ // resolve regardless of which rail produced the call tuple.
174
+ const ACTION_TO_TAG = {
175
+ read: 'read_file',
176
+ write: 'write_file',
177
+ append: 'append_file',
178
+ exec: 'shell',
179
+ };
180
+
181
+ function _normalizeTag(tag) {
182
+ return ACTION_TO_TAG[tag] || tag || 'tool';
183
+ }
184
+
185
+ // Single source of truth for tag → category. Both `tool-operation.js` (the
186
+ // descriptor) and `format.js` (the rendered label) consume this so the
187
+ // descriptor's category and the line's category can never diverge.
188
+ function categoryForTag(tag) {
189
+ const t = _normalizeTag(tag);
190
+ if (TOOL_CATEGORIES[t]) return TOOL_CATEGORIES[t];
191
+ if (t.startsWith('git_')) return 'git';
192
+ if (t.startsWith('mcp__') || t.startsWith('mcp_')) return 'mcp';
193
+ return 'tool';
194
+ }
195
+
196
+ // Normalize an incoming status to the resolver vocabulary. Accepts both the
197
+ // runtime forms ('pending'/'success'/'failure') and the descriptor forms
198
+ // ('pending'/'running'/'ok'/'error').
199
+ function _normStatus(s) {
200
+ if (s === 'pending' || s === 'running') return s;
201
+ if (s === 'error' || s === 'failure') return 'error';
202
+ return 'ok';
203
+ }
204
+
205
+ // THE colour resolver. Maps a descriptor's {category, status} to the colours
206
+ // for its glyph, category label, operation text, duration and meta. This is the
207
+ // single seam every chrome line goes through — `formatToolLine`,
208
+ // `formatWebSummaryLine`, etc. consume it instead of re-deriving colour inline,
209
+ // which is what makes the palette a one-table change. Honours NO_COLOR/non-TTY
210
+ // via `colorize` — when colour is off, every field resolves to ''.
211
+ function resolveLineColors(category, status) {
212
+ const cat = category || 'tool';
213
+ const catColor = colorize(UI_THEME.categories[cat] || UI_THEME.categories.tool);
214
+ const st = _normStatus(status);
215
+
216
+ let glyph;
217
+ if (st === 'error') glyph = colorize(STATUS_COLORS.error);
218
+ else if (st === 'ok') glyph = colorize(STATUS_COLORS.ok);
219
+ else {
220
+ // pending / running — category-tinted for vivid categories, cyan otherwise.
221
+ // NEVER gray: the `tool`/`debug` fallbacks would tint gray, so use cyan.
222
+ glyph = (cat !== 'tool' && cat !== 'debug')
223
+ ? catColor
224
+ : colorize(STATUS_COLORS.running);
225
+ }
226
+
227
+ const tail = colorize(st === 'error' ? UI_THEME.error : UI_THEME.subtle);
228
+ return {
229
+ glyph, // status-keyed
230
+ label: catColor, // category-keyed (the 5-char label)
231
+ op: catColor, // category-keyed (the operation text — painted too)
232
+ dur: tail, // subtle, or error red on failure
233
+ meta: tail, // subtle, or error red on failure
234
+ };
235
+ }
236
+
237
+ module.exports = {
238
+ // chrome palette + resolver
239
+ DIFF_THEME, UI_THEME, UI_ICONS, STATUS_COLORS, TOOL_CATEGORIES,
240
+ categoryForTag, resolveLineColors, colorEnabled, colorize,
241
+ // legacy palette (re-exported by ansi.js for back-compat)
242
+ THEME,
243
+ FG_GRAY, FG_DARK, FG_BLUE, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_TEAL,
244
+ FG_CODE_BG, BG_SELECTED, FG_CODE_BORDER, FG_CODE_LANG, FG_TAG, FG_FILEPATH,
245
+ };