@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.
- package/.claude/settings.local.json +2 -1
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -1874
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/index.js +1 -1
- package/lib/agent.js +145 -16
- package/lib/api.js +28 -3
- package/lib/commands/chat-session.js +187 -4
- package/lib/commands/chat-slash.js +16 -0
- package/lib/commands/chat-turn.js +272 -49
- package/lib/commands/chat.js +12 -8
- package/lib/config.js +27 -0
- package/lib/constants.js +30 -1
- package/lib/headless.js +36 -1
- package/lib/images.js +8 -2
- package/lib/permissions.js +23 -16
- package/lib/prompts.js +15 -3
- package/lib/tool_registry.js +357 -53
- package/lib/tool_specs.js +42 -8
- package/lib/tools.js +80 -19
- package/lib/ui/anim.js +86 -0
- package/lib/ui/ansi.js +17 -27
- package/lib/ui/chat-history.js +253 -71
- package/lib/ui/create-ui.js +67 -24
- package/lib/ui/diff.js +90 -25
- package/lib/ui/file-activity.js +236 -0
- package/lib/ui/format.js +173 -28
- package/lib/ui/input-field.js +5 -4
- package/lib/ui/md-stream.js +234 -0
- package/lib/ui/render-operation.js +113 -0
- package/lib/ui/select.js +1 -4
- package/lib/ui/status-bar.js +99 -57
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -45
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +58 -6
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/package.json +1 -1
- package/test/anim-driver.test.js +153 -0
- package/test/ask-user-display.test.js +226 -0
- package/test/ask-user-gate.test.js +231 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +250 -13
- package/test/extract-tool-calls.test.js +37 -3
- package/test/file-activity.test.js +522 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/chat-harness.js +2 -1
- package/test/headless.test.js +146 -1
- package/test/input-field-ctrl-o.test.js +37 -0
- package/test/live-height-physical.test.js +281 -0
- package/test/max-iterations.test.js +9 -7
- package/test/md-stream.test.js +183 -0
- package/test/native-dispatch.test.js +53 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/permissions.test.js +199 -0
- package/test/read-paginate.test.js +1 -1
- package/test/render-operation.test.js +317 -0
- package/test/replay-descriptor-xml.test.js +216 -0
- package/test/replay-descriptor.test.js +189 -0
- package/test/replay-web-aggregate.test.js +291 -0
- package/test/replay-web-persist.test.js +241 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +24 -0
- package/test/theme-palette.test.js +166 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +12 -3
- package/path +0 -1
package/lib/ui/status-bar.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { RST, DIM,
|
|
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
|
-
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
this.
|
|
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
|
-
|
|
72
|
-
|
|
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()
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
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.
|
|
81
|
-
resume() { this._paused = false; this.
|
|
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
|
-
//
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
177
|
-
//
|
|
178
|
-
//
|
|
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
|
-
|
|
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
|
|
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 (
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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 —
|
|
323
|
-
// setCost
|
|
324
|
-
//
|
|
325
|
-
// also suppress the event-driven repaints that must keep working
|
|
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
|
-
|
|
332
|
-
this.
|
|
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
|
-
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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:
|
|
11
|
-
warning:
|
|
12
|
-
error:
|
|
13
|
-
info:
|
|
14
|
-
muted:
|
|
15
|
-
subtle:
|
|
16
|
-
accent:
|
|
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 text — durations, meta, tokens
|
|
77
|
+
accent: fg(141), // purple — model name, IDs
|
|
17
78
|
default: '\x1b[39m', // terminal default fg
|
|
18
|
-
// Per-category accent
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
//
|
|
37
|
-
//
|
|
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
|
|
49
|
-
//
|
|
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:
|
|
125
|
+
bg256: bg(22), // deep forest green
|
|
53
126
|
bgTC: bgRGB(14, 40, 23),
|
|
54
|
-
signFg:
|
|
127
|
+
signFg: fg(40), // vivid green
|
|
55
128
|
sign: '+',
|
|
56
129
|
},
|
|
57
130
|
removed: {
|
|
58
|
-
bg256:
|
|
131
|
+
bg256: bg(52), // deep burgundy
|
|
59
132
|
bgTC: bgRGB(50, 18, 20),
|
|
60
|
-
signFg:
|
|
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:
|
|
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:
|
|
146
|
+
frame: fg(242), // lifted from 238 — visible structure
|
|
74
147
|
};
|
|
75
148
|
|
|
76
|
-
// Maps
|
|
77
|
-
//
|
|
78
|
-
//
|
|
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
|
-
|
|
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
|
+
};
|