@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.
- package/.claude/settings.local.json +3 -1
- package/CLAUDE.md +4 -1
- package/TECHNICAL_DEBT.md +66 -0
- package/index.js +23 -9
- package/lib/agent.js +407 -129
- package/lib/api.js +105 -39
- package/lib/args.js +22 -0
- package/lib/commands.js +367 -132
- package/lib/config.js +14 -0
- package/lib/constants.js +1 -1
- package/lib/debug.js +106 -0
- package/lib/permissions.js +9 -8
- package/lib/proc.js +96 -0
- package/lib/prompts.js +8 -10
- package/lib/tool_specs.js +14 -7
- package/lib/tools.js +299 -118
- package/lib/ui/chat-history.js +37 -8
- package/lib/ui/create-ui.js +63 -38
- package/lib/ui/diff.js +4 -3
- package/lib/ui/format.js +321 -0
- package/lib/ui/input-field.js +134 -59
- package/lib/ui/layout.js +0 -2
- package/lib/ui/messages.js +44 -0
- package/lib/ui/select.js +114 -0
- package/lib/ui/status-bar.js +135 -28
- package/lib/ui/stream.js +8 -12
- package/lib/ui/terminal.js +12 -4
- package/lib/ui/theme.js +25 -4
- package/lib/ui/utils.js +94 -27
- package/lib/ui/writer.js +391 -45
- package/lib/ui.js +6 -6
- package/package.json +1 -1
- package/lib/ui/legacy.js +0 -130
package/lib/ui/status-bar.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
78
|
-
if (data
|
|
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
|
-
|
|
83
|
-
|
|
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.
|
|
92
|
-
//
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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;
|
|
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') {
|
|
138
|
-
if (name === 'read_file') {
|
|
139
|
-
if (name === 'exec' || name === 'shell' || name === 'run_command' || name === 'run') {
|
|
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
|
-
|
|
137
|
+
writer.scrollback(` ${FG_TAG}◆ ${name}${RST} ${FG_DARK}${preview}${RST}`);
|
|
142
138
|
}
|
|
143
139
|
|
|
144
140
|
_colorizeCode(line) {
|
package/lib/ui/terminal.js
CHANGED
|
@@ -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', () => {
|
|
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
|
|
61
|
-
// in a tool
|
|
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: '
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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 };
|