@semalt-ai/code 1.8.1 → 1.8.4

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',
@@ -57,7 +68,8 @@ const SPINNER_DEFS = {
57
68
  };
58
69
 
59
70
  module.exports = {
60
- RST, BOLD, DIM, THEME,
71
+ RST, BOLD, DIM, EL, THEME,
72
+ bg256, fg256, bgRGB, fgRGB, hasTruecolor,
61
73
  FG_GRAY, FG_DARK, FG_BLUE, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_TEAL,
62
74
  BOX_H, BOX_V, BOX_TL, BOX_TR, BOX_BL, BOX_BR,
63
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,55 @@ 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
+ // Tool summary header. The writer's activity region now commits the
146
+ // primary "glyph · category · op · duration · meta" line directly to
147
+ // scrollback via endActivity — when a caller supplies an empty
148
+ // `content` they're signalling that the header is already present
149
+ // and only the expandable output body should render here.
150
+ if (content) {
151
+ const indicator = msg.isError
152
+ ? `${UI_THEME.error}${UI_ICONS.error}${RST}`
153
+ : `${UI_THEME.success}${UI_ICONS.success}${RST}`;
154
+ const category = TOOL_CATEGORIES[msg.tag] || msg.tag || 'tool';
155
+ const head = `${UI_THEME.accent}${category}${UI_THEME.muted}:${RST}`;
156
+ const desc = _dimPaths(content);
157
+ out = ` ${indicator} ${head} ${desc}\n`;
158
+ lineCount = 1;
159
+ } else {
160
+ out = '';
161
+ lineCount = 0;
162
+ }
99
163
  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
164
  const wrapAt = Math.max(60, getCols() - 8);
103
165
  const outLines = [];
104
166
  for (const raw of msg.output.split('\n')) {
105
167
  if (!raw.trim()) continue;
106
- if (raw.length <= wrapAt) {
168
+ if (msg.tag === 'debug' || raw.length <= wrapAt) {
107
169
  outLines.push(raw);
108
170
  } else {
109
171
  for (let i = 0; i < raw.length; i += wrapAt) {
@@ -125,70 +187,150 @@ class ChatHistory {
125
187
  const visible = (truncatable && !expanded) ? outLines.slice(0, MAX_TOOL_DISPLAY) : outLines;
126
188
  const remaining = outLines.length - visible.length;
127
189
 
128
- for (const ol of visible) { process.stdout.write(`${DIM} ${ol}${RST}\n`); lc++; }
190
+ const outerOpen = msg.tag === 'debug' ? ' ' : `${DIM} `;
191
+ const outerClose = msg.tag === 'debug' ? '' : RST;
192
+ for (const ol of visible) { out += `${outerOpen}${ol}${outerClose}\n`; lineCount++; }
129
193
  if (remaining > 0) {
130
- process.stdout.write(`${DIM} … ${remaining} more lines ${FG_DARK}(ctrl+o to expand ${remaining} rows)${RST}\n`);
131
- lc++;
194
+ out += `${DIM} … ${remaining} more lines ${FG_DARK}(ctrl+o to expand ${remaining} rows)${RST}\n`;
195
+ lineCount++;
132
196
  } else if (truncatable && expanded) {
133
- process.stdout.write(`${DIM} ${FG_DARK}(ctrl+o to collapse)${RST}\n`);
134
- lc++;
197
+ out += `${DIM} ${FG_DARK}(ctrl+o to collapse)${RST}\n`;
198
+ lineCount++;
135
199
  }
136
200
  }
137
- if (msg.id) this._msgLineCount[msg.id] = lc;
138
201
  } else if (msg.role === 'permission') {
139
- process.stdout.write(`\n${FG_YELLOW} Permission required: ${content}${RST}\n`);
202
+ out = `\n ${UI_THEME.warning}${UI_ICONS.warn}${RST} ${UI_THEME.warning}Permission required${RST}${UI_THEME.muted}:${RST} ${content}\n`;
140
203
  } else {
141
- const isErr = !!msg.isError && !msg.isWarning;
142
- const color = isErr ? FG_RED : FG_YELLOW;
143
- const prefix = isErr ? '✕' : '⚠';
204
+ // Fallback for role: 'system' and anything unrecognised.
144
205
  const lines = content.split('\n');
145
- process.stdout.write(`${color} ${prefix} ${lines[0]}${RST}\n`);
206
+ const first = lines[0] || '';
207
+ const stripGlyph = (s) => s.replace(/^[✓✗✕⚠]\s*/, '');
208
+ let rendered;
209
+ if (msg.isError && !msg.isWarning) {
210
+ rendered = ` ${UI_THEME.error}${UI_ICONS.error}${RST} ${UI_THEME.error}${stripGlyph(first)}${RST}`;
211
+ } else if (msg.isWarning) {
212
+ rendered = ` ${UI_THEME.warning}${UI_ICONS.warn}${RST} ${UI_THEME.warning}${stripGlyph(first)}${RST}`;
213
+ } else if (_isAutoApproveMeta(first)) {
214
+ const body = first.replace(/(auto-approv\w*)/gi, (m) => `${UI_THEME.warning}${m}${UI_THEME.subtle}`);
215
+ rendered = ` ${UI_THEME.subtle}${body}${RST}`;
216
+ } else if (first.startsWith('✓')) {
217
+ rendered = ` ${UI_THEME.success}${UI_ICONS.success}${RST} ${first.slice(1).trimStart()}`;
218
+ } else if (first.startsWith('✗')) {
219
+ rendered = ` ${UI_THEME.error}${UI_ICONS.error}${RST} ${UI_THEME.error}${first.slice(1).trimStart()}${RST}`;
220
+ } else {
221
+ rendered = ` ${UI_THEME.subtle}${UI_ICONS.bullet}${RST} ${UI_THEME.subtle}${first}${RST}`;
222
+ }
223
+ out = `${rendered}\n`;
224
+ const contColor = (msg.isError && !msg.isWarning) ? UI_THEME.error
225
+ : msg.isWarning ? UI_THEME.warning
226
+ : UI_THEME.subtle;
146
227
  for (let i = 1; i < lines.length; i++) {
147
- process.stdout.write(`${color} ${lines[i]}${RST}\n`);
228
+ out += ` ${contColor}${lines[i]}${RST}\n`;
148
229
  }
149
- if (msg.id) this._msgLineCount[msg.id] = lines.length;
230
+ lineCount = lines.length;
150
231
  }
232
+
233
+ if (out) this._commit(out);
234
+ if (msg.id && lineCount > 0) this._msgLineCount[msg.id] = lineCount;
235
+ this._messages.push(msg);
236
+ this._notifyLive();
151
237
  }
152
238
 
239
+ // Streaming path. Accumulates tokens until a newline, then commits each
240
+ // complete line to scrollback. The unfinished partial line is exposed via
241
+ // getStreamingLiveLine() so the UI composer can show it at the top of
242
+ // the live region while it's still in-flight.
153
243
  streamToken(token) {
154
- if (!this._streamWritten) {
155
- if (!token.trim()) {
156
- this._pendingWs += token;
157
- return;
158
- }
244
+ if (!token) return;
245
+ if (!this._streamActive) {
246
+ // Emit header immediately so users see the new AI turn before any
247
+ // tokens arrive. No blank line before the header since scrollback
248
+ // flows continuously.
159
249
  const time = _fmtTime(new Date());
160
- process.stdout.write(`\n${FG_GREEN}▸ AI-agent${RST} ${DIM}${time}${RST}\n `);
161
- this._streamWritten = true;
250
+ this._commit(`\n${FG_GREEN}▸ AI-agent${RST} ${DIM}${time}${RST}\n`);
251
+ this._streamActive = true;
162
252
  this._streamStart = new Date();
163
- this._pendingWs = '';
253
+ this._streamPartial = '';
254
+ }
255
+ this._streamPartial += token;
256
+ let nlIdx;
257
+ while ((nlIdx = this._streamPartial.indexOf('\n')) >= 0) {
258
+ const line = this._streamPartial.slice(0, nlIdx);
259
+ this._commit(' ' + line + '\n');
260
+ this._streamPartial = this._streamPartial.slice(nlIdx + 1);
164
261
  }
165
- process.stdout.write(token.replace(/\n/g, '\n '));
262
+ this._notifyLive();
263
+ }
264
+
265
+ _flushStream() {
266
+ if (!this._streamActive) return;
267
+ if (this._streamPartial !== '') {
268
+ this._commit(' ' + this._streamPartial + '\n');
269
+ this._streamPartial = '';
270
+ }
271
+ this._commit('\n');
272
+ this._streamActive = false;
273
+ this._streamStart = null;
274
+ this._didStream = true;
166
275
  }
167
276
 
168
277
  clearStreamingContent() {
169
- this._flush();
278
+ this._flushStream();
279
+ this._notifyLive();
170
280
  }
171
281
 
172
282
  finalizeLastMessage(cleanContent) {
173
- if (this._streamWritten) {
174
- this._flush();
283
+ const wasStreaming = this._streamActive;
284
+ const streamStart = this._streamStart;
285
+ if (wasStreaming) {
286
+ this._flushStream();
287
+ if (cleanContent && cleanContent.trim()) {
288
+ this._messages.push({
289
+ role: 'assistant',
290
+ content: cleanContent,
291
+ ts: streamStart || new Date(),
292
+ });
293
+ }
175
294
  } else if (!this._didStream && cleanContent && cleanContent.trim()) {
176
- _printAI(cleanContent, new Date());
295
+ // Non-streaming path — synthesise an assistant bubble from the final
296
+ // content.
297
+ this._commit(_buildAI(cleanContent, new Date()));
298
+ this._messages.push({
299
+ role: 'assistant',
300
+ content: cleanContent,
301
+ ts: new Date(),
302
+ });
177
303
  }
178
304
  this._didStream = false;
305
+ this._notifyLive();
179
306
  }
180
307
 
181
308
  clearMessages() {
182
- this._streamWritten = false;
183
- this._pendingWs = '';
309
+ this._streamActive = false;
310
+ this._streamPartial = '';
184
311
  this._didStream = false;
185
312
  this._msgById = {};
186
313
  this._msgLineCount = {};
187
314
  this._toolExpanded = {};
188
315
  this._lastExpandableId = null;
189
316
  this._messages = [];
317
+ // Wipe the terminal's visible scrollback and the saved scrollback buffer
318
+ // so the cleared state feels clean. Serialized through the writer so
319
+ // the clear can't land inside a pending scrollback burst, then redraw
320
+ // the live region under the new cursor.
321
+ writer.enqueue(() => {
322
+ // audit: allowed — viewport clear inside writer.enqueue (sanctioned escape hatch).
323
+ try { process.stdout.write('\x1b[3J\x1b[2J\x1b[H'); } catch {}
324
+ });
325
+ writer.redrawLive();
326
+ this._notifyLive();
190
327
  }
191
328
 
329
+ // In append-only scrollback we cannot rewrite a previous bubble in place;
330
+ // these methods now simply re-render the updated version as new
331
+ // scrollback. Expand/collapse prints the full (or summary) view below the
332
+ // prior truncated view. This is a small visual regression from the old
333
+ // scroll-region mode in exchange for eliminating the torn-frame race.
192
334
  toggleLastExpand() {
193
335
  const id = this._lastExpandableId;
194
336
  if (!id) return;
@@ -204,9 +346,18 @@ class ChatHistory {
204
346
  this.addMessage(msg);
205
347
  }
206
348
 
349
+ collapseById(id) {
350
+ // Nothing to erase in the append-only model — drop the tracking so a
351
+ // future expand toggle doesn't target the stale entry.
352
+ delete this._msgById[id];
353
+ delete this._msgLineCount[id];
354
+ if (this._lastExpandableId === id) this._lastExpandableId = null;
355
+ }
356
+
207
357
  removeById(id) {
208
358
  delete this._msgById[id];
209
359
  delete this._msgLineCount[id];
360
+ if (this._lastExpandableId === id) this._lastExpandableId = null;
210
361
  }
211
362
 
212
363
  invalidateCache() {}