@semalt-ai/code 1.8.0 → 1.8.3

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/lib/ui/ansi.js CHANGED
@@ -3,6 +3,17 @@
3
3
  const RST = '\x1b[0m';
4
4
  const BOLD = '\x1b[1m';
5
5
  const DIM = '\x1b[2m';
6
+ const EL = '\x1b[K'; // erase-to-end-of-line using current bg
7
+
8
+ function bg256(n) { return `\x1b[48;5;${n}m`; }
9
+ function fg256(n) { return `\x1b[38;5;${n}m`; }
10
+ function bgRGB(r, g, b) { return `\x1b[48;2;${r};${g};${b}m`; }
11
+ function fgRGB(r, g, b) { return `\x1b[38;2;${r};${g};${b}m`; }
12
+
13
+ function hasTruecolor() {
14
+ const v = process.env.COLORTERM;
15
+ return v === 'truecolor' || v === '24bit';
16
+ }
6
17
 
7
18
  const THEME = {
8
19
  user: '\x1b[36m',
@@ -50,13 +61,15 @@ const KEYWORDS = new Set([
50
61
  ]);
51
62
 
52
63
  const SPINNER_DEFS = {
53
- thinking: { frames: ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'], color: '\x1b[36m' },
54
- streaming: { frames: ['▁','▂','▃','▄','▅','▆','▇','█','▇','▆','▅','▄','▃','▂'], color: '\x1b[32m' },
55
- tool: { frames: ['⣾','⣽','⣻','⢿','⡿','⣟','⣯','⣷'], color: '\x1b[33m' },
64
+ thinking: { frames: ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'], color: '\x1b[36m' },
65
+ streaming: { frames: ['▁','▂','▃','▄','▅','▆','▇','█','▇','▆','▅','▄','▃','▂'], color: '\x1b[32m' },
66
+ tool: { frames: ['⣾','⣽','⣻','⢿','⡿','⣟','⣯','⣷'], color: '\x1b[33m' },
67
+ waiting_download: { frames: ['⬇ ','⬇⠂','⬇⠆','⬇⠇','⬇⠧','⬇⠷','⬇⠿','⬇⠾','⬇⠼','⬇⠸','⬇⠰','⬇⠠'], color: '\x1b[38;5;75m' },
56
68
  };
57
69
 
58
70
  module.exports = {
59
- RST, BOLD, DIM, THEME,
71
+ RST, BOLD, DIM, EL, THEME,
72
+ bg256, fg256, bgRGB, fgRGB, hasTruecolor,
60
73
  FG_GRAY, FG_DARK, FG_BLUE, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_TEAL,
61
74
  BOX_H, BOX_V, BOX_TL, BOX_TR, BOX_BL, BOX_BR,
62
75
  FG_CODE_BG, BG_SELECTED, FG_CODE_BORDER, FG_CODE_LANG, FG_TAG, FG_FILEPATH,
@@ -2,6 +2,8 @@
2
2
 
3
3
  const { RST, DIM, BOLD, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_DARK, FG_GRAY } = require('./ansi');
4
4
  const { getCols, stripAnsi } = require('./utils');
5
+ const { UI_THEME, UI_ICONS, TOOL_CATEGORIES } = require('./theme');
6
+ const writer = require('./writer');
5
7
 
6
8
 
7
9
  function safeContent(text) {
@@ -9,6 +11,8 @@ function safeContent(text) {
9
11
  return text.replace(/<\/?[a-zA-Z_][a-zA-Z0-9_]*(\s[^>]*)?>/g, '');
10
12
  }
11
13
 
14
+ // Legacy per-tag icons kept as a fallback only. New chrome uses a single
15
+ // status glyph (✓/✗/●) picked by outcome, not by tool kind.
12
16
  const TOOL_ICONS = {
13
17
  exec: '▶', shell: '▶', run_command: '▶', run: '▶',
14
18
  read_file: '[R]', write_file: '✎',
@@ -18,6 +22,24 @@ const TOOL_ICONS = {
18
22
  store_memory: '◆', recall_memory: '◆', list_memories: '◆',
19
23
  };
20
24
 
25
+ // Dim any path/URL-like substring so it recedes when scanning a list of
26
+ // tool operations.
27
+ function _dimPaths(text) {
28
+ return text.replace(/(\S*\/\S+)/g, (m) => {
29
+ const trail = m.match(/[`"'),.;:!?\]]+$/);
30
+ if (trail) {
31
+ const core = m.slice(0, -trail[0].length);
32
+ if (!core) return m;
33
+ return `${UI_THEME.subtle}${core}${UI_THEME.default}${trail[0]}`;
34
+ }
35
+ return `${UI_THEME.subtle}${m}${UI_THEME.default}`;
36
+ });
37
+ }
38
+
39
+ function _isAutoApproveMeta(content) {
40
+ return /auto-approv/i.test(content);
41
+ }
42
+
21
43
  function _fmtTime(ts) {
22
44
  const d = ts instanceof Date ? ts : (ts ? new Date(ts) : new Date());
23
45
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
@@ -25,47 +47,68 @@ function _fmtTime(ts) {
25
47
 
26
48
  const BG_USER = '\x1b[48;5;237m';
27
49
 
28
- function _printUser(content, ts) {
50
+ // Build renderers return a single string ending with \n (or empty). The
51
+ // caller commits it to scrollback in one write so nothing interleaves mid-
52
+ // bubble — the whole user/assistant/tool block reaches the terminal as one
53
+ // atomic chunk.
54
+ function _buildUser(content, ts) {
29
55
  const time = _fmtTime(ts);
30
- process.stdout.write(`\n${FG_CYAN}▸ You${RST} ${DIM}${time}${RST}\n`);
56
+ let out = `\n${FG_CYAN}▸ You${RST} ${DIM}${time}${RST}\n`;
31
57
  for (const line of (content || '').split('\n')) {
32
- process.stdout.write(`${BG_USER} ${line}\x1b[K${RST}\n`);
58
+ out += `${BG_USER} ${line}\x1b[K${RST}\n`;
33
59
  }
34
- process.stdout.write('\n');
60
+ out += '\n';
61
+ return out;
35
62
  }
36
63
 
37
- function _printAI(content, ts) {
64
+ function _buildAI(content, ts) {
38
65
  const time = _fmtTime(ts);
39
- process.stdout.write(`\n${FG_GREEN}▸ AI-agent${RST} ${DIM}${time}${RST}\n`);
66
+ let out = `\n${FG_GREEN}▸ AI-agent${RST} ${DIM}${time}${RST}\n`;
40
67
  for (const line of (content || '').split('\n')) {
41
- process.stdout.write(` ${line}\n`);
68
+ out += ` ${line}\n`;
42
69
  }
43
- process.stdout.write('\n');
70
+ out += '\n';
71
+ return out;
44
72
  }
45
73
 
46
74
  const MAX_TOOL_DISPLAY = 15;
47
75
 
48
76
  class ChatHistory {
49
77
  constructor() {
50
- this._streamWritten = false;
78
+ this._streamActive = false;
79
+ this._streamPartial = '';
51
80
  this._streamStart = null;
81
+ this._didStream = false;
52
82
  this._msgById = {};
53
83
  this._msgLineCount = {};
54
- this._pendingWs = '';
55
- this._didStream = false;
56
84
  this._toolExpanded = {};
57
85
  this._lastExpandableId = null;
58
86
  this._toolIdCounter = 0;
59
87
  this._messages = [];
88
+ // Callback into the UI orchestrator to refresh the live region (so the
89
+ // in-progress streaming line and chrome re-render together). Set by
90
+ // createUI; safe to leave null for headless/test contexts.
91
+ this._onLiveUpdate = null;
60
92
  }
61
93
 
62
- _flush() {
63
- this._pendingWs = '';
64
- if (this._streamWritten) {
65
- process.stdout.write('\n\n');
66
- this._streamWritten = false;
67
- this._streamStart = null;
68
- this._didStream = true;
94
+ // Return the in-progress streaming line as it should appear inside the
95
+ // live region. Null when no stream is active or the partial is empty —
96
+ // the live composer then omits the streaming row entirely.
97
+ getStreamingLiveLine() {
98
+ if (!this._streamActive) return null;
99
+ if (!this._streamPartial) return null;
100
+ // Render leading-whitespace-only partials as an empty space so the
101
+ // cursor sits on a visible row, not a bare \n.
102
+ return ' ' + this._streamPartial.replace(/\n/g, ' ');
103
+ }
104
+
105
+ isStreaming() { return this._streamActive; }
106
+
107
+ _commit(text) { writer.scrollback(text); }
108
+
109
+ _notifyLive() {
110
+ if (this._onLiveUpdate) {
111
+ try { this._onLiveUpdate(); } catch {}
69
112
  }
70
113
  }
71
114
 
@@ -74,36 +117,45 @@ class ChatHistory {
74
117
  const content = safeContent(msg.content || '');
75
118
  if ((msg.role === 'assistant' || msg.role === 'system') && !content.trim()) return;
76
119
 
77
- this._flush();
120
+ // Any in-progress stream must close before we emit a new scrollback
121
+ // block — otherwise the stream's partial line would sit between the
122
+ // header and body of the new bubble.
123
+ this._flushStream();
78
124
 
79
125
  if (msg.id) this._msgById[msg.id] = msg;
80
126
 
127
+ let out = '';
128
+ let lineCount = 0;
129
+
81
130
  if (msg.role === 'user') {
82
- _printUser(content, msg.ts);
131
+ out = _buildUser(content, msg.ts);
83
132
  } else if (msg.role === 'assistant') {
84
- _printAI(content, msg.ts);
133
+ out = _buildAI(content, msg.ts);
85
134
  } else if (msg.role === 'shell') {
86
135
  const cmd = msg.cmd || '';
87
- const out = safeContent(msg.content || '');
88
- process.stdout.write(`\n${DIM} $ ${cmd}${RST}\n`);
89
- if (out.trim()) {
90
- for (const line of out.split('\n')) {
91
- if (line.trim()) process.stdout.write(`${DIM} ${line}${RST}\n`);
136
+ const shellOut = safeContent(msg.content || '');
137
+ out = `\n${DIM} $ ${cmd}${RST}\n`;
138
+ if (shellOut.trim()) {
139
+ for (const line of shellOut.split('\n')) {
140
+ if (line.trim()) out += `${DIM} ${line}${RST}\n`;
92
141
  }
93
142
  }
94
- process.stdout.write('\n');
143
+ out += '\n';
95
144
  } else if (msg.role === 'tool') {
96
- const icon = TOOL_ICONS[msg.tag] || '⚙';
97
- process.stdout.write(`${DIM} ${icon} ${content}${RST}\n`);
98
- let lc = 1;
145
+ const indicator = msg.isError
146
+ ? `${UI_THEME.error}${UI_ICONS.error}${RST}`
147
+ : `${UI_THEME.success}${UI_ICONS.success}${RST}`;
148
+ const category = TOOL_CATEGORIES[msg.tag] || msg.tag || 'tool';
149
+ const head = `${UI_THEME.accent}${category}${UI_THEME.muted}:${RST}`;
150
+ const desc = _dimPaths(content);
151
+ out = ` ${indicator} ${head} ${desc}\n`;
152
+ lineCount = 1;
99
153
  if (msg.output) {
100
- // Wrap long lines so char-heavy single-line output (e.g. minified HTML)
101
- // is treated the same as multi-line output for truncation purposes.
102
154
  const wrapAt = Math.max(60, getCols() - 8);
103
155
  const outLines = [];
104
156
  for (const raw of msg.output.split('\n')) {
105
157
  if (!raw.trim()) continue;
106
- if (raw.length <= wrapAt) {
158
+ if (msg.tag === 'debug' || raw.length <= wrapAt) {
107
159
  outLines.push(raw);
108
160
  } else {
109
161
  for (let i = 0; i < raw.length; i += wrapAt) {
@@ -125,70 +177,149 @@ class ChatHistory {
125
177
  const visible = (truncatable && !expanded) ? outLines.slice(0, MAX_TOOL_DISPLAY) : outLines;
126
178
  const remaining = outLines.length - visible.length;
127
179
 
128
- for (const ol of visible) { process.stdout.write(`${DIM} ${ol}${RST}\n`); lc++; }
180
+ const outerOpen = msg.tag === 'debug' ? ' ' : `${DIM} `;
181
+ const outerClose = msg.tag === 'debug' ? '' : RST;
182
+ for (const ol of visible) { out += `${outerOpen}${ol}${outerClose}\n`; lineCount++; }
129
183
  if (remaining > 0) {
130
- process.stdout.write(`${DIM} … ${remaining} more lines ${FG_DARK}(ctrl+o to expand ${remaining} rows)${RST}\n`);
131
- lc++;
184
+ out += `${DIM} … ${remaining} more lines ${FG_DARK}(ctrl+o to expand ${remaining} rows)${RST}\n`;
185
+ lineCount++;
132
186
  } else if (truncatable && expanded) {
133
- process.stdout.write(`${DIM} ${FG_DARK}(ctrl+o to collapse)${RST}\n`);
134
- lc++;
187
+ out += `${DIM} ${FG_DARK}(ctrl+o to collapse)${RST}\n`;
188
+ lineCount++;
135
189
  }
136
190
  }
137
- if (msg.id) this._msgLineCount[msg.id] = lc;
138
191
  } else if (msg.role === 'permission') {
139
- process.stdout.write(`\n${FG_YELLOW} Permission required: ${content}${RST}\n`);
192
+ out = `\n ${UI_THEME.warning}${UI_ICONS.warn}${RST} ${UI_THEME.warning}Permission required${RST}${UI_THEME.muted}:${RST} ${content}\n`;
140
193
  } else {
141
- const isErr = msg.isError || (content.toLowerCase().includes('error') && !content.toLowerCase().includes('iswarning'));
142
- const color = isErr ? FG_RED : FG_YELLOW;
143
- const prefix = isErr ? '✕' : '⚠';
194
+ // Fallback for role: 'system' and anything unrecognised.
144
195
  const lines = content.split('\n');
145
- process.stdout.write(`${color} ${prefix} ${lines[0]}${RST}\n`);
196
+ const first = lines[0] || '';
197
+ const stripGlyph = (s) => s.replace(/^[✓✗✕⚠]\s*/, '');
198
+ let rendered;
199
+ if (msg.isError && !msg.isWarning) {
200
+ rendered = ` ${UI_THEME.error}${UI_ICONS.error}${RST} ${UI_THEME.error}${stripGlyph(first)}${RST}`;
201
+ } else if (msg.isWarning) {
202
+ rendered = ` ${UI_THEME.warning}${UI_ICONS.warn}${RST} ${UI_THEME.warning}${stripGlyph(first)}${RST}`;
203
+ } else if (_isAutoApproveMeta(first)) {
204
+ const body = first.replace(/(auto-approv\w*)/gi, (m) => `${UI_THEME.warning}${m}${UI_THEME.subtle}`);
205
+ rendered = ` ${UI_THEME.subtle}${body}${RST}`;
206
+ } else if (first.startsWith('✓')) {
207
+ rendered = ` ${UI_THEME.success}${UI_ICONS.success}${RST} ${first.slice(1).trimStart()}`;
208
+ } else if (first.startsWith('✗')) {
209
+ rendered = ` ${UI_THEME.error}${UI_ICONS.error}${RST} ${UI_THEME.error}${first.slice(1).trimStart()}${RST}`;
210
+ } else {
211
+ rendered = ` ${UI_THEME.subtle}${UI_ICONS.bullet}${RST} ${UI_THEME.subtle}${first}${RST}`;
212
+ }
213
+ out = `${rendered}\n`;
214
+ const contColor = (msg.isError && !msg.isWarning) ? UI_THEME.error
215
+ : msg.isWarning ? UI_THEME.warning
216
+ : UI_THEME.subtle;
146
217
  for (let i = 1; i < lines.length; i++) {
147
- process.stdout.write(`${color} ${lines[i]}${RST}\n`);
218
+ out += ` ${contColor}${lines[i]}${RST}\n`;
148
219
  }
149
- if (msg.id) this._msgLineCount[msg.id] = lines.length;
220
+ lineCount = lines.length;
150
221
  }
222
+
223
+ if (out) this._commit(out);
224
+ if (msg.id && lineCount > 0) this._msgLineCount[msg.id] = lineCount;
225
+ this._messages.push(msg);
226
+ this._notifyLive();
151
227
  }
152
228
 
229
+ // Streaming path. Accumulates tokens until a newline, then commits each
230
+ // complete line to scrollback. The unfinished partial line is exposed via
231
+ // getStreamingLiveLine() so the UI composer can show it at the top of
232
+ // the live region while it's still in-flight.
153
233
  streamToken(token) {
154
- if (!this._streamWritten) {
155
- if (!token.trim()) {
156
- this._pendingWs += token;
157
- return;
158
- }
234
+ if (!token) return;
235
+ if (!this._streamActive) {
236
+ // Emit header immediately so users see the new AI turn before any
237
+ // tokens arrive. No blank line before the header since scrollback
238
+ // flows continuously.
159
239
  const time = _fmtTime(new Date());
160
- process.stdout.write(`\n${FG_GREEN}▸ AI-agent${RST} ${DIM}${time}${RST}\n `);
161
- this._streamWritten = true;
240
+ this._commit(`\n${FG_GREEN}▸ AI-agent${RST} ${DIM}${time}${RST}\n`);
241
+ this._streamActive = true;
162
242
  this._streamStart = new Date();
163
- this._pendingWs = '';
243
+ this._streamPartial = '';
244
+ }
245
+ this._streamPartial += token;
246
+ let nlIdx;
247
+ while ((nlIdx = this._streamPartial.indexOf('\n')) >= 0) {
248
+ const line = this._streamPartial.slice(0, nlIdx);
249
+ this._commit(' ' + line + '\n');
250
+ this._streamPartial = this._streamPartial.slice(nlIdx + 1);
251
+ }
252
+ this._notifyLive();
253
+ }
254
+
255
+ _flushStream() {
256
+ if (!this._streamActive) return;
257
+ if (this._streamPartial !== '') {
258
+ this._commit(' ' + this._streamPartial + '\n');
259
+ this._streamPartial = '';
164
260
  }
165
- process.stdout.write(token.replace(/\n/g, '\n '));
261
+ this._commit('\n');
262
+ this._streamActive = false;
263
+ this._streamStart = null;
264
+ this._didStream = true;
166
265
  }
167
266
 
168
267
  clearStreamingContent() {
169
- this._flush();
268
+ this._flushStream();
269
+ this._notifyLive();
170
270
  }
171
271
 
172
272
  finalizeLastMessage(cleanContent) {
173
- if (this._streamWritten) {
174
- this._flush();
273
+ const wasStreaming = this._streamActive;
274
+ const streamStart = this._streamStart;
275
+ if (wasStreaming) {
276
+ this._flushStream();
277
+ if (cleanContent && cleanContent.trim()) {
278
+ this._messages.push({
279
+ role: 'assistant',
280
+ content: cleanContent,
281
+ ts: streamStart || new Date(),
282
+ });
283
+ }
175
284
  } else if (!this._didStream && cleanContent && cleanContent.trim()) {
176
- _printAI(cleanContent, new Date());
285
+ // Non-streaming path — synthesise an assistant bubble from the final
286
+ // content.
287
+ this._commit(_buildAI(cleanContent, new Date()));
288
+ this._messages.push({
289
+ role: 'assistant',
290
+ content: cleanContent,
291
+ ts: new Date(),
292
+ });
177
293
  }
178
294
  this._didStream = false;
295
+ this._notifyLive();
179
296
  }
180
297
 
181
298
  clearMessages() {
182
- this._streamWritten = false;
183
- this._pendingWs = '';
299
+ this._streamActive = false;
300
+ this._streamPartial = '';
184
301
  this._didStream = false;
185
302
  this._msgById = {};
186
303
  this._msgLineCount = {};
187
304
  this._toolExpanded = {};
188
305
  this._lastExpandableId = null;
189
306
  this._messages = [];
307
+ // Wipe the terminal's visible scrollback and the saved scrollback buffer
308
+ // so the cleared state feels clean. Serialized through the writer so
309
+ // the clear can't land inside a pending scrollback burst, then redraw
310
+ // the live region under the new cursor.
311
+ writer.enqueue(() => {
312
+ try { process.stdout.write('\x1b[3J\x1b[2J\x1b[H'); } catch {}
313
+ });
314
+ writer.redrawLive();
315
+ this._notifyLive();
190
316
  }
191
317
 
318
+ // In append-only scrollback we cannot rewrite a previous bubble in place;
319
+ // these methods now simply re-render the updated version as new
320
+ // scrollback. Expand/collapse prints the full (or summary) view below the
321
+ // prior truncated view. This is a small visual regression from the old
322
+ // scroll-region mode in exchange for eliminating the torn-frame race.
192
323
  toggleLastExpand() {
193
324
  const id = this._lastExpandableId;
194
325
  if (!id) return;
@@ -204,9 +335,18 @@ class ChatHistory {
204
335
  this.addMessage(msg);
205
336
  }
206
337
 
338
+ collapseById(id) {
339
+ // Nothing to erase in the append-only model — drop the tracking so a
340
+ // future expand toggle doesn't target the stale entry.
341
+ delete this._msgById[id];
342
+ delete this._msgLineCount[id];
343
+ if (this._lastExpandableId === id) this._lastExpandableId = null;
344
+ }
345
+
207
346
  removeById(id) {
208
347
  delete this._msgById[id];
209
348
  delete this._msgLineCount[id];
349
+ if (this._lastExpandableId === id) this._lastExpandableId = null;
210
350
  }
211
351
 
212
352
  invalidateCache() {}