@semalt-ai/code 1.19.0 → 1.20.1
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 +2 -1
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -1874
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/index.js +1 -1
- package/lib/agent.js +145 -16
- package/lib/api.js +28 -3
- package/lib/commands/chat-session.js +188 -4
- package/lib/commands/chat-slash.js +16 -0
- package/lib/commands/chat-turn.js +319 -52
- package/lib/commands/chat.js +12 -8
- package/lib/config.js +27 -0
- package/lib/constants.js +30 -1
- package/lib/headless.js +36 -1
- package/lib/images.js +8 -2
- package/lib/permissions.js +23 -16
- package/lib/prompts.js +15 -3
- package/lib/tool_registry.js +357 -53
- package/lib/tool_specs.js +42 -8
- package/lib/tools.js +80 -19
- package/lib/ui/anim.js +86 -0
- package/lib/ui/ansi.js +17 -27
- package/lib/ui/chat-history.js +253 -71
- package/lib/ui/create-ui.js +67 -24
- package/lib/ui/diff.js +90 -25
- package/lib/ui/file-activity.js +229 -0
- package/lib/ui/format.js +173 -28
- package/lib/ui/input-field.js +5 -4
- package/lib/ui/md-stream.js +234 -0
- package/lib/ui/render-operation.js +113 -0
- package/lib/ui/select.js +1 -4
- package/lib/ui/status-bar.js +99 -57
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -45
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +58 -6
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/package.json +1 -1
- package/test/anim-driver.test.js +153 -0
- package/test/ask-user-display.test.js +226 -0
- package/test/ask-user-gate.test.js +231 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +250 -13
- package/test/extract-tool-calls.test.js +37 -3
- package/test/file-activity.test.js +542 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/chat-harness.js +2 -1
- package/test/headless.test.js +146 -1
- package/test/input-field-ctrl-o.test.js +37 -0
- package/test/live-height-physical.test.js +281 -0
- package/test/max-iterations.test.js +9 -7
- package/test/md-stream.test.js +183 -0
- package/test/narration-ordering.test.js +309 -0
- package/test/native-dispatch.test.js +53 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/permission-flush.test.js +302 -0
- package/test/permissions.test.js +199 -0
- package/test/read-paginate.test.js +1 -1
- package/test/render-operation.test.js +317 -0
- package/test/replay-descriptor-xml.test.js +216 -0
- package/test/replay-descriptor.test.js +189 -0
- package/test/replay-web-aggregate.test.js +291 -0
- package/test/replay-web-persist.test.js +241 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +24 -0
- package/test/theme-palette.test.js +166 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +12 -3
- package/path +0 -1
package/lib/ui/diff.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const { RST, EL, hasTruecolor } = require('./ansi');
|
|
4
4
|
const { getCols, stripAnsi, termWidth } = require('./utils');
|
|
5
|
-
const { DIFF_THEME, UI_THEME } = require('./theme');
|
|
5
|
+
const { DIFF_THEME, UI_THEME, THEME, FG_CODE_LANG, colorEnabled } = require('./theme');
|
|
6
6
|
const writer = require('./writer');
|
|
7
7
|
|
|
8
8
|
function diffLines(oldLines, newLines) {
|
|
@@ -67,7 +67,9 @@ function renderDiff(oldText, newText, filePath, opts) {
|
|
|
67
67
|
// Resolve the palette once per render. Truecolor is used when the terminal
|
|
68
68
|
// advertises it via COLORTERM; otherwise fall back to 256-color.
|
|
69
69
|
const useTC = hasTruecolor();
|
|
70
|
-
|
|
70
|
+
// Strip colour for non-TTY AND NO_COLOR (colorEnabled folds in both); the
|
|
71
|
+
// diff body then renders as plain text with no ANSI leaking into piped output.
|
|
72
|
+
const a = (code) => colorEnabled() ? code : '';
|
|
71
73
|
const R = a(RST);
|
|
72
74
|
const EL_ = a(EL);
|
|
73
75
|
const pickBg = (t) => useTC && t.bgTC ? t.bgTC : t.bg256;
|
|
@@ -77,7 +79,7 @@ function renderDiff(oldText, newText, filePath, opts) {
|
|
|
77
79
|
context: { bg: '', signFg: '', sign: DIFF_THEME.context.sign },
|
|
78
80
|
ln: a(DIFF_THEME.lineNumber),
|
|
79
81
|
code: a(DIFF_THEME.code),
|
|
80
|
-
hdr
|
|
82
|
+
// (P.hdr removed in Phase 2 D3 — the diff no longer emits a path header.)
|
|
81
83
|
frame: a(DIFF_THEME.frame),
|
|
82
84
|
};
|
|
83
85
|
|
|
@@ -85,7 +87,7 @@ function renderDiff(oldText, newText, filePath, opts) {
|
|
|
85
87
|
const newLines = newText.split('\n');
|
|
86
88
|
const isNewFile = oldText === '';
|
|
87
89
|
|
|
88
|
-
let diff;
|
|
90
|
+
let diff = null;
|
|
89
91
|
if (!isNewFile) {
|
|
90
92
|
diff = (oldLines.length > 200 || newLines.length > 200)
|
|
91
93
|
? diffLinesHashed(oldLines, newLines) : diffLines(oldLines, newLines);
|
|
@@ -129,27 +131,67 @@ function renderDiff(oldText, newText, filePath, opts) {
|
|
|
129
131
|
return `${GUTTER}${P.frame}${label}${R}`;
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
// Flatten the change into per-line entries with line numbers resolved. Both
|
|
135
|
+
// the full hunk renderer and the capped head+tail renderer consume this. A
|
|
136
|
+
// new file is every-line-added; an existing file annotates the LCS diff.
|
|
137
|
+
let annotated;
|
|
137
138
|
if (isNewFile) {
|
|
138
|
-
|
|
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}`);
|
|
142
|
-
let ln = 1;
|
|
143
|
-
for (const line of newLines) out.push(makeLine(String(ln++), 'added', line));
|
|
139
|
+
annotated = newLines.map((text, i) => ({ type: 'add', text, oldLine: null, newLine: i + 1 }));
|
|
144
140
|
} else {
|
|
145
141
|
let oldLn = 1, newLn = 1;
|
|
146
|
-
|
|
142
|
+
annotated = diff.map((d) => {
|
|
147
143
|
const e = { type: d.type, text: d.text, oldLine: null, newLine: null };
|
|
148
144
|
if (d.type !== 'add') e.oldLine = oldLn++;
|
|
149
145
|
if (d.type !== 'del') e.newLine = newLn++;
|
|
150
146
|
return e;
|
|
151
147
|
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const renderEntry = (e) => {
|
|
151
|
+
if (e.type === 'del') return makeLine(String(e.oldLine), 'removed', e.text);
|
|
152
|
+
if (e.type === 'add') return makeLine(String(e.newLine), 'added', e.text);
|
|
153
|
+
return makeLine(String(e.newLine), 'context', e.text);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Capped path (execution-time diffs). When the caller passes a positive
|
|
157
|
+
// `maxLines` and the edit touches MORE changed (+/-) lines than that, show the
|
|
158
|
+
// first DIFF_HEAD_RATIO of the budget and the last (1-ratio), eliding the
|
|
159
|
+
// middle with a `… K more changed lines (N total)` notice — mirroring the W.6
|
|
160
|
+
// shell head+tail discipline. A small edit (or a series of small edits, each
|
|
161
|
+
// short) never trips this: it renders in full via the hunk path below.
|
|
162
|
+
const DIFF_HEAD_RATIO = 0.6;
|
|
163
|
+
const maxLines = (opts && Number.isInteger(opts.maxLines) && opts.maxLines > 0) ? opts.maxLines : 0;
|
|
164
|
+
if (maxLines) {
|
|
165
|
+
const changed = annotated.filter((e) => e.type !== 'same');
|
|
166
|
+
if (changed.length > maxLines) {
|
|
167
|
+
const head = Math.max(1, Math.round(maxLines * DIFF_HEAD_RATIO));
|
|
168
|
+
const tail = Math.max(1, maxLines - head);
|
|
169
|
+
const elided = changed.length - head - tail;
|
|
170
|
+
// D3 (Phase 2): no path header — the result line above already states the
|
|
171
|
+
// path. The body opens directly with the first changed line.
|
|
172
|
+
const capped = [];
|
|
173
|
+
for (let k = 0; k < head; k++) capped.push(renderEntry(changed[k]));
|
|
174
|
+
capped.push(isTTY
|
|
175
|
+
? `${GUTTER}${P.frame}… ${elided} more changed lines (${changed.length} total)${R}`
|
|
176
|
+
: `… ${elided} more changed lines (${changed.length} total)`);
|
|
177
|
+
for (let k = changed.length - tail; k < changed.length; k++) capped.push(renderEntry(changed[k]));
|
|
178
|
+
return capped.join('\n');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
152
181
|
|
|
182
|
+
const out = [];
|
|
183
|
+
// D3 (Phase 2): the diff no longer emits a path header — the result line
|
|
184
|
+
// above already states the path (the descriptor's `target`), so restating it
|
|
185
|
+
// here was pure duplication. The body opens with the @@ hunk ranges (and the
|
|
186
|
+
// "(new file)" marker for a new file), which carry information the path does not.
|
|
187
|
+
|
|
188
|
+
if (isNewFile) {
|
|
189
|
+
// Meta-label "(new file)" rendered in the shared subtle palette so it
|
|
190
|
+
// recedes relative to the hunk marker itself.
|
|
191
|
+
const subtle = a(UI_THEME.subtle);
|
|
192
|
+
out.push(`${GUTTER}${P.frame}@@ -0,0 +1,${newLines.length} @@ ${R}${subtle}(new file)${R}`);
|
|
193
|
+
for (const e of annotated) out.push(makeLine(String(e.newLine), 'added', e.text));
|
|
194
|
+
} else {
|
|
153
195
|
const changedIdx = [];
|
|
154
196
|
annotated.forEach((d, i) => { if (d.type !== 'same') changedIdx.push(i); });
|
|
155
197
|
|
|
@@ -172,11 +214,7 @@ function renderDiff(oldText, newText, filePath, opts) {
|
|
|
172
214
|
const newCnt = hunk.filter((e) => e.newLine !== null).length;
|
|
173
215
|
out.push(hunkSep(`@@ -${oldStart},${oldCnt} +${newStart},${newCnt} @@`));
|
|
174
216
|
|
|
175
|
-
for (const e of hunk)
|
|
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));
|
|
179
|
-
}
|
|
217
|
+
for (const e of hunk) out.push(renderEntry(e));
|
|
180
218
|
}
|
|
181
219
|
}
|
|
182
220
|
|
|
@@ -192,11 +230,38 @@ function renderDiff(oldText, newText, filePath, opts) {
|
|
|
192
230
|
return out.join('\n');
|
|
193
231
|
}
|
|
194
232
|
|
|
233
|
+
// Execution-time file-edit diff. This is the SINGLE rendering site for the full
|
|
234
|
+
// diff of a mutating edit (write/append/edit_file/replace_in_file): the agent
|
|
235
|
+
// loop calls it from onToolEnd after the edit executes, so the diff renders for
|
|
236
|
+
// EVERY edit regardless of approval state (manual-approved, auto-approved) or
|
|
237
|
+
// entry mode (fresh / --resume / /history / /chats). The permission modal no
|
|
238
|
+
// longer carries the full diff, so the user sees it exactly once, here.
|
|
239
|
+
//
|
|
240
|
+
// `payload` carries { before, after, path } captured by the executor; `maxLines`
|
|
241
|
+
// is config.diff_max_lines (the changed-line cap). Returns the rendered diff
|
|
242
|
+
// string, or null when there is nothing to show — a failed edit (`error`), a
|
|
243
|
+
// missing/ malformed payload (e.g. a loaded-history turn, which never carries
|
|
244
|
+
// one, so past turns are NOT replayed), or a no-op edit.
|
|
245
|
+
function buildExecutionDiff(opts) {
|
|
246
|
+
const o = opts || {};
|
|
247
|
+
if (o.error) return null;
|
|
248
|
+
const p = o.diff;
|
|
249
|
+
if (!p || typeof p.before !== 'string' || typeof p.after !== 'string') return null;
|
|
250
|
+
if (p.before === p.after) return null;
|
|
251
|
+
const renderOpts = {
|
|
252
|
+
maxLines: (Number.isInteger(o.maxLines) && o.maxLines > 0) ? o.maxLines : 50,
|
|
253
|
+
};
|
|
254
|
+
if (Number.isInteger(o.inset) && o.inset >= 0) renderOpts.inset = o.inset;
|
|
255
|
+
const rendered = renderDiff(p.before, p.after, p.path || '', renderOpts);
|
|
256
|
+
if (!rendered || rendered === ' No changes detected') return null;
|
|
257
|
+
return rendered;
|
|
258
|
+
}
|
|
259
|
+
|
|
195
260
|
function _mdInline(text) {
|
|
196
261
|
let out = '', i = 0;
|
|
197
262
|
while (i < text.length) {
|
|
198
263
|
const c = text[i], c1 = i + 1 < text.length ? text[i+1] : '';
|
|
199
|
-
if (c === '`') { const end = text.indexOf('`', i+1); if (end !== -1) { out +=
|
|
264
|
+
if (c === '`') { const end = text.indexOf('`', i+1); if (end !== -1) { out += FG_CODE_LANG + text.slice(i+1, end) + '\x1b[39m'; i = end+1; continue; } }
|
|
200
265
|
if (c === '*' && c1 === '*') { const end = text.indexOf('**', i+2); if (end !== -1) { out += '\x1b[1m' + text.slice(i+2, end) + '\x1b[22m'; i = end+2; continue; } }
|
|
201
266
|
if (c === '_' && c1 === '_') { const end = text.indexOf('__', i+2); if (end !== -1) { out += '\x1b[1m' + text.slice(i+2, end) + '\x1b[22m'; i = end+2; continue; } }
|
|
202
267
|
if (c === '*' && c1 !== '*') { let end = -1; for (let j = i+1; j < text.length; j++) { if (text[j] === '*' && (j+1 >= text.length || text[j+1] !== '*')) { end = j; break; } } if (end !== -1) { out += '\x1b[3m' + text.slice(i+1, end) + '\x1b[23m'; i = end+1; continue; } }
|
|
@@ -225,7 +290,7 @@ function renderMarkdown(text) {
|
|
|
225
290
|
if (inCode) {
|
|
226
291
|
const t = line.trim();
|
|
227
292
|
if (t.length === 3 && t[0] === '`' && t[1] === '`' && t[2] === '`') { output.push('└' + '─'.repeat(Math.max(1, cols - 2))); inCode = false; }
|
|
228
|
-
else { output.push('│ ' +
|
|
293
|
+
else { output.push('│ ' + line); }
|
|
229
294
|
continue;
|
|
230
295
|
}
|
|
231
296
|
const trimmed = line.trim();
|
|
@@ -253,4 +318,4 @@ function renderMarkdown(text) {
|
|
|
253
318
|
if (overflow > 0) writer.scrollback(THEME.dim + '[... ' + overflow + ' more lines]' + THEME.reset);
|
|
254
319
|
}
|
|
255
320
|
|
|
256
|
-
module.exports = { renderDiff, renderMarkdown, _mdInline };
|
|
321
|
+
module.exports = { renderDiff, buildExecutionDiff, renderMarkdown, _mdInline, _truncateByWidth };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// File-activity process summary (a SECOND INSTANCE of the web-activity pattern).
|
|
4
|
+
//
|
|
5
|
+
// A warm-up phase of an agent turn often fires a long run of pure file reads
|
|
6
|
+
// (read_file) or directory listings (list_dir) back-to-back. By default each
|
|
7
|
+
// committed its own tool line ("✓ file · read index.html", "✓ file · read
|
|
8
|
+
// battlecity.js", …), flooding scrollback with one row per op. This module
|
|
9
|
+
// collapses a run of CONSECUTIVE same-type file ops into a SINGLE compact
|
|
10
|
+
// process-summary line —
|
|
11
|
+
//
|
|
12
|
+
// ✓ file · explored ×10 (index.html, battlecity.js, …)
|
|
13
|
+
//
|
|
14
|
+
// — exactly the way `web-activity.js` collapses web_search/http_get. It is a
|
|
15
|
+
// parallel, independent instance of `createWebActivityTracker`; the web tracker
|
|
16
|
+
// is untouched.
|
|
17
|
+
//
|
|
18
|
+
// Scope (deliberately narrow — DECISION 3): ONLY `read_file` + `list_dir`. Both
|
|
19
|
+
// are pure reads with no diff and no output preview (their descriptors carry
|
|
20
|
+
// `detail: null`), so grouping them sidesteps the deferred-detail-band ordering.
|
|
21
|
+
// Other file tools keep their own per-op line.
|
|
22
|
+
//
|
|
23
|
+
// Two DIVERGENCES from the web tracker, both required by the append-only
|
|
24
|
+
// scrollback model:
|
|
25
|
+
// • GROUP KEY = a single shared key for BOTH read_file and list_dir, so a
|
|
26
|
+
// mixed read/list exploration phase collapses into ONE summary instead of
|
|
27
|
+
// fragmenting on every read↔list switch. Every group renders the SAME verb
|
|
28
|
+
// ("explored ×N", live "exploring… ×N") regardless of composition. Any OTHER
|
|
29
|
+
// tool still breaks the run. The web tracker has a single key.
|
|
30
|
+
// • THRESHOLD decided at flush time. A group of 1–2 ops commits each op as its
|
|
31
|
+
// own normal result line (byte-identical to today); a group of 3+ commits ONE
|
|
32
|
+
// summary line. The web tracker always collapses. We can't retroactively pull
|
|
33
|
+
// already-committed lines into a group, so ALL commits defer to flush() where
|
|
34
|
+
// the final count is known.
|
|
35
|
+
|
|
36
|
+
const { UI_ICONS, resolveLineColors } = require('./theme');
|
|
37
|
+
const { RST, DIM } = require('./ansi');
|
|
38
|
+
const { getCols, termWidth, stripAnsi } = require('./utils');
|
|
39
|
+
const { truncateLine } = require('./format');
|
|
40
|
+
const { renderOperation } = require('./render-operation');
|
|
41
|
+
const { isWebCore } = require('./web-activity');
|
|
42
|
+
|
|
43
|
+
// Below this many ops in a group, commit individual per-op lines (today's
|
|
44
|
+
// output); at or above it, commit ONE collapsed summary line.
|
|
45
|
+
const GROUP_THRESHOLD = 3;
|
|
46
|
+
|
|
47
|
+
// Native and XML rails BOTH emit `read` as the action for a read_file call
|
|
48
|
+
// (tool_registry: read_file fromParams/_parseReadTag → ['read', …]); list_dir
|
|
49
|
+
// stays `list_dir`. Normalize so the action tag and the registry tag name agree
|
|
50
|
+
// — mirrors format.js / theme.js ACTION_TO_TAG, scoped to the groupable set.
|
|
51
|
+
const ACTION_TO_TAG = { read: 'read_file' };
|
|
52
|
+
|
|
53
|
+
function normalizeFileTag(tag) {
|
|
54
|
+
return ACTION_TO_TAG[tag] || tag || '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The tools collapsed into the file-activity summary (DECISION 3).
|
|
58
|
+
const FILE_GROUP_TAGS = new Set(['read_file', 'list_dir']);
|
|
59
|
+
|
|
60
|
+
function isGroupableFileTag(tag) {
|
|
61
|
+
return FILE_GROUP_TAGS.has(normalizeFileTag(tag));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Group key. read_file and list_dir share ONE key (`file:access`) so consecutive
|
|
65
|
+
// reads and lists accumulate into a SINGLE group regardless of order — they no
|
|
66
|
+
// longer flush each other on a read↔list switch. (Any non-groupable tag keeps a
|
|
67
|
+
// per-tag key, but in practice only groupable tags ever reach this — the caller
|
|
68
|
+
// gates on isGroupable.)
|
|
69
|
+
function fileGroupKey(tag) {
|
|
70
|
+
return isGroupableFileTag(tag) ? 'file:access' : `file:${normalizeFileTag(tag)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Final path segment, for the compact basename list. Trailing slashes (a dir
|
|
74
|
+
// path) are stripped first so `/a/b/` → `b`; `.` and bare names pass through.
|
|
75
|
+
function _basename(p) {
|
|
76
|
+
const s = String(p == null ? '' : p).replace(/[/\\]+$/, '');
|
|
77
|
+
const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'));
|
|
78
|
+
const base = i >= 0 ? s.slice(i + 1) : s;
|
|
79
|
+
return base || s || '.';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Pure: fold a list of file ops (ToolOperation descriptors OR persisted cores —
|
|
83
|
+
// both expose `tag`/`target`) into the fields the summary needs. read_file and
|
|
84
|
+
// list_dir share one group, so a group may be MIXED — but the verb is a SINGLE
|
|
85
|
+
// "explored"/"exploring…" regardless of composition (read-only, list-only, and
|
|
86
|
+
// mixed all read the same). No more homogeneous-vs-mixed branching.
|
|
87
|
+
function fileSummaryState(ops) {
|
|
88
|
+
const list = (ops || []).filter(Boolean);
|
|
89
|
+
return {
|
|
90
|
+
verb: 'explored',
|
|
91
|
+
gerund: 'exploring…',
|
|
92
|
+
count: list.length,
|
|
93
|
+
basenames: list.map((o) => _basename(o.target)),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Styled, chrome-consistent summary line. Mirrors `formatWebSummaryLine`'s
|
|
98
|
+
// "<glyph> <category> · <operation>" layout so the file summary reads as a peer
|
|
99
|
+
// of the other tool lines. The ×N count sits in the FIXED prefix BEFORE the
|
|
100
|
+
// truncatable basename list, so it ALWAYS shows even when the basenames are cut
|
|
101
|
+
// to fit. One physical row (the Phase-4 single-row invariant): the basename list
|
|
102
|
+
// is truncated to the remaining columns at the CURRENT terminal width.
|
|
103
|
+
function formatFileSummaryLine(state, opts) {
|
|
104
|
+
const { pending = false } = opts || {};
|
|
105
|
+
const colors = resolveLineColors('file', pending ? 'pending' : 'success');
|
|
106
|
+
const glyph = pending ? UI_ICONS.pending : UI_ICONS.success;
|
|
107
|
+
const cat = 'file'.padEnd(5);
|
|
108
|
+
const sep = ` ${DIM}·${RST} `;
|
|
109
|
+
|
|
110
|
+
const head = ` ${colors.glyph}${glyph}${RST} ${colors.label}${cat}${RST}`;
|
|
111
|
+
const verb = pending ? state.gerund : state.verb;
|
|
112
|
+
const fixed = `${verb} ×${state.count} (`;
|
|
113
|
+
|
|
114
|
+
// Width budget for the basename list: total columns minus the styled prefix
|
|
115
|
+
// (measured plain), the fixed "verb ×N (" lead, and the trailing ")".
|
|
116
|
+
const cols = getCols();
|
|
117
|
+
const used = termWidth(stripAnsi(head)) + termWidth(stripAnsi(sep)) + termWidth(fixed) + 1;
|
|
118
|
+
const budget = cols - used;
|
|
119
|
+
const joined = state.basenames.join(', ');
|
|
120
|
+
const list = budget > 0 ? truncateLine(joined, budget) : '…';
|
|
121
|
+
|
|
122
|
+
const opSeg = `${colors.op}${fixed}${list})${RST}`;
|
|
123
|
+
return [head, opSeg].join(sep);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Predicate: a persisted display core is a GROUPABLE file-op core (a normal
|
|
127
|
+
// descriptor core for a successful read_file/list_dir). Replay buffers these and
|
|
128
|
+
// re-groups them at the same boundaries the live path flushes. A web core or a
|
|
129
|
+
// non-file / errored / unknown core fails the gate. Tolerant of any input.
|
|
130
|
+
function isGroupableFileCore(core) {
|
|
131
|
+
if (!core || typeof core !== 'object' || core.v !== 1) return false;
|
|
132
|
+
if (isWebCore(core)) return false;
|
|
133
|
+
if (core.status && core.status !== 'ok') return false;
|
|
134
|
+
return isGroupableFileTag(core.tag);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Stateful runtime collapser. Owns one writer "activity" entry per group of
|
|
138
|
+
// consecutive same-key file ops, updating it in place as ops complete and
|
|
139
|
+
// committing on flush(): a single summary line (≥3 ops) or the individual per-op
|
|
140
|
+
// lines (1–2 ops). Tools run sequentially in the agent loop, so at most one group
|
|
141
|
+
// is ever open and there is no concurrency. A SECOND INSTANCE of the web tracker.
|
|
142
|
+
function createFileActivityTracker(deps) {
|
|
143
|
+
const { writerModule } = deps || {};
|
|
144
|
+
let groupId = null;
|
|
145
|
+
let seq = 0;
|
|
146
|
+
let currentKey = null;
|
|
147
|
+
let ended = []; // successful op descriptors committed into this group
|
|
148
|
+
let current = null; // the in-flight op, shown in the live aggregate line
|
|
149
|
+
|
|
150
|
+
function _render() {
|
|
151
|
+
const ops = current ? ended.concat([current]) : ended;
|
|
152
|
+
return formatFileSummaryLine(fileSummaryState(ops), { pending: true });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _refresh() {
|
|
156
|
+
if (groupId === null) return;
|
|
157
|
+
writerModule.updateActivity(groupId, () => _render());
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const api = {
|
|
161
|
+
isGroupable: isGroupableFileTag,
|
|
162
|
+
isOpen() { return groupId !== null; },
|
|
163
|
+
|
|
164
|
+
// Open or extend the live group for a starting op. read_file and list_dir
|
|
165
|
+
// share one key, so a read↔list switch does NOT flush — both accumulate into
|
|
166
|
+
// the same group (the key only changes for a different category, which never
|
|
167
|
+
// reaches here). The live row is a growing web-style aggregate: "● file ·
|
|
168
|
+
// exploring… ×N (a, b, …)". `input` is the op's path (used for the live
|
|
169
|
+
// basename).
|
|
170
|
+
start(tag, input) {
|
|
171
|
+
const key = fileGroupKey(tag);
|
|
172
|
+
if (groupId !== null && key !== currentKey) api.flush();
|
|
173
|
+
current = { tag, target: input };
|
|
174
|
+
if (groupId === null) {
|
|
175
|
+
currentKey = key;
|
|
176
|
+
groupId = `file-${seq++}`;
|
|
177
|
+
writerModule.startActivity(groupId, () => _render());
|
|
178
|
+
} else {
|
|
179
|
+
_refresh();
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
// Record a SUCCESSFUL op (a prebuilt ToolOperation descriptor) into the group
|
|
184
|
+
// and re-render the live aggregate. Errored ops are NOT routed here (the
|
|
185
|
+
// caller flushes the group and renders the error standalone).
|
|
186
|
+
end(operation) {
|
|
187
|
+
ended.push(operation);
|
|
188
|
+
current = null;
|
|
189
|
+
_refresh();
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
// Commit the group to scrollback and reset. THRESHOLD decided here: <3 ops →
|
|
193
|
+
// each as its own normal result line (byte-identical to today's per-op
|
|
194
|
+
// output); ≥3 → one collapsed summary line. Exactly one endActivity call, so
|
|
195
|
+
// the commit happens once; a no-op when no group is open (the double-flush
|
|
196
|
+
// guard the boundary+finally flush sites rely on).
|
|
197
|
+
flush() {
|
|
198
|
+
if (groupId === null) return;
|
|
199
|
+
const id = groupId;
|
|
200
|
+
const ops = ended;
|
|
201
|
+
groupId = null;
|
|
202
|
+
currentKey = null;
|
|
203
|
+
ended = [];
|
|
204
|
+
current = null;
|
|
205
|
+
let line = '';
|
|
206
|
+
if (ops.length >= GROUP_THRESHOLD) {
|
|
207
|
+
line = formatFileSummaryLine(fileSummaryState(ops), { pending: false });
|
|
208
|
+
} else {
|
|
209
|
+
line = ops
|
|
210
|
+
.map((op) => renderOperation(op, { mode: 'ansi', phase: 'result' }))
|
|
211
|
+
.join('\n');
|
|
212
|
+
}
|
|
213
|
+
writerModule.endActivity(id, line);
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
return api;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = {
|
|
220
|
+
GROUP_THRESHOLD,
|
|
221
|
+
FILE_GROUP_TAGS,
|
|
222
|
+
normalizeFileTag,
|
|
223
|
+
isGroupableFileTag,
|
|
224
|
+
fileGroupKey,
|
|
225
|
+
fileSummaryState,
|
|
226
|
+
formatFileSummaryLine,
|
|
227
|
+
isGroupableFileCore,
|
|
228
|
+
createFileActivityTracker,
|
|
229
|
+
};
|