@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/.claude/settings.local.json +14 -1
- package/CLAUDE.md +2 -1
- package/index.js +29 -8
- package/lib/agent.js +725 -133
- package/lib/api.js +193 -59
- package/lib/commands.js +263 -201
- package/lib/config.js +33 -4
- package/lib/constants.js +52 -2
- package/lib/metrics.js +16 -3
- package/lib/permissions.js +73 -73
- package/lib/prompts.js +90 -86
- package/lib/tool_specs.js +499 -0
- package/lib/tools.js +418 -198
- package/lib/ui/ansi.js +13 -1
- package/lib/ui/chat-history.js +212 -61
- package/lib/ui/create-ui.js +145 -377
- package/lib/ui/diff.js +91 -78
- package/lib/ui/format.js +247 -0
- package/lib/ui/input-field.js +200 -107
- 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 +179 -42
- package/lib/ui/stream.js +8 -12
- package/lib/ui/terminal.js +60 -0
- package/lib/ui/theme.js +99 -0
- package/lib/ui/utils.js +135 -6
- package/lib/ui/writer.js +603 -0
- package/lib/ui.js +11 -6
- package/package.json +1 -1
- package/lib/ui/legacy.js +0 -130
package/lib/ui/status-bar.js
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { RST, DIM, FG_RED, SPINNER_DEFS } = require('./ansi');
|
|
4
|
-
const {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
45
|
+
resume() { this._paused = false; this._notify(); }
|
|
25
46
|
|
|
26
47
|
setModel(name) {
|
|
27
48
|
this._model = name || '';
|
|
28
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
82
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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;
|
|
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) {
|
|
@@ -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 };
|
package/lib/ui/theme.js
ADDED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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 };
|