@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.
@@ -1,15 +1,32 @@
1
1
  'use strict';
2
2
 
3
- const { RST, DIM, FG_RED, SPINNER_DEFS } = require('./ansi');
4
- const { stripAnsi } = require('./utils');
3
+ const { RST, DIM, FG_RED, FG_DARK, SPINNER_DEFS } = require('./ansi');
4
+ const { UI_THEME } = require('./theme');
5
+ const { stripAnsi, termWidth } = require('./utils');
5
6
 
7
+ // Status bar is a *content producer* only. It builds a single line string
8
+ // and hands it to the UI orchestrator via the onChange callback; the
9
+ // orchestrator composes the full live region (status + input + hints) and
10
+ // pushes it through the shared writer in one serialized burst. No direct
11
+ // stdout writes happen here, so the status-bar timer can't interleave
12
+ // mid-bubble.
6
13
  class FullStatusBar {
7
- constructor(layout) {
14
+ constructor(layout, onChange) {
8
15
  this._layout = layout;
16
+ this._onChange = typeof onChange === 'function' ? onChange : () => {};
9
17
  this._state = 'idle';
10
18
  this._label = 'Ready';
11
19
  this._model = '';
12
- this._totalTokens = 0;
20
+ // Current-context indicator state. "Reported" is the last API-reported
21
+ // prompt_tokens (exact, authoritative). "Pending" is an approximate delta
22
+ // for messages added to the conversation after the last report — user
23
+ // input between turns, plus the assistant/tool-result text from the
24
+ // previous turn that will land in the next prompt. Rendered sum:
25
+ // used = _reportedContext + _pendingDelta
26
+ // Reset to pending=0 when a fresh prompt_tokens arrives.
27
+ this._reportedContext = 0;
28
+ this._pendingDelta = 0;
29
+ this._contextLimit = null;
13
30
  this._speed = 0;
14
31
  this._streamStart = null;
15
32
  this._streamTokens = 0;
@@ -17,23 +34,24 @@ class FullStatusBar {
17
34
  this._animIdx = 0;
18
35
  this._animTimer = null;
19
36
  this._paused = false;
20
- this._clockTimer = setInterval(() => this._renderBar(), 1000);
37
+ // Clock tick drives the `HH:MM:SS` part of the right-hand side. Every
38
+ // tick just notifies the orchestrator to re-push the live region — the
39
+ // compound erase+redraw goes through the writer's queue so a tick
40
+ // falling mid-bubble can't produce a torn frame.
41
+ this._clockTimer = setInterval(() => this._notify(), 1000);
21
42
  }
22
43
 
23
44
  pause() { this._paused = true; }
24
- resume() { this._paused = false; this._renderBar(); }
45
+ resume() { this._paused = false; this._notify(); }
25
46
 
26
47
  setModel(name) {
27
48
  this._model = name || '';
28
- this._renderBar();
49
+ this._notify();
29
50
  }
30
51
 
31
52
  update(state, label) {
32
53
  this._state = state || 'idle';
33
54
  if (label !== undefined) this._label = label;
34
-
35
- // An explicit update must always be visible — unpause so _renderBar() runs.
36
- // The pause flag only suppresses timer-driven clock/spinner redraws.
37
55
  this._paused = false;
38
56
 
39
57
  if (state === 'streaming') {
@@ -44,12 +62,11 @@ class FullStatusBar {
44
62
 
45
63
  const animStates = ['thinking', 'streaming', 'tool', 'waiting_download'];
46
64
  if (animStates.includes(state) && !this._animTimer) {
47
- this._animTimer = setInterval(() => { this._animIdx++; this._renderBar(); }, 100);
65
+ this._animTimer = setInterval(() => { this._animIdx++; this._notify(); }, 100);
48
66
  } else if (!animStates.includes(state) && this._animTimer) {
49
67
  clearInterval(this._animTimer); this._animTimer = null; this._animIdx = 0;
50
68
  }
51
- if (state === 'idle') this.drawSeparator();
52
- this._renderBar();
69
+ this._notify();
53
70
  }
54
71
 
55
72
  onToken() {
@@ -61,28 +78,63 @@ class FullStatusBar {
61
78
  const elapsed = (Date.now() - this._streamStart) / 1000;
62
79
  this._speed = elapsed > 0 ? Math.round(this._streamTokens / elapsed) : 0;
63
80
  }
64
- this._renderBar();
81
+ this._notify();
65
82
  }
66
83
  }
67
84
 
85
+ // Called by the agent loop after each API response (authoritative) and by
86
+ // resolveTokenLimit on session start (limit only). Setting contextTokens
87
+ // clears pending because the freshly reported prompt_tokens already
88
+ // accounts for every message that was on the wire.
68
89
  updateMetrics(data) {
69
- if (data && data.totalTokens !== undefined) this._totalTokens = data.totalTokens;
70
- this._renderBar();
90
+ if (!data) { this._notify(); return; }
91
+ if (typeof data.contextTokens === 'number') {
92
+ this._reportedContext = data.contextTokens;
93
+ this._pendingDelta = 0;
94
+ }
95
+ if (data.tokenLimit && typeof data.tokenLimit.limit === 'number') {
96
+ this._contextLimit = data.tokenLimit.limit;
97
+ }
98
+ this._notify();
71
99
  }
72
100
 
73
- liveUpdate(fields) {
74
- if (fields && fields.tokens) {
75
- const n = parseInt(String(fields.tokens), 10);
76
- if (!isNaN(n)) this._totalTokens = n;
101
+ setContextLimit(limit) {
102
+ this._contextLimit = (Number.isInteger(limit) && limit > 0) ? limit : null;
103
+ this._notify();
104
+ }
105
+
106
+ setReportedContext(promptTokens) {
107
+ if (typeof promptTokens === 'number' && promptTokens >= 0) {
108
+ this._reportedContext = promptTokens;
109
+ this._pendingDelta = 0;
110
+ this._notify();
77
111
  }
78
- this._renderBar();
79
112
  }
80
113
 
81
- _renderBar() {
82
- if (this._paused) return;
114
+ addPendingTokens(n) {
115
+ if (typeof n === 'number' && n > 0) {
116
+ this._pendingDelta += n;
117
+ this._notify();
118
+ }
119
+ }
120
+
121
+ // Render the status bar as a single line string. Used by the UI composer
122
+ // to build the live region. ALWAYS returns a string — the status row is a
123
+ // permanent fixture of the live region, never omitted. Missing data
124
+ // renders as a short placeholder ("—") so the row width is stable.
125
+ // `_paused` is honored by suppressing the `_notify` *tick* (no forced
126
+ // redraws while idle) but the row itself is still present when the
127
+ // composer asks for it.
128
+ renderLine() {
83
129
  const layout = this._layout;
84
130
  const cols = layout.cols;
85
- const row = layout.statusRow;
131
+ // Budget one less than cols so `_fitOneRow` in the writer never has to
132
+ // chop a visible char off the end — mid-word truncation ("190,507 tok"
133
+ // → "190,507 to") came from this row filling the full terminal width
134
+ // and getting cut at cols-1 downstream. Budgeting here also keeps the
135
+ // line safely inside a single physical row even if the terminal
136
+ // reflows on resize.
137
+ const maxCols = Math.max(1, cols - 1);
86
138
  let left = '';
87
139
  const state = this._state;
88
140
 
@@ -100,26 +152,111 @@ class FullStatusBar {
100
152
 
101
153
  const now = new Date();
102
154
  const timePart = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
103
- const rightParts = [timePart];
104
- if (this._model) rightParts.push(this._model);
105
- const liveTokens = this._totalTokens + this._streamTokens;
106
- rightParts.push(`${liveTokens.toLocaleString()} tok`);
107
- if (state === 'streaming' && this._speed > 0) rightParts.push(`${this._speed} t/s`);
108
-
109
- const rightVisible = rightParts.join(' · ');
110
- const rightAnsi = `${DIM}${rightParts.join(` ${DIM}·${RST}${DIM} `)}${RST}`;
111
- const leftLen = stripAnsi(left).length;
112
- const rightLen = rightVisible.length;
113
- const padding = Math.max(1, cols - leftLen - rightLen);
114
- const line = left + ' '.repeat(padding) + rightAnsi;
115
-
116
- process.stdout.write(`\x1b7\x1b[${row};1H\x1b[2K${line}\x1b8`);
155
+ // Field slots are paired: `visible` drives layout/padding; `ansi` is
156
+ // injected into the final rendered string. Keeping them side-by-side
157
+ // avoids double-counting ANSI escapes in the padding math and lets the
158
+ // token field override the surrounding DIM for its warning/error pct.
159
+ // `priority` is drop-priority: lower values are dropped first when the
160
+ // row doesn't fit the terminal width. Priority order (high→low):
161
+ // model > tokens > time > speed
162
+ // so on narrow terminals the row silently loses speed, then time, then
163
+ // tokens, keeping the model name readable to the last. Never chop a
164
+ // field mid-character.
165
+ const tokenField = this._buildTokenField();
166
+ const fields = [
167
+ { visible: timePart, ansi: timePart, priority: 2 },
168
+ { visible: this._model || '—', ansi: this._model || '—', priority: 4 },
169
+ { visible: tokenField.visible, ansi: tokenField.ansi, priority: 3 },
170
+ ];
171
+ if (state === 'streaming' && this._speed > 0) {
172
+ const s = `${this._speed} t/s`;
173
+ fields.push({ visible: s, ansi: s, priority: 1 });
174
+ }
175
+
176
+ const sep = ' · ';
177
+ const sepAnsi = ` ${RST}${DIM}·${RST}${DIM} `;
178
+ const leftWidth = termWidth(stripAnsi(left));
179
+ // Keep at least one space between left and right chrome when left is
180
+ // non-empty; when idle (left == '') the right can sit at column 0.
181
+ const gap = leftWidth > 0 ? 1 : 0;
182
+ const availableRight = Math.max(0, maxCols - leftWidth - gap);
183
+
184
+ const widthOf = (arr) => {
185
+ let w = 0;
186
+ for (let i = 0; i < arr.length; i++) {
187
+ if (i > 0) w += sep.length;
188
+ w += termWidth(arr[i].visible);
189
+ }
190
+ return w;
191
+ };
192
+
193
+ const keep = fields.slice();
194
+ while (keep.length > 0 && widthOf(keep) > availableRight) {
195
+ let minIdx = 0;
196
+ for (let i = 1; i < keep.length; i++) {
197
+ if (keep[i].priority < keep[minIdx].priority) minIdx = i;
198
+ }
199
+ keep.splice(minIdx, 1);
200
+ }
201
+
202
+ if (keep.length === 0) {
203
+ // Terminal too narrow for any right-hand field — render left only,
204
+ // padded to fill the budgeted row. Downstream truncation (if the
205
+ // left itself exceeds maxCols) is handled by `_fitOneRow`.
206
+ const padCount = Math.max(0, maxCols - leftWidth);
207
+ return left + ' '.repeat(padCount);
208
+ }
209
+
210
+ const rightWidth = widthOf(keep);
211
+ const rightAnsi = `${DIM}${keep.map((f) => f.ansi).join(sepAnsi)}${RST}`;
212
+ const padding = Math.max(gap > 0 ? gap : 0, maxCols - leftWidth - rightWidth);
213
+ return left + ' '.repeat(padding) + rightAnsi;
117
214
  }
118
215
 
119
- drawSeparator() {
120
- const layout = this._layout;
121
- const { FG_DARK } = require('./ansi');
122
- process.stdout.write(`\x1b7\x1b[${layout.separatorRow};1H\x1b[2K${FG_DARK}${'─'.repeat(layout.cols)}${RST}\x1b8`);
216
+ // Build the current-context indicator as a { visible, ansi } pair.
217
+ // `visible` is the plain text used for padding math; `ansi` carries the
218
+ // threshold color for the percentage (gray <70, amber 70-90, red ≥90).
219
+ // When the active profile reports no context_length, the "/ limit" and
220
+ // "(%)" portions are omitted.
221
+ _buildTokenField() {
222
+ const used = Math.max(0, (this._reportedContext | 0) + (this._pendingDelta | 0));
223
+ const usedStr = used.toLocaleString();
224
+ if (!this._contextLimit) {
225
+ const s = `${usedStr} tok`;
226
+ return { visible: s, ansi: s };
227
+ }
228
+ const limit = this._contextLimit;
229
+ const pct = limit > 0 ? Math.round((used / limit) * 100) : 0;
230
+ const limitStr = limit.toLocaleString();
231
+ const visible = `${usedStr} / ${limitStr} tok (${pct}%)`;
232
+ let pctAnsi = `${pct}%`;
233
+ if (pct >= 90) {
234
+ pctAnsi = `${RST}${UI_THEME.error}${pct}%${RST}${DIM}`;
235
+ } else if (pct >= 70) {
236
+ pctAnsi = `${RST}${UI_THEME.warning}${pct}%${RST}${DIM}`;
237
+ }
238
+ const ansi = `${usedStr} / ${limitStr} tok (${pctAnsi})`;
239
+ return { visible, ansi };
240
+ }
241
+
242
+ // Thin horizontal rule rendered above the status line. Kept as its own
243
+ // line in the live region so the composer can turn it on/off without
244
+ // affecting surrounding rows.
245
+ renderSeparator() {
246
+ const cols = this._layout.cols;
247
+ return `${FG_DARK}${'─'.repeat(Math.max(0, cols - 1))}${RST}`;
248
+ }
249
+
250
+ // Back-compat: older call sites (command.js, create-ui.js) call
251
+ // drawSeparator/_renderBar after every state change. These now just
252
+ // trigger a live-region refresh — the orchestrator composes the full
253
+ // region and serializes the write.
254
+ drawSeparator() { this._notify(); }
255
+ _renderBar() { this._notify(); }
256
+
257
+ _notify() {
258
+ if (this._paused) { this._onChange(); return; }
259
+ this._onChange();
123
260
  }
124
261
 
125
262
  destroy() {
package/lib/ui/stream.js CHANGED
@@ -4,6 +4,7 @@ const readline = require('readline');
4
4
 
5
5
  const { RST, DIM, FG_DARK, FG_CODE_BG, FG_CODE_BORDER, FG_CODE_LANG, FG_TAG, FG_FILEPATH, KEYWORDS } = require('./ansi');
6
6
  const { renderMarkdown, _mdInline } = require('./diff');
7
+ const writer = require('./writer');
7
8
 
8
9
  const THINK_OPEN = '<think>', THINK_CLOSE = '</think>';
9
10
 
@@ -15,21 +16,17 @@ class StreamRenderer {
15
16
  this._showThink = opts.showThink || false;
16
17
  this.inToolCall = null;
17
18
  this._firstLinePrefix = opts.firstLinePrefix || null;
18
- this._firstLineDone = false; this._firstToken = false;
19
+ this._firstLineDone = false;
19
20
  this._streamedText = ''; this._hasToolCall = false; this._linesWritten = 0;
20
21
  }
21
22
 
22
23
  _write(str) {
23
24
  for (let i = 0; i < str.length; i++) if (str[i] === '\n') this._linesWritten++;
25
+ // audit: allowed — non-TUI StreamRenderer, no live region to protect.
24
26
  process.stdout.write(str);
25
27
  }
26
28
 
27
29
  feed(chunk) {
28
- if (!this._firstToken && chunk) {
29
- this._firstToken = true;
30
- const { StatusBar } = require('./legacy');
31
- if (StatusBar.current) StatusBar.current.update({ status: 'streaming' });
32
- }
33
30
  this._streamedText += chunk;
34
31
  this.buffer += chunk;
35
32
  while (this.buffer.includes('\n')) {
@@ -45,14 +42,13 @@ class StreamRenderer {
45
42
  if (this.inThinkBlock) { if (this._showThink) this._write(`${FG_DARK}⟨/thinking⟩${RST}\n`); this.inThinkBlock = false; }
46
43
  if (this.inToolCall) { this._renderToolCall(this.inToolCall); this.inToolCall = null; }
47
44
  if (this.inCodeBlock) { this._write(` ${FG_CODE_BORDER}╰${'─'.repeat(50)}${RST}\n`); this.inCodeBlock = false; }
48
- const { StatusBar } = require('./legacy');
49
- if (StatusBar.current) StatusBar.current.update({ status: 'idle' });
50
45
  if (process.stdout.isTTY && !this._hasToolCall && !this._showThink && this._streamedText.trim()) {
51
46
  let prose = this._streamedText;
52
47
  let ts = prose.indexOf(THINK_OPEN);
53
48
  while (ts !== -1) { const te = prose.indexOf(THINK_CLOSE, ts); if (te !== -1) { prose = prose.slice(0, ts) + prose.slice(te + THINK_CLOSE.length); ts = prose.indexOf(THINK_OPEN); } else { prose = prose.slice(0, ts); break; } }
54
49
  prose = prose.split(THINK_CLOSE).join('');
55
50
  if (prose.trim()) {
51
+ // audit: allowed — non-TUI StreamRenderer, no live region to protect.
56
52
  if (this._linesWritten > 0) { readline.moveCursor(process.stdout, 0, -this._linesWritten); readline.cursorTo(process.stdout, 0); process.stdout.write('\x1b[0J'); }
57
53
  renderMarkdown(prose);
58
54
  }
@@ -134,11 +130,11 @@ class StreamRenderer {
134
130
  _renderToolCall(call) {
135
131
  this._hasToolCall = true;
136
132
  const name = call.name, lines = call.contentLines || [], content = lines.join('\n').trim();
137
- if (name === 'write_file') { process.stdout.write(` ${FG_TAG}◆ write_file${RST} ${FG_FILEPATH}${call.filePath || ''}${RST} ${FG_DARK}(${lines.length} lines)${RST}\n`); return; }
138
- if (name === 'read_file') { process.stdout.write(` ${FG_TAG}◆ read_file${RST} ${FG_FILEPATH}${content}${RST}\n`); return; }
139
- if (name === 'exec' || name === 'shell' || name === 'run_command' || name === 'run') { process.stdout.write(` ${FG_TAG}◆ exec${RST} ${FG_DARK}${content}${RST}\n`); return; }
133
+ if (name === 'write_file') { writer.scrollback(` ${FG_TAG}◆ write_file${RST} ${FG_FILEPATH}${call.filePath || ''}${RST} ${FG_DARK}(${lines.length} lines)${RST}`); return; }
134
+ if (name === 'read_file') { writer.scrollback(` ${FG_TAG}◆ read_file${RST} ${FG_FILEPATH}${content}${RST}`); return; }
135
+ if (name === 'exec' || name === 'shell' || name === 'run_command' || name === 'run') { writer.scrollback(` ${FG_TAG}◆ exec${RST} ${FG_DARK}${content}${RST}`); return; }
140
136
  const preview = content.length > 80 ? content.slice(0, 77) + '...' : content;
141
- process.stdout.write(` ${FG_TAG}◆ ${name}${RST} ${FG_DARK}${preview}${RST}\n`);
137
+ writer.scrollback(` ${FG_TAG}◆ ${name}${RST} ${FG_DARK}${preview}${RST}`);
142
138
  }
143
139
 
144
140
  _colorizeCode(line) {
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ // Process-level signal/exit wiring for the TUI. The actual terminal cleanup
4
+ // (erase live region, reset SGR, show cursor, pop modes) lives in
5
+ // writer.teardown() so every exit path runs the exact same single-write
6
+ // sequence. This module just makes sure that sequence runs no matter how
7
+ // the process dies.
8
+
9
+ const writer = require('./writer');
10
+
11
+ let _registered = false;
12
+
13
+ // Kept for backwards compatibility with callers that publish viewport
14
+ // metrics. The live-region writer doesn't need them — teardown computes
15
+ // what to erase from its own live-region height.
16
+ function setCleanupInfo(_info) {}
17
+
18
+ // Idempotent. Safe to call from synchronous contexts including the 'exit'
19
+ // handler. Delegates to writer.teardown(), which is the single source of
20
+ // truth for what a "clean" terminal state looks like.
21
+ function teardownTerminal() {
22
+ try { writer.teardown(); } catch {}
23
+ }
24
+
25
+ // Install process-level handlers. Called once by the entrypoint. Idempotent
26
+ // so library code can also call it defensively without doubling handlers.
27
+ function registerTerminalCleanup() {
28
+ if (_registered) return;
29
+ _registered = true;
30
+
31
+ // Normal exit + process.exit(): fires synchronously, last thing Node does.
32
+ // Catches every path that doesn't already manually call teardown.
33
+ process.on('exit', () => { try { writer.teardown(); } catch {} });
34
+
35
+ // Signals that should terminate the app. Cleanup first, then exit with
36
+ // the conventional 128+signum code. In TUI raw mode, Ctrl+C is consumed
37
+ // at the byte level and SIGINT is not delivered, so this handler only
38
+ // trips in non-raw contexts (one-shot commands, readline prompts, etc.).
39
+ process.on('SIGINT', () => { try { writer.teardown(); } catch {} process.exit(130); });
40
+ process.on('SIGTERM', () => { try { writer.teardown(); } catch {} process.exit(143); });
41
+ process.on('SIGHUP', () => { try { writer.teardown(); } catch {} process.exit(129); });
42
+
43
+ // Last-chance net: if something throws outside a try/catch, still
44
+ // restore terminal state before the stack trace prints.
45
+ process.on('uncaughtException', (err) => {
46
+ try { writer.teardown(); } catch {}
47
+ // audit: allowed — crash handler stderr after writer teardown.
48
+ try { console.error(err && err.stack ? err.stack : err); } catch {}
49
+ process.exit(1);
50
+ });
51
+
52
+ process.on('unhandledRejection', (reason) => {
53
+ try { writer.teardown(); } catch {}
54
+ // audit: allowed — crash handler stderr after writer teardown.
55
+ try { console.error(reason && reason.stack ? reason.stack : reason); } catch {}
56
+ process.exit(1);
57
+ });
58
+ }
59
+
60
+ module.exports = { teardownTerminal, registerTerminalCleanup, setCleanupInfo };
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ const { bg256, fg256, bgRGB, fgRGB } = require('./ansi');
4
+
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.
9
+ 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
17
+ 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].
22
+ categories: {
23
+ net: fg256(110), // dusty blue — http, download, upload
24
+ file: fg256(151), // soft sage — file ops
25
+ cmd: fg256(180), // warm tan — shell / exec
26
+ user: fg256(217), // pale rose — ask_user
27
+ memory: fg256(183), // light lavender — memory
28
+ env: fg256(186), // pale gold — env vars
29
+ system: fg256(109), // steel blue — system_info
30
+ debug: fg256(244), // muted gray — debug blocks
31
+ tool: fg256(141), // accent purple — fallback
32
+ },
33
+ };
34
+
35
+ // Indicator glyphs for status lines. Separated from colour so the renderer
36
+ // can swap either independently (e.g. an ASCII-only fallback later).
37
+ const UI_ICONS = {
38
+ pending: '●',
39
+ success: '✓',
40
+ error: '✗',
41
+ retry: '⟳',
42
+ warn: '⚠',
43
+ bullet: '●',
44
+ };
45
+
46
+ // Diff rendering palette. Each change-type carries both a 256-color and a
47
+ // truecolor background so the renderer can pick based on COLORTERM at start.
48
+ // Only this file names colors — keep magic numbers out of diff.js.
49
+ const DIFF_THEME = {
50
+ added: {
51
+ bg256: bg256(22), // deep forest green
52
+ bgTC: bgRGB(14, 40, 23),
53
+ signFg: fg256(114), // bright mint
54
+ sign: '+',
55
+ },
56
+ removed: {
57
+ bg256: bg256(52), // deep burgundy
58
+ bgTC: bgRGB(50, 18, 20),
59
+ signFg: fg256(174), // bright salmon
60
+ sign: '-',
61
+ },
62
+ context: {
63
+ bg256: '',
64
+ bgTC: '',
65
+ signFg: '',
66
+ sign: ' ',
67
+ },
68
+ gutter: { bg256: '', bgTC: '' },
69
+ lineNumber: fg256(240), // dim gray
70
+ code: '\x1b[39m', // terminal default fg
71
+ header: '\x1b[1;38;5;75m', // bold light blue
72
+ frame: fg256(238), // very dim gray
73
+ };
74
+
75
+ // Maps an agent-loop tool tag onto the short category shown before the
76
+ // operation in a tool line ("file", "net", "shell"). Renderers should
77
+ // fall back to the tag itself if the tag isn't listed here.
78
+ //
79
+ // Keys include BOTH the XML tag names (read_file, write_file, shell) and
80
+ // the internal action names emitted by the native-function mapper (read,
81
+ // write, exec). Normalization happens at lookup time so neither side needs
82
+ // to know about the other's naming.
83
+ const TOOL_CATEGORIES = {
84
+ exec: 'shell', shell: 'shell', run: 'shell', run_command: 'shell', bash: 'shell',
85
+ read: 'file', write: 'file', append: 'file',
86
+ read_file: 'file', write_file: 'file', create_file: 'file',
87
+ append_file: 'file', delete_file: 'file', edit_file: 'file',
88
+ list_dir: 'file', search_files: 'file', search_in_file: 'file',
89
+ replace_in_file: 'file', move_file: 'file', copy_file: 'file',
90
+ file_stat: 'file', make_dir: 'file', remove_dir: 'file',
91
+ http_get: 'net', download: 'net', upload: 'net',
92
+ ask_user: 'user',
93
+ store_memory: 'memory', recall_memory: 'memory', list_memories: 'memory',
94
+ get_env: 'env', set_env: 'env',
95
+ system_info: 'system',
96
+ debug: 'debug',
97
+ };
98
+
99
+ module.exports = { DIFF_THEME, UI_THEME, UI_ICONS, TOOL_CATEGORIES };
package/lib/ui/utils.js CHANGED
@@ -7,11 +7,112 @@ function stripAnsi(str) {
7
7
  return str.replace(/\x1b\[[^m]*m/g, '');
8
8
  }
9
9
 
10
- function hr(char, color) {
11
- const { FG_DARK, RST } = require('./ansi');
12
- const c = char || '─';
13
- const col = color || FG_DARK;
14
- process.stdout.write(`${col}${c.repeat(getCols())}${RST}\n`);
10
+ // Display-column width of a single codepoint. Factored out so truncateVisible
11
+ // can reuse the width logic without rebuilding a per-character substring.
12
+ function _cpWidth(cp) {
13
+ if (cp === 0) return 0;
14
+ if ((cp >= 0x0300 && cp <= 0x036F) ||
15
+ (cp >= 0x200B && cp <= 0x200F) ||
16
+ cp === 0x2060 ||
17
+ (cp >= 0xFE00 && cp <= 0xFE0F) ||
18
+ (cp >= 0xE0100 && cp <= 0xE01EF)) {
19
+ return 0;
20
+ }
21
+ if ((cp >= 0x3040 && cp <= 0x33FF) ||
22
+ (cp >= 0x3400 && cp <= 0x4DBF) ||
23
+ (cp >= 0x4E00 && cp <= 0x9FFF) ||
24
+ (cp >= 0xF900 && cp <= 0xFAFF) ||
25
+ (cp >= 0x2600 && cp <= 0x27BF) ||
26
+ (cp >= 0x1F300 && cp <= 0x1FAFF)) {
27
+ return 2;
28
+ }
29
+ return 1;
30
+ }
31
+
32
+ // Display-column width of a string. Handles ASCII/Latin/Cyrillic (1 col),
33
+ // CJK ideographs + hiragana/katakana (2 cols), emoji in SMP pictograph
34
+ // blocks and the dingbat/misc-symbols range (2 cols), and zero-width joiners,
35
+ // combining marks, variation selectors (0 cols). Out-of-scope scripts
36
+ // (Hangul, Thai, RTL) fall through as 1 col.
37
+ function termWidth(str) {
38
+ if (!str) return 0;
39
+ let w = 0;
40
+ for (const ch of str) w += _cpWidth(ch.codePointAt(0));
41
+ return w;
42
+ }
43
+
44
+ // ANSI-aware, display-width-aware truncation to `maxCols` visible columns.
45
+ // Walks the string once; CSI escape sequences (`\x1b[…final`) are copied
46
+ // through verbatim without consuming any visible width. Visible-width math
47
+ // uses _cpWidth, so a 2-col CJK glyph or emoji counts as 2 and combining
48
+ // marks count as 0. Always terminates with `\x1b[0m` so the terminal isn't
49
+ // left in a colored state at the truncation point (safe when no escape
50
+ // preceded the cut — a redundant reset is a no-op).
51
+ function truncateVisible(str, maxCols) {
52
+ if (!str) return '';
53
+ const max = Math.max(0, maxCols | 0);
54
+ if (max === 0) return '\x1b[0m';
55
+ const s = String(str);
56
+ const len = s.length;
57
+ let out = '';
58
+ let cols = 0;
59
+ let i = 0;
60
+ while (i < len) {
61
+ const code = s.charCodeAt(i);
62
+ // CSI: ESC '[' … final-byte-in-0x40..0x7E.
63
+ if (code === 0x1B && i + 1 < len && s.charCodeAt(i + 1) === 0x5B) {
64
+ let j = i + 2;
65
+ while (j < len) {
66
+ const c = s.charCodeAt(j);
67
+ if (c >= 0x40 && c <= 0x7E) { j++; break; }
68
+ j++;
69
+ }
70
+ out += s.slice(i, j);
71
+ i = j;
72
+ continue;
73
+ }
74
+ // Bare ESC or non-CSI escape lead-in: copy through as 0 width so it
75
+ // doesn't eat a column it never claimed.
76
+ if (code === 0x1B) { out += s[i]; i++; continue; }
77
+ const cp = s.codePointAt(i);
78
+ const clen = cp > 0xFFFF ? 2 : 1;
79
+ const w = _cpWidth(cp);
80
+ if (cols + w > max) break;
81
+ out += s.slice(i, i + clen);
82
+ cols += w;
83
+ i += clen;
84
+ }
85
+ return out + '\x1b[0m';
86
+ }
87
+
88
+ // Repeat a single-column glyph so the visible row is exactly `width` columns.
89
+ // Accepts an optional `used` arg (columns already occupied) to fill the
90
+ // remainder — handy for header lines like " LABEL ══════…" that need to
91
+ // reach the right edge regardless of terminal size.
92
+ function repeatToWidth(char, width, used) {
93
+ const w = Math.max(0, (width || 0) - (used || 0));
94
+ return (char || '─').repeat(w);
95
+ }
96
+
97
+ // Number of *physical* terminal rows that `text` occupies at the given
98
+ // width. Uses termWidth (display columns) so emoji / CJK / combining marks
99
+ // are counted correctly, and ceils each logical line's wrap count. An
100
+ // empty line still occupies 1 row. This is the number to use whenever the
101
+ // redraw logic cursor-ups to erase or when the layout grows to hold new
102
+ // content — counting `text.split('\n').length` undercounts wrapped lines
103
+ // and lets the trailing physical row leak into scrollback on the next
104
+ // redraw (TUI duplicate-frame bug).
105
+ function displayRows(text, termCols) {
106
+ if (text === undefined || text === null) return 0;
107
+ const cols = Math.max(1, termCols | 0);
108
+ const raw = stripAnsi(String(text));
109
+ const lines = raw.split('\n');
110
+ let rows = 0;
111
+ for (const line of lines) {
112
+ const w = termWidth(line);
113
+ rows += w === 0 ? 1 : Math.ceil(w / cols);
114
+ }
115
+ return rows;
15
116
  }
16
117
 
17
118
  function boxLine(text, width) {
@@ -42,4 +143,32 @@ function isPrintableKey(str, key) {
42
143
  return !/[\x00-\x1f\x7f]/.test(str);
43
144
  }
44
145
 
45
- module.exports = { getCols, getRows, stripAnsi, hr, boxLine, insertCharAt, removeCharAt, isPrintableKey };
146
+ // Rough token count for context-size indicator. Not a real tokenizer — zero-dep
147
+ // project can't afford tiktoken/BPE. Single pass over charCodeAt, no regex.
148
+ // Ratios chosen so an all-ASCII English doc averages ~4 chars/token (OpenAI-ish),
149
+ // while CJK-heavy Chinese/Japanese text (where one ideograph ≈ one token for
150
+ // most BPE vocabs) scales toward ~2 chars/token. Mixed code+comments land in
151
+ // the middle band. Used by the status bar only; never drives hard limits.
152
+ function approxTokens(text) {
153
+ if (!text) return 0;
154
+ const str = typeof text === 'string' ? text : String(text);
155
+ const total = str.length;
156
+ if (total === 0) return 0;
157
+ let cjk = 0;
158
+ for (let i = 0; i < total; i++) {
159
+ const cp = str.charCodeAt(i);
160
+ if ((cp >= 0x3040 && cp <= 0x33FF) ||
161
+ (cp >= 0x3400 && cp <= 0x4DBF) ||
162
+ (cp >= 0x4E00 && cp <= 0x9FFF) ||
163
+ (cp >= 0xAC00 && cp <= 0xD7AF) ||
164
+ (cp >= 0xF900 && cp <= 0xFAFF)) {
165
+ cjk++;
166
+ }
167
+ }
168
+ const ratio = cjk / total;
169
+ if (ratio > 0.30) return Math.ceil(total / 2);
170
+ if (ratio > 0.10) return Math.ceil(total / 3);
171
+ return Math.ceil(total / 4);
172
+ }
173
+
174
+ module.exports = { getCols, getRows, stripAnsi, termWidth, truncateVisible, displayRows, repeatToWidth, boxLine, insertCharAt, removeCharAt, isPrintableKey, approxTokens };