@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/diff.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { FG_DARK, FG_RED, FG_GREEN, FG_GRAY, FG_YELLOW, FG_TEAL, RST, THEME } = require('./ansi');
|
|
4
|
-
const { getCols, stripAnsi } = require('./utils');
|
|
3
|
+
const { FG_DARK, FG_RED, FG_GREEN, FG_GRAY, FG_YELLOW, FG_TEAL, RST, THEME, EL, hasTruecolor } = require('./ansi');
|
|
4
|
+
const { getCols, stripAnsi, termWidth } = require('./utils');
|
|
5
|
+
const { DIFF_THEME, UI_THEME } = require('./theme');
|
|
6
|
+
const writer = require('./writer');
|
|
5
7
|
|
|
6
8
|
function diffLines(oldLines, newLines) {
|
|
7
9
|
const m = oldLines.length, n = newLines.length;
|
|
@@ -37,29 +39,47 @@ function diffLinesHashed(oldLines, newLines) {
|
|
|
37
39
|
return result;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
// Truncate `text` to at most `w` terminal columns. When truncation happens,
|
|
43
|
+
// the last visible column is replaced with '…'. Width is measured in display
|
|
44
|
+
// columns (termWidth), so emoji / CJK consume 2 per char.
|
|
45
|
+
function _truncateByWidth(text, w) {
|
|
46
|
+
if (w <= 0) return '';
|
|
47
|
+
if (termWidth(text) <= w) return text;
|
|
48
|
+
let out = '', used = 0;
|
|
49
|
+
for (const ch of text) {
|
|
50
|
+
const chw = termWidth(ch);
|
|
51
|
+
if (used + chw > w - 1) break;
|
|
52
|
+
out += ch;
|
|
53
|
+
used += chw;
|
|
54
|
+
}
|
|
55
|
+
return out + '…';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderDiff(oldText, newText, filePath, opts) {
|
|
41
59
|
if (oldText.includes('\x00') || newText.includes('\x00')) return '[binary file — diff skipped]';
|
|
42
60
|
const isTTY = process.stdout.isTTY === true;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// `inset` reserves N columns for an outer wrapper (e.g. the permission
|
|
62
|
+
// bubble indents continuation lines by 5 spaces). Subtract it from the
|
|
63
|
+
// effective width so our truncated content still fits on one physical row.
|
|
64
|
+
const inset = (opts && Number.isInteger(opts.inset) && opts.inset >= 0) ? opts.inset : 0;
|
|
65
|
+
const cols = Math.max(20, getCols() - inset);
|
|
66
|
+
|
|
67
|
+
// Resolve the palette once per render. Truecolor is used when the terminal
|
|
68
|
+
// advertises it via COLORTERM; otherwise fall back to 256-color.
|
|
69
|
+
const useTC = hasTruecolor();
|
|
70
|
+
const a = (code) => isTTY ? code : '';
|
|
71
|
+
const R = a(RST);
|
|
72
|
+
const EL_ = a(EL);
|
|
73
|
+
const pickBg = (t) => useTC && t.bgTC ? t.bgTC : t.bg256;
|
|
74
|
+
const P = {
|
|
75
|
+
added: { bg: a(pickBg(DIFF_THEME.added)), signFg: a(DIFF_THEME.added.signFg), sign: DIFF_THEME.added.sign },
|
|
76
|
+
removed: { bg: a(pickBg(DIFF_THEME.removed)), signFg: a(DIFF_THEME.removed.signFg), sign: DIFF_THEME.removed.sign },
|
|
77
|
+
context: { bg: '', signFg: '', sign: DIFF_THEME.context.sign },
|
|
78
|
+
ln: a(DIFF_THEME.lineNumber),
|
|
79
|
+
code: a(DIFF_THEME.code),
|
|
80
|
+
hdr: a(DIFF_THEME.header),
|
|
81
|
+
frame: a(DIFF_THEME.frame),
|
|
82
|
+
};
|
|
63
83
|
|
|
64
84
|
const oldLines = oldText === '' ? [] : oldText.split('\n');
|
|
65
85
|
const newLines = newText.split('\n');
|
|
@@ -69,58 +89,58 @@ function renderDiff(oldText, newText, filePath) {
|
|
|
69
89
|
if (!isNewFile) {
|
|
70
90
|
diff = (oldLines.length > 200 || newLines.length > 200)
|
|
71
91
|
? diffLinesHashed(oldLines, newLines) : diffLines(oldLines, newLines);
|
|
72
|
-
if (!diff.some((d) => d.type !== 'same')) return
|
|
92
|
+
if (!diff.some((d) => d.type !== 'same')) return ' No changes detected';
|
|
73
93
|
}
|
|
74
94
|
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
// ── Layout ─────────────────────────────────────────────────────────────────
|
|
96
|
+
// [GUTTER(3) default bg] [LN_W line#] [' '] [sign] [' '] [code] [EL] [RST]
|
|
97
|
+
// The gutter stays in the terminal's default bg so the dark bg on the
|
|
98
|
+
// change lines has a visible left edge — no box frame needed.
|
|
99
|
+
const GUTTER = ' ';
|
|
100
|
+
const maxLn = Math.max(oldLines.length, newLines.length, 1);
|
|
101
|
+
const LN_W = Math.max(2, String(maxLn).length);
|
|
102
|
+
const PREFIX_W = GUTTER.length + LN_W + 3; // gutter + ln + ' ' + sign + ' '
|
|
103
|
+
const CODE_W = Math.max(1, cols - PREFIX_W);
|
|
104
|
+
|
|
105
|
+
// Render a single diff line. For added/removed, the dark bg begins right
|
|
106
|
+
// after the gutter and extends to the physical right edge via EL. Context
|
|
107
|
+
// lines set no bg so EL is skipped too (no-op but cleaner output).
|
|
108
|
+
function makeLine(lnStr, kind, text) {
|
|
109
|
+
const t = P[kind];
|
|
110
|
+
const num = (lnStr || '').padStart(LN_W);
|
|
111
|
+
const disp = (text || '').replace(/\t/g, ' ');
|
|
112
|
+
const code = _truncateByWidth(disp, CODE_W);
|
|
113
|
+
|
|
114
|
+
let s = GUTTER;
|
|
115
|
+
s += t.bg;
|
|
116
|
+
s += P.ln + num;
|
|
117
|
+
s += ' ';
|
|
118
|
+
if (t.signFg) s += t.signFg;
|
|
119
|
+
s += t.sign;
|
|
120
|
+
s += P.code;
|
|
121
|
+
s += ' ';
|
|
122
|
+
s += code;
|
|
123
|
+
if (t.bg) s += EL_;
|
|
124
|
+
s += R;
|
|
125
|
+
return s;
|
|
99
126
|
}
|
|
100
127
|
|
|
101
|
-
// Hunk separator: ···· @@ -x,y +a,b @@ ····
|
|
102
128
|
function hunkSep(label) {
|
|
103
|
-
|
|
104
|
-
const total = innerWidth - 2; // 2 for leading/trailing ·
|
|
105
|
-
const half = Math.max(0, Math.floor((total - body.length) / 2));
|
|
106
|
-
const right = Math.max(0, total - body.length - half);
|
|
107
|
-
return `${INDENT}${BORDER}│${R}${HUNK_CLR} ${'·'.repeat(half)}${body}${'·'.repeat(right)} ${R}${BORDER}│${R}`;
|
|
129
|
+
return `${GUTTER}${P.frame}${label}${R}`;
|
|
108
130
|
}
|
|
109
131
|
|
|
110
132
|
const out = [];
|
|
133
|
+
// Minimal file header — bold blue path, no box. The surrounding UI owns
|
|
134
|
+
// any tool-label framing; this line is the only header the diff emits.
|
|
135
|
+
out.push(`${GUTTER}${P.hdr}${filePath}${R}`);
|
|
111
136
|
|
|
112
|
-
// ── Header ─────────────────────────────────────────────────────────────────
|
|
113
|
-
const pathPart = ` ${filePath} `;
|
|
114
|
-
const fillRight = Math.max(1, innerWidth - 1 - pathPart.length); // 1 for leading ─
|
|
115
|
-
out.push(`${INDENT}${BORDER}╭─${PATH_CLR}${pathPart}${R}${BORDER}${'─'.repeat(fillRight)}╮${R}`);
|
|
116
|
-
|
|
117
|
-
// ── Body ───────────────────────────────────────────────────────────────────
|
|
118
137
|
if (isNewFile) {
|
|
119
|
-
|
|
138
|
+
// Meta-label "(new file)" rendered in the shared subtle palette so it
|
|
139
|
+
// recedes relative to the hunk marker itself.
|
|
140
|
+
const subtle = a(UI_THEME.subtle);
|
|
141
|
+
out.push(`${GUTTER}${P.frame}@@ -0,0 +1,${newLines.length} @@ ${R}${subtle}(new file)${R}`);
|
|
120
142
|
let ln = 1;
|
|
121
|
-
for (const line of newLines)
|
|
122
|
-
out.push(makeLine(String(ln++), '+', line, ADD_BG, ADD_FG));
|
|
123
|
-
}
|
|
143
|
+
for (const line of newLines) out.push(makeLine(String(ln++), 'added', line));
|
|
124
144
|
} else {
|
|
125
145
|
let oldLn = 1, newLn = 1;
|
|
126
146
|
const annotated = diff.map((d) => {
|
|
@@ -153,26 +173,19 @@ function renderDiff(oldText, newText, filePath) {
|
|
|
153
173
|
out.push(hunkSep(`@@ -${oldStart},${oldCnt} +${newStart},${newCnt} @@`));
|
|
154
174
|
|
|
155
175
|
for (const e of hunk) {
|
|
156
|
-
if (e.type === 'del')
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
out.push(makeLine(String(e.newLine), '+', e.text, ADD_BG, ADD_FG));
|
|
160
|
-
} else {
|
|
161
|
-
out.push(makeLine(String(e.newLine), ' ', e.text, '', CTX_CLR));
|
|
162
|
-
}
|
|
176
|
+
if (e.type === 'del') out.push(makeLine(String(e.oldLine), 'removed', e.text));
|
|
177
|
+
else if (e.type === 'add') out.push(makeLine(String(e.newLine), 'added', e.text));
|
|
178
|
+
else out.push(makeLine(String(e.newLine), 'context', e.text));
|
|
163
179
|
}
|
|
164
180
|
}
|
|
165
181
|
}
|
|
166
182
|
|
|
167
|
-
// ── Footer ─────────────────────────────────────────────────────────────────
|
|
168
|
-
out.push(`${INDENT}${BORDER}╰${'─'.repeat(innerWidth)}╯${R}`);
|
|
169
|
-
|
|
170
183
|
const MAX = 120;
|
|
171
184
|
if (out.length > MAX) {
|
|
172
185
|
const extra = out.length - MAX;
|
|
173
186
|
const result = out.slice(0, MAX);
|
|
174
187
|
result.push(isTTY
|
|
175
|
-
? `${
|
|
188
|
+
? `${GUTTER}${P.frame}… ${extra} more lines${R}`
|
|
176
189
|
: `… ${extra} more lines`);
|
|
177
190
|
return result.join('\n');
|
|
178
191
|
}
|
|
@@ -194,7 +207,7 @@ function _mdInline(text) {
|
|
|
194
207
|
}
|
|
195
208
|
|
|
196
209
|
function renderMarkdown(text) {
|
|
197
|
-
if (!process.stdout.isTTY) {
|
|
210
|
+
if (!process.stdout.isTTY) { writer.scrollback(text); return; }
|
|
198
211
|
const { loadConfig } = require('../config');
|
|
199
212
|
const maxLines = (loadConfig().max_output_lines) || 50;
|
|
200
213
|
const cols = getCols();
|
|
@@ -236,8 +249,8 @@ function renderMarkdown(text) {
|
|
|
236
249
|
}
|
|
237
250
|
let overflow = 0, printLines = output;
|
|
238
251
|
if (output.length > maxLines) { overflow = output.length - maxLines; printLines = output.slice(0, maxLines); }
|
|
239
|
-
|
|
240
|
-
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);
|
|
241
254
|
}
|
|
242
255
|
|
|
243
256
|
module.exports = { renderDiff, renderMarkdown, _mdInline };
|
package/lib/ui/format.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
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
|
+
function _truncate(text, max) {
|
|
99
|
+
if (text == null) return '';
|
|
100
|
+
const s = String(text);
|
|
101
|
+
if (s.length <= max) return s;
|
|
102
|
+
return s.slice(0, Math.max(0, max - 1)) + '…';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Verb + target string. Never more than ~80 chars visible — longer URLs,
|
|
106
|
+
// commands, paths are head-tail truncated so the line stays on one row
|
|
107
|
+
// even in 120-col terminals with metadata appended.
|
|
108
|
+
function _operation(tag, arg, attrs) {
|
|
109
|
+
const a = attrs || {};
|
|
110
|
+
const max = 80;
|
|
111
|
+
const pathOrArg = (k) => a[k] || arg || '';
|
|
112
|
+
switch (_normalizeTag(tag)) {
|
|
113
|
+
case 'http_get': return _truncate(`GET ${a.url || arg || ''}`, max);
|
|
114
|
+
case 'download': return _truncate(`download ${a.url || arg || ''}`, max);
|
|
115
|
+
case 'upload': return _truncate(`upload ${a.path || arg || ''}`, max);
|
|
116
|
+
case 'shell': return _truncate(a.command || arg || '', max);
|
|
117
|
+
case 'read_file': return _truncate(`read ${pathOrArg('path')}`, max);
|
|
118
|
+
case 'write_file': return _truncate(`write ${pathOrArg('path')}`, max);
|
|
119
|
+
case 'create_file': return _truncate(`create ${pathOrArg('path')}`, max);
|
|
120
|
+
case 'append_file': return _truncate(`append ${pathOrArg('path')}`, max);
|
|
121
|
+
case 'delete_file': return _truncate(`delete ${pathOrArg('path')}`, max);
|
|
122
|
+
case 'list_dir': return _truncate(`list ${pathOrArg('path') || '.'}`, max);
|
|
123
|
+
case 'make_dir': return _truncate(`mkdir ${pathOrArg('path')}`, max);
|
|
124
|
+
case 'remove_dir': return _truncate(`rmdir ${pathOrArg('path')}`, max);
|
|
125
|
+
case 'move_file': return _truncate(`move ${a.src || ''} → ${a.dst || ''}`, max);
|
|
126
|
+
case 'copy_file': return _truncate(`copy ${a.src || ''} → ${a.dst || ''}`, max);
|
|
127
|
+
case 'edit_file': return _truncate(`edit ${a.path || ''}:${a.line || ''}`, max);
|
|
128
|
+
case 'search_files': return _truncate(`search ${a.pattern || arg || ''}${a.dir && a.dir !== '.' ? ` in ${a.dir}` : ''}`, max);
|
|
129
|
+
case 'search_in_file': return _truncate(`search-in ${a.path || arg || ''}`, max);
|
|
130
|
+
case 'replace_in_file': return _truncate(`replace ${a.path || arg || ''}`, max);
|
|
131
|
+
case 'file_stat': return _truncate(`stat ${pathOrArg('path')}`, max);
|
|
132
|
+
case 'get_env': return _truncate(`get $${a.name || arg || ''}`, max);
|
|
133
|
+
case 'set_env': return _truncate(`set $${a.name || ''}`, max);
|
|
134
|
+
case 'ask_user': return _truncate(`ask ${a.question || arg || ''}`, max);
|
|
135
|
+
case 'store_memory': return _truncate(`store ${a.key || ''}`, max);
|
|
136
|
+
case 'recall_memory': return _truncate(`recall ${a.key || arg || ''}`, max);
|
|
137
|
+
case 'list_memories': return 'list memories';
|
|
138
|
+
case 'system_info': return 'system info';
|
|
139
|
+
default: return _truncate(arg ? `${tag} ${arg}` : tag, max);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Type-specific meta tail. Split into sub-parts so the 4-segment joiner
|
|
144
|
+
// renders e.g. "200 · 42 KB" with its own " · " between them. Returns an
|
|
145
|
+
// array of strings; the caller joins them with " · ".
|
|
146
|
+
function _metaParts(tag, meta, error) {
|
|
147
|
+
const out = [];
|
|
148
|
+
const normalized = _normalizeTag(tag);
|
|
149
|
+
if (error && (normalized === 'http_get' || normalized === 'download')) {
|
|
150
|
+
out.push(formatHttpErrorTag(error));
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
switch (normalized) {
|
|
154
|
+
case 'http_get':
|
|
155
|
+
case 'download':
|
|
156
|
+
if (meta && typeof meta.status_code === 'number') out.push(String(meta.status_code));
|
|
157
|
+
if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
|
|
158
|
+
break;
|
|
159
|
+
case 'shell':
|
|
160
|
+
if (meta && typeof meta.exit_code === 'number') out.push(`exit ${meta.exit_code}`);
|
|
161
|
+
break;
|
|
162
|
+
case 'read_file':
|
|
163
|
+
case 'write_file':
|
|
164
|
+
case 'create_file':
|
|
165
|
+
case 'append_file':
|
|
166
|
+
case 'upload':
|
|
167
|
+
if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
|
|
168
|
+
break;
|
|
169
|
+
case 'search_files':
|
|
170
|
+
case 'search_in_file':
|
|
171
|
+
if (meta && typeof meta.count === 'number') {
|
|
172
|
+
out.push(`${meta.count} ${meta.count === 1 ? 'match' : 'matches'}`);
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
case 'replace_in_file':
|
|
176
|
+
if (meta && typeof meta.count === 'number') out.push(`${meta.count} replaced`);
|
|
177
|
+
break;
|
|
178
|
+
case 'list_dir':
|
|
179
|
+
if (meta && typeof meta.count === 'number') out.push(`${meta.count} ${meta.count === 1 ? 'entry' : 'entries'}`);
|
|
180
|
+
break;
|
|
181
|
+
case 'file_stat':
|
|
182
|
+
if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
|
|
183
|
+
if (meta && meta.kind) out.push(meta.kind);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Build the full styled 4-segment tool line. `status` is one of
|
|
190
|
+
// 'pending' | 'success' | 'failure'. For 'failure', caller may pass an
|
|
191
|
+
// Error-shaped `error` and/or partial `meta` — we format both when
|
|
192
|
+
// present (e.g. a failed HTTP fetch that still yielded a status code).
|
|
193
|
+
function formatToolLine(args) {
|
|
194
|
+
const {
|
|
195
|
+
status,
|
|
196
|
+
tag,
|
|
197
|
+
arg,
|
|
198
|
+
attrs,
|
|
199
|
+
durationMs,
|
|
200
|
+
meta,
|
|
201
|
+
error,
|
|
202
|
+
} = args || {};
|
|
203
|
+
|
|
204
|
+
let glyph;
|
|
205
|
+
let glyphColor;
|
|
206
|
+
if (status === 'pending') {
|
|
207
|
+
glyph = UI_ICONS.pending;
|
|
208
|
+
glyphColor = UI_THEME.muted;
|
|
209
|
+
} else if (status === 'failure' || error) {
|
|
210
|
+
glyph = UI_ICONS.error;
|
|
211
|
+
glyphColor = UI_THEME.error;
|
|
212
|
+
} else {
|
|
213
|
+
glyph = UI_ICONS.success;
|
|
214
|
+
glyphColor = UI_THEME.success;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const cat = _categoryLabel(tag);
|
|
218
|
+
const catColor = (UI_THEME.categories && UI_THEME.categories[_normalizeTag(tag) && (TOOL_CATEGORIES[_normalizeTag(tag)] || 'tool')]) || UI_THEME.accent;
|
|
219
|
+
|
|
220
|
+
const op = _operation(tag, arg, attrs);
|
|
221
|
+
const durStr = formatDuration(durationMs || 0) + (status === 'pending' ? '…' : '');
|
|
222
|
+
const metaParts = _metaParts(tag, meta, error);
|
|
223
|
+
|
|
224
|
+
// Segment-by-segment styling. Each fragment carries its own ANSI codes
|
|
225
|
+
// so the " · " separator — neutral DIM — can sit between them without
|
|
226
|
+
// leaking the surrounding color into the separator itself.
|
|
227
|
+
const sep = ` ${DIM}·${RST} `;
|
|
228
|
+
|
|
229
|
+
const durColor = status === 'failure' ? UI_THEME.error : UI_THEME.muted;
|
|
230
|
+
const metaColor = status === 'failure' ? UI_THEME.error : UI_THEME.subtle;
|
|
231
|
+
|
|
232
|
+
const segments = [];
|
|
233
|
+
segments.push(` ${glyphColor}${glyph}${RST} ${catColor}${cat}${RST}`);
|
|
234
|
+
segments.push(`${UI_THEME.default}${op}${RST}`);
|
|
235
|
+
segments.push(`${durColor}${durStr}${RST}`);
|
|
236
|
+
for (const m of metaParts) {
|
|
237
|
+
if (m) segments.push(`${metaColor}${m}${RST}`);
|
|
238
|
+
}
|
|
239
|
+
return segments.join(sep);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = {
|
|
243
|
+
formatDuration,
|
|
244
|
+
formatBytes,
|
|
245
|
+
formatHttpErrorTag,
|
|
246
|
+
formatToolLine,
|
|
247
|
+
};
|