@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/chat-history.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { RST, DIM, BOLD, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_DARK, FG_GRAY } = require('./ansi');
|
|
4
4
|
const { getCols, stripAnsi } = require('./utils');
|
|
5
5
|
const { UI_THEME, UI_ICONS, TOOL_CATEGORIES } = require('./theme');
|
|
6
|
+
const { summarizeToolResult } = require('./format');
|
|
6
7
|
const writer = require('./writer');
|
|
7
8
|
|
|
8
9
|
|
|
@@ -142,14 +143,41 @@ class ChatHistory {
|
|
|
142
143
|
}
|
|
143
144
|
out += '\n';
|
|
144
145
|
} else if (msg.role === 'tool') {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
146
|
+
// Tool summary header. The writer's activity region now commits the
|
|
147
|
+
// primary "glyph · category · op · duration · meta" line directly to
|
|
148
|
+
// scrollback via endActivity — when a caller supplies an empty
|
|
149
|
+
// `content` they're signalling that the header is already present
|
|
150
|
+
// and only the expandable output body should render here.
|
|
151
|
+
//
|
|
152
|
+
// History-loaded tool messages arrive with the raw stored payload as
|
|
153
|
+
// `content` and no `output` — collapse them through summarizeToolResult
|
|
154
|
+
// to a single live-activity-style line. Callers that pass `output`
|
|
155
|
+
// (debug blocks, live-activity error pass-through) keep the legacy
|
|
156
|
+
// header chrome.
|
|
157
|
+
if (msg.content && !msg.output) {
|
|
158
|
+
const summary = safeContent(summarizeToolResult(msg.content));
|
|
159
|
+
if (summary) {
|
|
160
|
+
const indicator = msg.isError
|
|
161
|
+
? `${UI_THEME.error}${UI_ICONS.error}${RST}`
|
|
162
|
+
: `${UI_THEME.success}${UI_ICONS.success}${RST}`;
|
|
163
|
+
const sep = ` ${DIM}·${RST} `;
|
|
164
|
+
const styled = summary.split(' · ').map((p) => _dimPaths(p)).join(sep);
|
|
165
|
+
out = ` ${indicator} ${styled}\n`;
|
|
166
|
+
lineCount = 1;
|
|
167
|
+
}
|
|
168
|
+
} else if (content) {
|
|
169
|
+
const indicator = msg.isError
|
|
170
|
+
? `${UI_THEME.error}${UI_ICONS.error}${RST}`
|
|
171
|
+
: `${UI_THEME.success}${UI_ICONS.success}${RST}`;
|
|
172
|
+
const category = TOOL_CATEGORIES[msg.tag] || msg.tag || 'tool';
|
|
173
|
+
const head = `${UI_THEME.accent}${category}${UI_THEME.muted}:${RST}`;
|
|
174
|
+
const desc = _dimPaths(content);
|
|
175
|
+
out = ` ${indicator} ${head} ${desc}\n`;
|
|
176
|
+
lineCount = 1;
|
|
177
|
+
} else {
|
|
178
|
+
out = '';
|
|
179
|
+
lineCount = 0;
|
|
180
|
+
}
|
|
153
181
|
if (msg.output) {
|
|
154
182
|
const wrapAt = Math.max(60, getCols() - 8);
|
|
155
183
|
const outLines = [];
|
|
@@ -309,6 +337,7 @@ class ChatHistory {
|
|
|
309
337
|
// the clear can't land inside a pending scrollback burst, then redraw
|
|
310
338
|
// the live region under the new cursor.
|
|
311
339
|
writer.enqueue(() => {
|
|
340
|
+
// audit: allowed — viewport clear inside writer.enqueue (sanctioned escape hatch).
|
|
312
341
|
try { process.stdout.write('\x1b[3J\x1b[2J\x1b[H'); } catch {}
|
|
313
342
|
});
|
|
314
343
|
writer.redrawLive();
|
package/lib/ui/create-ui.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const readline = require('readline');
|
|
4
4
|
|
|
5
|
-
const { RST,
|
|
5
|
+
const { RST, FG_YELLOW, FG_CYAN, FG_GRAY } = require('./ansi');
|
|
6
6
|
const { ChatHistory } = require('./chat-history');
|
|
7
7
|
const writer = require('./writer');
|
|
8
8
|
const { registerTerminalCleanup } = require('./terminal');
|
|
@@ -10,9 +10,11 @@ const { registerTerminalCleanup } = require('./terminal');
|
|
|
10
10
|
function _createNoOpUI() {
|
|
11
11
|
const chatHistory = {
|
|
12
12
|
addMessage: (msg) => {
|
|
13
|
+
// audit: allowed — non-TUI no-op UI fallback, no live region to protect.
|
|
13
14
|
if (msg.role === 'assistant' || msg.role === 'user')
|
|
14
15
|
process.stdout.write(`[${msg.role}] ${msg.content || ''}\n`);
|
|
15
16
|
},
|
|
17
|
+
// audit: allowed — non-TUI no-op UI fallback, no live region to protect.
|
|
16
18
|
streamToken: (token) => process.stdout.write(token),
|
|
17
19
|
clearStreamingContent: () => {},
|
|
18
20
|
finalizeLastMessage: () => {},
|
|
@@ -25,8 +27,9 @@ function _createNoOpUI() {
|
|
|
25
27
|
};
|
|
26
28
|
const statusBar = {
|
|
27
29
|
update: () => {}, updateMetrics: () => {}, onToken: () => {},
|
|
28
|
-
drawSeparator: () => {},
|
|
29
|
-
setModel: () => {},
|
|
30
|
+
drawSeparator: () => {}, _renderBar: () => {},
|
|
31
|
+
setModel: () => {}, setContextLimit: () => {}, setReportedContext: () => {},
|
|
32
|
+
addPendingTokens: () => {}, destroy: () => {},
|
|
30
33
|
};
|
|
31
34
|
let _submitCb = null;
|
|
32
35
|
const inputField = {
|
|
@@ -59,7 +62,7 @@ function createUI(opts) {
|
|
|
59
62
|
const { LayoutManager } = require('./layout');
|
|
60
63
|
const { FullStatusBar } = require('./status-bar');
|
|
61
64
|
const { InputField } = require('./input-field');
|
|
62
|
-
const { interactiveSelect } = require('./
|
|
65
|
+
const { interactiveSelect } = require('./select');
|
|
63
66
|
|
|
64
67
|
const layout = new LayoutManager();
|
|
65
68
|
const chatHistory = new ChatHistory();
|
|
@@ -93,24 +96,38 @@ function createUI(opts) {
|
|
|
93
96
|
// chrome has a visible top edge.
|
|
94
97
|
if (_sb) lines.push(_sb.renderSeparator());
|
|
95
98
|
|
|
96
|
-
// Status bar.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
}
|
|
99
|
+
// Status bar. ALWAYS pushed — the row is a permanent fixture of the
|
|
100
|
+
// live region so the input and hint rows below it keep a stable
|
|
101
|
+
// vertical position. `renderLine()` is contractually non-null; missing
|
|
102
|
+
// data (model, tokens) renders as a short placeholder inside the row.
|
|
103
|
+
if (_sb) lines.push(_sb.renderLine());
|
|
103
104
|
|
|
104
105
|
// Input row(s).
|
|
106
|
+
let caret = null;
|
|
105
107
|
if (_inputField) {
|
|
106
|
-
|
|
108
|
+
const inputLines = _inputField.renderInputLines();
|
|
109
|
+
for (const row of inputLines) {
|
|
107
110
|
lines.push(row);
|
|
108
111
|
}
|
|
109
112
|
// Hints row.
|
|
110
113
|
lines.push(_inputField.renderHintsLine());
|
|
114
|
+
|
|
115
|
+
// Caret: translate the input-local { line, col } into the live
|
|
116
|
+
// region's "rows up from the cursor's post-draw position". The
|
|
117
|
+
// post-draw cursor lands one row below the final hints row, so:
|
|
118
|
+
// rowsFromBottom = 1 (for hints) + (inputLines.length - caret.line)
|
|
119
|
+
// Leaving caret null keeps the OS cursor hidden — used while input
|
|
120
|
+
// is disabled or a navigation capture is active.
|
|
121
|
+
const inCaret = _inputField.getCaretPosition && _inputField.getCaretPosition();
|
|
122
|
+
if (inCaret && inputLines.length > 0) {
|
|
123
|
+
caret = {
|
|
124
|
+
rowsFromBottom: 1 + (inputLines.length - inCaret.line),
|
|
125
|
+
col: inCaret.col,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
111
128
|
}
|
|
112
129
|
|
|
113
|
-
writer.setLive(lines);
|
|
130
|
+
writer.setLive(lines, caret);
|
|
114
131
|
}
|
|
115
132
|
|
|
116
133
|
_sb = new FullStatusBar(layout, _updateLive);
|
|
@@ -136,31 +153,34 @@ function createUI(opts) {
|
|
|
136
153
|
|
|
137
154
|
// ── captureSelect (modal menu) ───────────────────────────────────────────────
|
|
138
155
|
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
156
|
+
// Numbered-options picker for tools like ask_user. interactiveSelect
|
|
157
|
+
// renders each frame into the writer's modal region (above status,
|
|
158
|
+
// below scrollback) and routes keys through the input field's
|
|
159
|
+
// captureNavigation API — so the menu cohabits with the live region
|
|
160
|
+
// instead of taking over the screen, and Enter/Esc resolve in place.
|
|
142
161
|
inputField.captureSelect = (menu) => new Promise((resolve) => {
|
|
143
162
|
if (!process.stdin.isTTY) {
|
|
144
163
|
resolve(menu.options[0]);
|
|
145
164
|
return;
|
|
146
165
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
166
|
+
interactiveSelect(
|
|
167
|
+
menu.options,
|
|
168
|
+
(opt, isSelected) => isSelected
|
|
169
|
+
? ` ${FG_YELLOW}❯${RST} ${FG_CYAN}${opt}${RST}`
|
|
170
|
+
: ` ${FG_GRAY}${opt}${RST}`,
|
|
171
|
+
{
|
|
172
|
+
initialIndex: 0,
|
|
173
|
+
onExpand: () => chatHistory.toggleLastExpand(),
|
|
174
|
+
captureNavigation: (handler) => {
|
|
175
|
+
inputField.captureNavigation(handler);
|
|
176
|
+
return () => inputField.releaseNavigation();
|
|
157
177
|
},
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
178
|
+
}
|
|
179
|
+
).then((idx) => {
|
|
180
|
+
// Cancel returns null. Match the prior contract: pick the last
|
|
181
|
+
// option (typically "No"/decline) so callers don't need to
|
|
182
|
+
// special-case cancellation.
|
|
183
|
+
resolve(idx === null ? menu.options[menu.options.length - 1] : menu.options[idx]);
|
|
164
184
|
});
|
|
165
185
|
});
|
|
166
186
|
|
|
@@ -181,6 +201,7 @@ function createUI(opts) {
|
|
|
181
201
|
// starts in a known state, then paint the first live frame below a fresh
|
|
182
202
|
// cursor position.
|
|
183
203
|
writer.enqueue(() => {
|
|
204
|
+
// audit: allowed — terminal-mode raw escape inside writer.enqueue (sanctioned escape hatch).
|
|
184
205
|
try { process.stdout.write('\x1b[2J\x1b[3J\x1b[H\x1b[?25l'); } catch {}
|
|
185
206
|
});
|
|
186
207
|
// Pre-render both input and hints so the first _updateLive has valid
|
|
@@ -198,17 +219,21 @@ function createUI(opts) {
|
|
|
198
219
|
// ── Destroy ──────────────────────────────────────────────────────────────────
|
|
199
220
|
// Stop timers + stdin listeners FIRST so no further writes can be queued,
|
|
200
221
|
// then run writer.teardown(). teardown is a single synchronous stdout
|
|
201
|
-
// write that erases the live region
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
222
|
+
// write that erases the live region, emits any end-of-session artifacts
|
|
223
|
+
// passed in (session summary, resume hint, goodbye) as regular scrollback
|
|
224
|
+
// content, and resets terminal state. After it returns, the cursor sits
|
|
225
|
+
// at column 0 of the row immediately below those artifacts — ready for
|
|
226
|
+
// the shell prompt. Callers that want artifacts emitted at exit must
|
|
227
|
+
// pass them here rather than console.log-ing after destroy(); doing it
|
|
228
|
+
// after destroy races with terminal-mode restoration and can leave
|
|
229
|
+
// artifacts overlaid on scrollback with the cursor drifting mid-viewport.
|
|
230
|
+
function destroy(teardownOpts) {
|
|
206
231
|
if (_destroyCalled) return;
|
|
207
232
|
_destroyCalled = true;
|
|
208
233
|
try { inputField.destroy(); } catch {}
|
|
209
234
|
try { sb.destroy(); } catch {}
|
|
210
235
|
try { layout.destroy(); } catch {}
|
|
211
|
-
writer.teardown();
|
|
236
|
+
writer.teardown(teardownOpts);
|
|
212
237
|
}
|
|
213
238
|
|
|
214
239
|
return { chatHistory, statusBar: sb, inputField, layout, destroy, redrawFixed: _updateLive };
|
package/lib/ui/diff.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { FG_DARK, FG_RED, FG_GREEN, FG_GRAY, FG_YELLOW, FG_TEAL, RST, THEME, EL, hasTruecolor } = require('./ansi');
|
|
4
4
|
const { getCols, stripAnsi, termWidth } = require('./utils');
|
|
5
5
|
const { DIFF_THEME, UI_THEME } = require('./theme');
|
|
6
|
+
const writer = require('./writer');
|
|
6
7
|
|
|
7
8
|
function diffLines(oldLines, newLines) {
|
|
8
9
|
const m = oldLines.length, n = newLines.length;
|
|
@@ -206,7 +207,7 @@ function _mdInline(text) {
|
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
function renderMarkdown(text) {
|
|
209
|
-
if (!process.stdout.isTTY) {
|
|
210
|
+
if (!process.stdout.isTTY) { writer.scrollback(text); return; }
|
|
210
211
|
const { loadConfig } = require('../config');
|
|
211
212
|
const maxLines = (loadConfig().max_output_lines) || 50;
|
|
212
213
|
const cols = getCols();
|
|
@@ -248,8 +249,8 @@ function renderMarkdown(text) {
|
|
|
248
249
|
}
|
|
249
250
|
let overflow = 0, printLines = output;
|
|
250
251
|
if (output.length > maxLines) { overflow = output.length - maxLines; printLines = output.slice(0, maxLines); }
|
|
251
|
-
|
|
252
|
-
if (overflow > 0)
|
|
252
|
+
if (printLines.length > 0) writer.scrollback(printLines.join('\n'));
|
|
253
|
+
if (overflow > 0) writer.scrollback(THEME.dim + '[... ' + overflow + ' more lines]' + THEME.reset);
|
|
253
254
|
}
|
|
254
255
|
|
|
255
256
|
module.exports = { renderDiff, renderMarkdown, _mdInline };
|
package/lib/ui/format.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure formatters for the tool-line chrome. Kept zero-dep and side-effect
|
|
4
|
+
// free: every function maps inputs → string, does not touch stdout, and does
|
|
5
|
+
// not read config or state. Consumed by:
|
|
6
|
+
// - commands.js — builds pending + final activity lines
|
|
7
|
+
// - writer.js — nothing (writer is layout-only)
|
|
8
|
+
//
|
|
9
|
+
// Layout contract for a tool line (all four segments are " · "-joined):
|
|
10
|
+
//
|
|
11
|
+
// <glyph> <category> · <operation> · <duration> · <meta>
|
|
12
|
+
//
|
|
13
|
+
// glyph ● pending (muted) / ✓ success / ✗ failure
|
|
14
|
+
// category 5-col padded short tag ("net ", "file ", "shell", …)
|
|
15
|
+
// operation verb + target ("GET https://x", "write /tmp/x", "npm install")
|
|
16
|
+
// duration formatDuration(ms), pending lines trail with "…"
|
|
17
|
+
// meta type-specific tail — exit codes, byte counts, match counts, …
|
|
18
|
+
|
|
19
|
+
const { RST, DIM } = require('./ansi');
|
|
20
|
+
const { UI_THEME, UI_ICONS, TOOL_CATEGORIES } = require('./theme');
|
|
21
|
+
|
|
22
|
+
// Adaptive precision. ms < 1s shows "Nms", under a minute shows "N.Ns"
|
|
23
|
+
// (sub-10s) or "Ns", under an hour shows "MmSs", above uses "HhMm". Never
|
|
24
|
+
// returns the empty string for a valid number — callers strip the value
|
|
25
|
+
// when they want no duration rendered.
|
|
26
|
+
function formatDuration(ms) {
|
|
27
|
+
if (ms == null || !Number.isFinite(ms)) return '';
|
|
28
|
+
const v = ms < 0 ? 0 : ms;
|
|
29
|
+
if (v < 1000) return `${Math.round(v)}ms`;
|
|
30
|
+
if (v < 60_000) {
|
|
31
|
+
const s = v / 1000;
|
|
32
|
+
return s < 10 ? `${s.toFixed(1)}s` : `${Math.round(s)}s`;
|
|
33
|
+
}
|
|
34
|
+
if (v < 3_600_000) {
|
|
35
|
+
const totalSec = Math.floor(v / 1000);
|
|
36
|
+
return `${Math.floor(totalSec / 60)}m${totalSec % 60}s`;
|
|
37
|
+
}
|
|
38
|
+
const totalMin = Math.floor(v / 60_000);
|
|
39
|
+
return `${Math.floor(totalMin / 60)}h${totalMin % 60}m`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Human-readable byte count. Single decimal below 10, none above. Scales
|
|
43
|
+
// through KB/MB/GB. Rejects NaN/negative by returning '' so callers can
|
|
44
|
+
// drop the meta fragment entirely.
|
|
45
|
+
function formatBytes(n) {
|
|
46
|
+
if (n == null || !Number.isFinite(n) || n < 0) return '';
|
|
47
|
+
if (n < 1024) return `${Math.round(n)} B`;
|
|
48
|
+
const kb = n / 1024;
|
|
49
|
+
if (kb < 1024) return kb < 10 ? `${kb.toFixed(1)} KB` : `${Math.round(kb)} KB`;
|
|
50
|
+
const mb = kb / 1024;
|
|
51
|
+
if (mb < 1024) return mb < 10 ? `${mb.toFixed(1)} MB` : `${Math.round(mb)} MB`;
|
|
52
|
+
const gb = mb / 1024;
|
|
53
|
+
return gb < 10 ? `${gb.toFixed(1)} GB` : `${Math.round(gb)} GB`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Short tag for HTTP / network errors. Prefers Node's err.code when
|
|
57
|
+
// available (authoritative) and falls back to substring matches against
|
|
58
|
+
// err.message for non-Node-originated errors (our own "Request timeout"
|
|
59
|
+
// sentinel, for example).
|
|
60
|
+
function formatHttpErrorTag(err) {
|
|
61
|
+
if (!err) return 'error';
|
|
62
|
+
const code = err.code || '';
|
|
63
|
+
const msg = String(err.message || err).toLowerCase();
|
|
64
|
+
if (code === 'ETIMEDOUT' || /timed out|timeout/.test(msg)) return 'timeout';
|
|
65
|
+
if (code === 'ECONNREFUSED' || /refused/.test(msg)) return 'refused';
|
|
66
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || /getaddrinfo|dns/.test(msg)) return 'dns';
|
|
67
|
+
if (code === 'ECONNRESET' || /reset by peer|econnreset/.test(msg)) return 'reset';
|
|
68
|
+
if (code === 'CERT_HAS_EXPIRED' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || /certificate|cert/.test(msg)) return 'cert';
|
|
69
|
+
if (code === 'EHOSTUNREACH' || /unreachable/.test(msg)) return 'unreachable';
|
|
70
|
+
return 'error';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The XML-tag extractor and native-function mapper hand the agent loop
|
|
74
|
+
// *action* names (read, write, append) that differ from the *tag* names
|
|
75
|
+
// (read_file, write_file, append_file) indexed by TAG_REGISTRY and
|
|
76
|
+
// TOOL_CATEGORIES. Normalize here so downstream category/label lookups
|
|
77
|
+
// resolve regardless of which code path produced the call tuple.
|
|
78
|
+
const ACTION_TO_TAG = {
|
|
79
|
+
read: 'read_file',
|
|
80
|
+
write: 'write_file',
|
|
81
|
+
append: 'append_file',
|
|
82
|
+
exec: 'shell',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function _normalizeTag(tag) {
|
|
86
|
+
return ACTION_TO_TAG[tag] || tag || 'tool';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 5-column label. Wider than "net"/"file" so concurrent lines align their
|
|
90
|
+
// operation columns; category names of 5 chars ("shell") fit exactly.
|
|
91
|
+
const CATEGORY_WIDTH = 5;
|
|
92
|
+
|
|
93
|
+
function _categoryLabel(tag) {
|
|
94
|
+
const cat = TOOL_CATEGORIES[_normalizeTag(tag)] || 'tool';
|
|
95
|
+
return cat.length >= CATEGORY_WIDTH ? cat.slice(0, CATEGORY_WIDTH) : cat.padEnd(CATEGORY_WIDTH);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Display-only normalizer for tool argument text. Collapses every run of
|
|
99
|
+
// whitespace (including \n from heredocs and `\<NL>` line continuations)
|
|
100
|
+
// to a single space and trims the ends, so the summary line never spills
|
|
101
|
+
// across multiple physical rows. Pure: never mutates the value used for
|
|
102
|
+
// execution or sent to the model.
|
|
103
|
+
function normalizeCmdForDisplay(text) {
|
|
104
|
+
if (text == null) return '';
|
|
105
|
+
return String(text).replace(/\s+/g, ' ').trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Truncate to fit `max` visible chars, normalizing first so a multi-line
|
|
109
|
+
// command becomes a single visual line. When the cut would land mid-word,
|
|
110
|
+
// back up to the nearest space — but only if it doesn't sacrifice more
|
|
111
|
+
// than ~30% of the available width (otherwise prefer the harder cut).
|
|
112
|
+
function _truncate(text, max) {
|
|
113
|
+
const s = normalizeCmdForDisplay(text);
|
|
114
|
+
if (s.length <= max) return s;
|
|
115
|
+
const cap = Math.max(0, max - 1);
|
|
116
|
+
let cut = s.slice(0, cap);
|
|
117
|
+
const lastSpace = cut.lastIndexOf(' ');
|
|
118
|
+
if (lastSpace > cap * 0.7) cut = cut.slice(0, lastSpace);
|
|
119
|
+
return cut + '…';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Verb + target string. Never more than ~80 chars visible — longer URLs,
|
|
123
|
+
// commands, paths are head-tail truncated so the line stays on one row
|
|
124
|
+
// even in 120-col terminals with metadata appended.
|
|
125
|
+
function _operation(tag, arg, attrs) {
|
|
126
|
+
const a = attrs || {};
|
|
127
|
+
const max = 80;
|
|
128
|
+
const pathOrArg = (k) => a[k] || arg || '';
|
|
129
|
+
switch (_normalizeTag(tag)) {
|
|
130
|
+
case 'http_get': return _truncate(`GET ${a.url || arg || ''}`, max);
|
|
131
|
+
case 'download': return _truncate(`download ${a.url || arg || ''}`, max);
|
|
132
|
+
case 'upload': return _truncate(`upload ${a.path || arg || ''}`, max);
|
|
133
|
+
case 'shell': return _truncate(a.command || arg || '', max);
|
|
134
|
+
case 'read_file': return _truncate(`read ${pathOrArg('path')}`, max);
|
|
135
|
+
case 'write_file': return _truncate(`write ${pathOrArg('path')}`, max);
|
|
136
|
+
case 'create_file': return _truncate(`create ${pathOrArg('path')}`, max);
|
|
137
|
+
case 'append_file': return _truncate(`append ${pathOrArg('path')}`, max);
|
|
138
|
+
case 'delete_file': return _truncate(`delete ${pathOrArg('path')}`, max);
|
|
139
|
+
case 'list_dir': return _truncate(`list ${pathOrArg('path') || '.'}`, max);
|
|
140
|
+
case 'make_dir': return _truncate(`mkdir ${pathOrArg('path')}`, max);
|
|
141
|
+
case 'remove_dir': return _truncate(`rmdir ${pathOrArg('path')}`, max);
|
|
142
|
+
case 'move_file': return _truncate(`move ${a.src || ''} → ${a.dst || ''}`, max);
|
|
143
|
+
case 'copy_file': return _truncate(`copy ${a.src || ''} → ${a.dst || ''}`, max);
|
|
144
|
+
case 'edit_file': return _truncate(`edit ${a.path || ''}:${a.line || ''}`, max);
|
|
145
|
+
case 'search_files': return _truncate(`search ${a.pattern || arg || ''}${a.dir && a.dir !== '.' ? ` in ${a.dir}` : ''}`, max);
|
|
146
|
+
case 'search_in_file': return _truncate(`search-in ${a.path || arg || ''}`, max);
|
|
147
|
+
case 'replace_in_file': return _truncate(`replace ${a.path || arg || ''}`, max);
|
|
148
|
+
case 'file_stat': return _truncate(`stat ${pathOrArg('path')}`, max);
|
|
149
|
+
case 'get_env': return _truncate(`get $${a.name || arg || ''}`, max);
|
|
150
|
+
case 'set_env': return _truncate(`set $${a.name || ''}`, max);
|
|
151
|
+
case 'ask_user': return _truncate(`ask ${a.question || arg || ''}`, max);
|
|
152
|
+
case 'store_memory': return _truncate(`store ${a.key || ''}`, max);
|
|
153
|
+
case 'recall_memory': return _truncate(`recall ${a.key || arg || ''}`, max);
|
|
154
|
+
case 'list_memories': return 'list memories';
|
|
155
|
+
case 'system_info': return 'system info';
|
|
156
|
+
default: return _truncate(arg ? `${tag} ${arg}` : tag, max);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Type-specific meta tail. Split into sub-parts so the 4-segment joiner
|
|
161
|
+
// renders e.g. "200 · 42 KB" with its own " · " between them. Returns an
|
|
162
|
+
// array of strings; the caller joins them with " · ".
|
|
163
|
+
function _metaParts(tag, meta, error) {
|
|
164
|
+
const out = [];
|
|
165
|
+
const normalized = _normalizeTag(tag);
|
|
166
|
+
if (error && (normalized === 'http_get' || normalized === 'download')) {
|
|
167
|
+
out.push(formatHttpErrorTag(error));
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
switch (normalized) {
|
|
171
|
+
case 'http_get':
|
|
172
|
+
case 'download':
|
|
173
|
+
if (meta && typeof meta.status_code === 'number') out.push(String(meta.status_code));
|
|
174
|
+
if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
|
|
175
|
+
break;
|
|
176
|
+
case 'shell':
|
|
177
|
+
if (meta && typeof meta.exit_code === 'number') out.push(`exit ${meta.exit_code}`);
|
|
178
|
+
break;
|
|
179
|
+
case 'read_file':
|
|
180
|
+
case 'write_file':
|
|
181
|
+
case 'create_file':
|
|
182
|
+
case 'append_file':
|
|
183
|
+
case 'upload':
|
|
184
|
+
if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
|
|
185
|
+
break;
|
|
186
|
+
case 'search_files':
|
|
187
|
+
case 'search_in_file':
|
|
188
|
+
if (meta && typeof meta.count === 'number') {
|
|
189
|
+
out.push(`${meta.count} ${meta.count === 1 ? 'match' : 'matches'}`);
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
case 'replace_in_file':
|
|
193
|
+
if (meta && typeof meta.count === 'number') out.push(`${meta.count} replaced`);
|
|
194
|
+
break;
|
|
195
|
+
case 'list_dir':
|
|
196
|
+
if (meta && typeof meta.count === 'number') out.push(`${meta.count} ${meta.count === 1 ? 'entry' : 'entries'}`);
|
|
197
|
+
break;
|
|
198
|
+
case 'file_stat':
|
|
199
|
+
if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
|
|
200
|
+
if (meta && meta.kind) out.push(meta.kind);
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Build the full styled 4-segment tool line. `status` is one of
|
|
207
|
+
// 'pending' | 'success' | 'failure'. For 'failure', caller may pass an
|
|
208
|
+
// Error-shaped `error` and/or partial `meta` — we format both when
|
|
209
|
+
// present (e.g. a failed HTTP fetch that still yielded a status code).
|
|
210
|
+
function formatToolLine(args) {
|
|
211
|
+
const {
|
|
212
|
+
status,
|
|
213
|
+
tag,
|
|
214
|
+
arg,
|
|
215
|
+
attrs,
|
|
216
|
+
durationMs,
|
|
217
|
+
meta,
|
|
218
|
+
error,
|
|
219
|
+
noDuration,
|
|
220
|
+
} = args || {};
|
|
221
|
+
|
|
222
|
+
let glyph;
|
|
223
|
+
let glyphColor;
|
|
224
|
+
if (status === 'pending') {
|
|
225
|
+
glyph = UI_ICONS.pending;
|
|
226
|
+
glyphColor = UI_THEME.muted;
|
|
227
|
+
} else if (status === 'failure' || error) {
|
|
228
|
+
glyph = UI_ICONS.error;
|
|
229
|
+
glyphColor = UI_THEME.error;
|
|
230
|
+
} else {
|
|
231
|
+
glyph = UI_ICONS.success;
|
|
232
|
+
glyphColor = UI_THEME.success;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const cat = _categoryLabel(tag);
|
|
236
|
+
const catColor = (UI_THEME.categories && UI_THEME.categories[_normalizeTag(tag) && (TOOL_CATEGORIES[_normalizeTag(tag)] || 'tool')]) || UI_THEME.accent;
|
|
237
|
+
|
|
238
|
+
const op = _operation(tag, arg, attrs);
|
|
239
|
+
const metaParts = _metaParts(tag, meta, error);
|
|
240
|
+
|
|
241
|
+
// Segment-by-segment styling. Each fragment carries its own ANSI codes
|
|
242
|
+
// so the " · " separator — neutral DIM — can sit between them without
|
|
243
|
+
// leaking the surrounding color into the separator itself.
|
|
244
|
+
const sep = ` ${DIM}·${RST} `;
|
|
245
|
+
|
|
246
|
+
const durColor = status === 'failure' ? UI_THEME.error : UI_THEME.muted;
|
|
247
|
+
const metaColor = status === 'failure' ? UI_THEME.error : UI_THEME.subtle;
|
|
248
|
+
|
|
249
|
+
const segments = [];
|
|
250
|
+
segments.push(` ${glyphColor}${glyph}${RST} ${catColor}${cat}${RST}`);
|
|
251
|
+
segments.push(`${UI_THEME.default}${op}${RST}`);
|
|
252
|
+
if (!noDuration) {
|
|
253
|
+
const durStr = formatDuration(durationMs || 0) + (status === 'pending' ? '…' : '');
|
|
254
|
+
segments.push(`${durColor}${durStr}${RST}`);
|
|
255
|
+
}
|
|
256
|
+
for (const m of metaParts) {
|
|
257
|
+
if (m) segments.push(`${metaColor}${m}${RST}`);
|
|
258
|
+
}
|
|
259
|
+
return segments.join(sep);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Collapse a stored tool-result string to a single-line summary in the same
|
|
263
|
+
// shape as the live activity bubble ("net · GET https://x · 200 · 256 KB").
|
|
264
|
+
// Pure: no ANSI, no IO, no allocation beyond the returned string. Idempotent
|
|
265
|
+
// — a value that already looks like a summary is returned untouched so the
|
|
266
|
+
// helper survives double-application once a separate display field lands in
|
|
267
|
+
// storage.
|
|
268
|
+
function summarizeToolResult(content) {
|
|
269
|
+
if (typeof content !== 'string' || !content) return '';
|
|
270
|
+
const trimmed = content.trim();
|
|
271
|
+
if (!trimmed) return '';
|
|
272
|
+
|
|
273
|
+
if (!trimmed.includes('\n') && trimmed.includes(' · ') && trimmed.length < 200) {
|
|
274
|
+
return trimmed;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// HTTP: agent.js formats as `HTTP <VERB> <url> (<status>):\n<body>`.
|
|
278
|
+
const httpMatch = content.match(/^HTTP\s+(\w+)\s+(\S+)\s+\((\d+)\)/);
|
|
279
|
+
if (httpMatch) {
|
|
280
|
+
const parts = ['net', `${httpMatch[1]} ${httpMatch[2]}`, httpMatch[3]];
|
|
281
|
+
const bytes = formatBytes(Buffer.byteLength(content, 'utf8'));
|
|
282
|
+
if (bytes) parts.push(bytes);
|
|
283
|
+
return parts.join(' · ');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Exec: `Command \`<cmd>\`:\nExit code: <N>\n<output>`. The cmd may span
|
|
287
|
+
// multiple lines if the user passed a heredoc — non-greedy capture stops
|
|
288
|
+
// at the first `\`:` boundary, then we collapse whitespace for the preview.
|
|
289
|
+
const cmdMatch = content.match(/^Command `([\s\S]+?)`:/);
|
|
290
|
+
const exitMatch = content.match(/^Exit code: (-?\d+)$/m);
|
|
291
|
+
if (cmdMatch && exitMatch) {
|
|
292
|
+
const cmd = cmdMatch[1].replace(/\s+/g, ' ').trim();
|
|
293
|
+
const preview = cmd.length > 60 ? cmd.slice(0, 59) + '…' : cmd;
|
|
294
|
+
return `exec · ${preview} · exit ${exitMatch[1]}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const lines = content.split('\n');
|
|
298
|
+
if (lines.length <= 3 && /^(Wrote|Read|Created|Deleted|Moved|Renamed)\b/.test(lines[0])) {
|
|
299
|
+
return lines[0];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (lines.length > 1 || content.length > 120) {
|
|
303
|
+
const firstLine = lines[0] || '';
|
|
304
|
+
const preview = firstLine.length > 100 ? firstLine.slice(0, 99) + '…' : firstLine;
|
|
305
|
+
const parts = ['tool', preview];
|
|
306
|
+
const bytes = formatBytes(Buffer.byteLength(content, 'utf8'));
|
|
307
|
+
if (bytes) parts.push(bytes);
|
|
308
|
+
return parts.join(' · ');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return trimmed;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
module.exports = {
|
|
315
|
+
formatDuration,
|
|
316
|
+
formatBytes,
|
|
317
|
+
formatHttpErrorTag,
|
|
318
|
+
formatToolLine,
|
|
319
|
+
summarizeToolResult,
|
|
320
|
+
normalizeCmdForDisplay,
|
|
321
|
+
};
|