@semalt-ai/code 1.8.3 → 1.8.5

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,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const { RST, DIM, FG_RED, FG_DARK, SPINNER_DEFS } = require('./ansi');
4
- const { stripAnsi } = require('./utils');
4
+ const { UI_THEME } = require('./theme');
5
+ const { stripAnsi, termWidth } = require('./utils');
5
6
 
6
7
  // Status bar is a *content producer* only. It builds a single line string
7
8
  // and hands it to the UI orchestrator via the onChange callback; the
@@ -16,8 +17,16 @@ class FullStatusBar {
16
17
  this._state = 'idle';
17
18
  this._label = 'Ready';
18
19
  this._model = '';
19
- this._totalTokens = 0;
20
- this._tokenLimit = null;
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;
21
30
  this._speed = 0;
22
31
  this._streamStart = null;
23
32
  this._streamTokens = 0;
@@ -73,27 +82,59 @@ class FullStatusBar {
73
82
  }
74
83
  }
75
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.
76
89
  updateMetrics(data) {
77
- if (data && data.totalTokens !== undefined) this._totalTokens = data.totalTokens;
78
- if (data && 'tokenLimit' in data) this._tokenLimit = data.tokenLimit;
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
+ }
79
98
  this._notify();
80
99
  }
81
100
 
82
- liveUpdate(fields) {
83
- if (fields && fields.tokens) {
84
- const n = parseInt(String(fields.tokens), 10);
85
- if (!isNaN(n)) this._totalTokens = n;
86
- }
101
+ setContextLimit(limit) {
102
+ this._contextLimit = (Number.isInteger(limit) && limit > 0) ? limit : null;
87
103
  this._notify();
88
104
  }
89
105
 
106
+ setReportedContext(promptTokens) {
107
+ if (typeof promptTokens === 'number' && promptTokens >= 0) {
108
+ this._reportedContext = promptTokens;
109
+ this._pendingDelta = 0;
110
+ this._notify();
111
+ }
112
+ }
113
+
114
+ addPendingTokens(n) {
115
+ if (typeof n === 'number' && n > 0) {
116
+ this._pendingDelta += n;
117
+ this._notify();
118
+ }
119
+ }
120
+
90
121
  // Render the status bar as a single line string. Used by the UI composer
91
- // to build the live region. Returns null when paused so the composer can
92
- // omit the row entirely.
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.
93
128
  renderLine() {
94
- if (this._paused) return null;
95
129
  const layout = this._layout;
96
130
  const cols = layout.cols;
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);
97
138
  let left = '';
98
139
  const state = this._state;
99
140
 
@@ -111,27 +152,93 @@ class FullStatusBar {
111
152
 
112
153
  const now = new Date();
113
154
  const timePart = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
114
- const rightParts = [timePart];
115
- if (this._model) rightParts.push(this._model);
116
- const liveTokens = this._totalTokens + this._streamTokens;
117
- rightParts.push(`${liveTokens.toLocaleString()} tok`);
118
- if (this._tokenLimit) {
119
- if (this._tokenLimit.limit === null) {
120
- rightParts.push('limit unknown');
121
- } else {
122
- rightParts.push(`${this._tokenLimit.used.toLocaleString()}/${this._tokenLimit.limit.toLocaleString()} (${this._tokenLimit.pct}%)`);
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);
123
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);
124
200
  }
125
- if (state === 'streaming' && this._speed > 0) rightParts.push(`${this._speed} t/s`);
126
201
 
127
- const rightVisible = rightParts.join(' · ');
128
- const rightAnsi = `${DIM}${rightParts.join(` ${DIM}·${RST}${DIM} `)}${RST}`;
129
- const leftLen = stripAnsi(left).length;
130
- const rightLen = rightVisible.length;
131
- const padding = Math.max(1, cols - leftLen - rightLen);
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);
132
213
  return left + ' '.repeat(padding) + rightAnsi;
133
214
  }
134
215
 
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
+
135
242
  // Thin horizontal rule rendered above the status line. Kept as its own
136
243
  // line in the live region so the composer can turn it on/off without
137
244
  // affecting surrounding rows.
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) {
@@ -7,6 +7,7 @@
7
7
  // the process dies.
8
8
 
9
9
  const writer = require('./writer');
10
+ const dbg = require('../debug');
10
11
 
11
12
  let _registered = false;
12
13
 
@@ -30,26 +31,33 @@ function registerTerminalCleanup() {
30
31
 
31
32
  // Normal exit + process.exit(): fires synchronously, last thing Node does.
32
33
  // Catches every path that doesn't already manually call teardown.
33
- process.on('exit', () => { try { writer.teardown(); } catch {} });
34
+ process.on('exit', () => {
35
+ try { writer.teardown(); } catch {}
36
+ try { dbg.close(); } catch {}
37
+ });
34
38
 
35
39
  // Signals that should terminate the app. Cleanup first, then exit with
36
40
  // the conventional 128+signum code. In TUI raw mode, Ctrl+C is consumed
37
41
  // at the byte level and SIGINT is not delivered, so this handler only
38
42
  // 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); });
43
+ process.on('SIGINT', () => { try { writer.teardown(); } catch {} try { dbg.close(); } catch {} process.exit(130); });
44
+ process.on('SIGTERM', () => { try { writer.teardown(); } catch {} try { dbg.close(); } catch {} process.exit(143); });
45
+ process.on('SIGHUP', () => { try { writer.teardown(); } catch {} try { dbg.close(); } catch {} process.exit(129); });
42
46
 
43
47
  // Last-chance net: if something throws outside a try/catch, still
44
48
  // restore terminal state before the stack trace prints.
45
49
  process.on('uncaughtException', (err) => {
46
50
  try { writer.teardown(); } catch {}
51
+ try { dbg.close(); } catch {}
52
+ // audit: allowed — crash handler stderr after writer teardown.
47
53
  try { console.error(err && err.stack ? err.stack : err); } catch {}
48
54
  process.exit(1);
49
55
  });
50
56
 
51
57
  process.on('unhandledRejection', (reason) => {
52
58
  try { writer.teardown(); } catch {}
59
+ try { dbg.close(); } catch {}
60
+ // audit: allowed — crash handler stderr after writer teardown.
53
61
  try { console.error(reason && reason.stack ? reason.stack : reason); } catch {}
54
62
  process.exit(1);
55
63
  });
package/lib/ui/theme.js CHANGED
@@ -15,6 +15,21 @@ const UI_THEME = {
15
15
  subtle: fg256(244), // slightly less dim — secondary text
16
16
  accent: fg256(141), // soft purple — tool names, IDs
17
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
+ },
18
33
  };
19
34
 
20
35
  // Indicator glyphs for status lines. Separated from colour so the renderer
@@ -57,11 +72,17 @@ const DIFF_THEME = {
57
72
  frame: fg256(238), // very dim gray
58
73
  };
59
74
 
60
- // Maps an agent-loop tool tag onto the short category shown before the colon
61
- // in a tool-status line ("file:", "net:", "cmd:"). Renderers should fall
62
- // back to the tag itself if the tag isn't listed here.
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.
63
83
  const TOOL_CATEGORIES = {
64
- exec: 'cmd', shell: 'cmd', run: 'cmd', run_command: 'cmd',
84
+ exec: 'shell', shell: 'shell', run: 'shell', run_command: 'shell', bash: 'shell',
85
+ read: 'file', write: 'file', append: 'file',
65
86
  read_file: 'file', write_file: 'file', create_file: 'file',
66
87
  append_file: 'file', delete_file: 'file', edit_file: 'file',
67
88
  list_dir: 'file', search_files: 'file', search_in_file: 'file',
package/lib/ui/utils.js CHANGED
@@ -7,6 +7,28 @@ function stripAnsi(str) {
7
7
  return str.replace(/\x1b\[[^m]*m/g, '');
8
8
  }
9
9
 
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
+
10
32
  // Display-column width of a string. Handles ASCII/Latin/Cyrillic (1 col),
11
33
  // CJK ideographs + hiragana/katakana (2 cols), emoji in SMP pictograph
12
34
  // blocks and the dingbat/misc-symbols range (2 cols), and zero-width joiners,
@@ -15,35 +37,52 @@ function stripAnsi(str) {
15
37
  function termWidth(str) {
16
38
  if (!str) return 0;
17
39
  let w = 0;
18
- for (const ch of str) {
19
- const cp = ch.codePointAt(0);
20
- if (cp === 0) continue;
21
- if ((cp >= 0x0300 && cp <= 0x036F) ||
22
- (cp >= 0x200B && cp <= 0x200F) ||
23
- cp === 0x2060 ||
24
- (cp >= 0xFE00 && cp <= 0xFE0F) ||
25
- (cp >= 0xE0100 && cp <= 0xE01EF)) {
26
- continue;
27
- }
28
- if ((cp >= 0x3040 && cp <= 0x33FF) ||
29
- (cp >= 0x3400 && cp <= 0x4DBF) ||
30
- (cp >= 0x4E00 && cp <= 0x9FFF) ||
31
- (cp >= 0xF900 && cp <= 0xFAFF) ||
32
- (cp >= 0x2600 && cp <= 0x27BF) ||
33
- (cp >= 0x1F300 && cp <= 0x1FAFF)) {
34
- w += 2;
35
- continue;
36
- }
37
- w += 1;
38
- }
40
+ for (const ch of str) w += _cpWidth(ch.codePointAt(0));
39
41
  return w;
40
42
  }
41
43
 
42
- function hr(char, color) {
43
- const { FG_DARK, RST } = require('./ansi');
44
- const c = char || '─';
45
- const col = color || FG_DARK;
46
- process.stdout.write(`${col}${c.repeat(getCols())}${RST}\n`);
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';
47
86
  }
48
87
 
49
88
  // Repeat a single-column glyph so the visible row is exactly `width` columns.
@@ -104,4 +143,32 @@ function isPrintableKey(str, key) {
104
143
  return !/[\x00-\x1f\x7f]/.test(str);
105
144
  }
106
145
 
107
- module.exports = { getCols, getRows, stripAnsi, termWidth, displayRows, hr, repeatToWidth, 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 };