@semalt-ai/code 1.8.5 → 1.20.0
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 +7 -1
- package/.github/workflows/ci.yml +69 -0
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -316
- package/README.md +148 -4
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +856 -120
- package/lib/api.js +239 -50
- package/lib/args.js +74 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +489 -0
- package/lib/commands/chat-slash.js +415 -0
- package/lib/commands/chat-turn.js +669 -0
- package/lib/commands/chat.js +407 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +360 -11
- package/lib/constants.js +401 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +202 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +270 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +123 -26
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +99 -8
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2862 -0
- package/lib/tool_specs.js +263 -9
- package/lib/tools.js +352 -1039
- 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 +236 -0
- package/lib/ui/format.js +195 -29
- package/lib/ui/input-field.js +21 -11
- 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 +146 -36
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -44
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +270 -0
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- 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/background.test.js +414 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +599 -0
- package/test/extract-tool-calls.test.js +349 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/file-activity.test.js +522 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +143 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +348 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- 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 +218 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/md-stream.test.js +183 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +409 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +362 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- 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/result-cap.test.js +233 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +171 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/theme-palette.test.js +166 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +203 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- package/lib/commands.js +0 -1438
- package/path +0 -1
package/lib/ui/create-ui.js
CHANGED
|
@@ -5,8 +5,15 @@ const readline = require('readline');
|
|
|
5
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
|
+
const { getCols } = require('./utils');
|
|
9
|
+
const { wrapPromptLines } = require('./format');
|
|
8
10
|
const { registerTerminalCleanup } = require('./terminal');
|
|
9
11
|
|
|
12
|
+
// Cap a long ask_user question to this many wrapped lines in the modal header
|
|
13
|
+
// (mirrors the permission picker's MAX_DESC_LINES in permissions.js) so it can
|
|
14
|
+
// never overflow the live modal band; the rest collapses to "… N more lines".
|
|
15
|
+
const MAX_PROMPT_LINES = 12;
|
|
16
|
+
|
|
10
17
|
function _createNoOpUI() {
|
|
11
18
|
const chatHistory = {
|
|
12
19
|
addMessage: (msg) => {
|
|
@@ -19,6 +26,10 @@ function _createNoOpUI() {
|
|
|
19
26
|
clearStreamingContent: () => {},
|
|
20
27
|
finalizeLastMessage: () => {},
|
|
21
28
|
clearMessages: () => {},
|
|
29
|
+
// Phase 7b — no live region in the non-TTY no-op UI, so the deferred detail
|
|
30
|
+
// band is inert; the output preview just never displays. Present so the
|
|
31
|
+
// chat-turn boundary calls are safe in a non-TTY interactive run.
|
|
32
|
+
deferToolOutput: () => {}, commitDeferredDetail: () => {},
|
|
22
33
|
scrollUp: () => {}, scrollDown: () => {},
|
|
23
34
|
rerenderById: () => {},
|
|
24
35
|
removeById: () => {},
|
|
@@ -62,7 +73,6 @@ function createUI(opts) {
|
|
|
62
73
|
const { LayoutManager } = require('./layout');
|
|
63
74
|
const { FullStatusBar } = require('./status-bar');
|
|
64
75
|
const { InputField } = require('./input-field');
|
|
65
|
-
const { interactiveSelect } = require('./select');
|
|
66
76
|
|
|
67
77
|
const layout = new LayoutManager();
|
|
68
78
|
const chatHistory = new ChatHistory();
|
|
@@ -153,34 +163,67 @@ function createUI(opts) {
|
|
|
153
163
|
|
|
154
164
|
// ── captureSelect (modal menu) ───────────────────────────────────────────────
|
|
155
165
|
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
166
|
+
// ask_user-PRIVATE numbered-options picker (the model/rewind/permission
|
|
167
|
+
// pickers call interactiveSelect directly — this wrapper is NOT on that path).
|
|
168
|
+
// It manages its OWN modal frame (mirroring permissions.js requestPermission)
|
|
169
|
+
// rather than going through interactiveSelect, because it prepends the QUESTION
|
|
170
|
+
// as NON-SELECTABLE header rows above the options: navigation only ever cycles
|
|
171
|
+
// the options, so the prompt rows can never be landed on or returned. The frame
|
|
172
|
+
// redraws in place in the writer's modal region (above status, below
|
|
173
|
+
// scrollback) and keys route through the input field's captureNavigation API,
|
|
174
|
+
// so the menu cohabits with the live region instead of taking over the screen.
|
|
175
|
+
//
|
|
176
|
+
// `menu` = { prompt, options }. `prompt` (the question prose, already stripped
|
|
177
|
+
// of the numbered options by the executor) is word-wrapped to terminal width
|
|
178
|
+
// and clamped to MAX_PROMPT_LINES so a long question can't overflow the band.
|
|
161
179
|
inputField.captureSelect = (menu) => new Promise((resolve) => {
|
|
180
|
+
const options = (menu && Array.isArray(menu.options)) ? menu.options : [];
|
|
162
181
|
if (!process.stdin.isTTY) {
|
|
163
|
-
resolve(
|
|
182
|
+
resolve(options[0]);
|
|
164
183
|
return;
|
|
165
184
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
if (options.length === 0) {
|
|
186
|
+
resolve(null);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Wrap + clamp the question into non-selectable header rows.
|
|
191
|
+
const promptLines = wrapPromptLines(menu && menu.prompt, {
|
|
192
|
+
cols: Math.max(20, getCols() - 4),
|
|
193
|
+
maxLines: MAX_PROMPT_LINES,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
let idx = 0;
|
|
197
|
+
const buildLines = () => {
|
|
198
|
+
const lines = [];
|
|
199
|
+
for (const p of promptLines) lines.push(` ${FG_GRAY}${p}${RST}`);
|
|
200
|
+
if (promptLines.length) lines.push('');
|
|
201
|
+
for (let i = 0; i < options.length; i++) {
|
|
202
|
+
lines.push(i === idx
|
|
203
|
+
? ` ${FG_YELLOW}❯${RST} ${FG_CYAN}${options[i]}${RST}`
|
|
204
|
+
: ` ${FG_GRAY}${options[i]}${RST}`);
|
|
205
|
+
}
|
|
206
|
+
return lines;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
writer.setModal(buildLines());
|
|
210
|
+
inputField.captureNavigation((action) => {
|
|
211
|
+
if (action === 'prev') {
|
|
212
|
+
idx = (idx - 1 + options.length) % options.length;
|
|
213
|
+
writer.setModal(buildLines());
|
|
214
|
+
} else if (action === 'next') {
|
|
215
|
+
idx = (idx + 1) % options.length;
|
|
216
|
+
writer.setModal(buildLines());
|
|
217
|
+
} else if (action === 'select' || action === 'cancel') {
|
|
218
|
+
// Order matters (mirrors interactiveSelect): clearModal first while the
|
|
219
|
+
// caret is still suppressed, then releaseNavigation so the host's render
|
|
220
|
+
// hooks restore the input caret on the next setLive frame.
|
|
221
|
+
writer.clearModal();
|
|
222
|
+
inputField.releaseNavigation();
|
|
223
|
+
// Cancel → last option (typically "No"/decline) so callers don't need to
|
|
224
|
+
// special-case cancellation — matches the prior contract.
|
|
225
|
+
resolve(action === 'select' ? options[idx] : options[options.length - 1]);
|
|
178
226
|
}
|
|
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]);
|
|
184
227
|
});
|
|
185
228
|
});
|
|
186
229
|
|
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,236 @@
|
|
|
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 · read ×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. A homogeneous run keeps its specific
|
|
28
|
+
// verb ("read ×N" / "list ×N"); a genuinely mixed run uses the neutral "file
|
|
29
|
+
// ×N". Any OTHER 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 now share one group, so a group may be MIXED. The verb reflects the
|
|
85
|
+
// group's composition: homogeneous reads → "read"/"reading…", homogeneous lists
|
|
86
|
+
// → "list"/"listing…", a genuinely mixed group → the neutral "file"/"accessing…".
|
|
87
|
+
function fileSummaryState(ops) {
|
|
88
|
+
const list = (ops || []).filter(Boolean);
|
|
89
|
+
let hasRead = false, hasList = false;
|
|
90
|
+
for (const o of list) {
|
|
91
|
+
if (normalizeFileTag(o.tag) === 'list_dir') hasList = true;
|
|
92
|
+
else hasRead = true;
|
|
93
|
+
}
|
|
94
|
+
const mixed = hasRead && hasList;
|
|
95
|
+
const isList = hasList && !hasRead;
|
|
96
|
+
return {
|
|
97
|
+
verb: mixed ? 'file' : (isList ? 'list' : 'read'),
|
|
98
|
+
gerund: mixed ? 'accessing…' : (isList ? 'listing…' : 'reading…'),
|
|
99
|
+
count: list.length,
|
|
100
|
+
basenames: list.map((o) => _basename(o.target)),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Styled, chrome-consistent summary line. Mirrors `formatWebSummaryLine`'s
|
|
105
|
+
// "<glyph> <category> · <operation>" layout so the file summary reads as a peer
|
|
106
|
+
// of the other tool lines. The ×N count sits in the FIXED prefix BEFORE the
|
|
107
|
+
// truncatable basename list, so it ALWAYS shows even when the basenames are cut
|
|
108
|
+
// to fit. One physical row (the Phase-4 single-row invariant): the basename list
|
|
109
|
+
// is truncated to the remaining columns at the CURRENT terminal width.
|
|
110
|
+
function formatFileSummaryLine(state, opts) {
|
|
111
|
+
const { pending = false } = opts || {};
|
|
112
|
+
const colors = resolveLineColors('file', pending ? 'pending' : 'success');
|
|
113
|
+
const glyph = pending ? UI_ICONS.pending : UI_ICONS.success;
|
|
114
|
+
const cat = 'file'.padEnd(5);
|
|
115
|
+
const sep = ` ${DIM}·${RST} `;
|
|
116
|
+
|
|
117
|
+
const head = ` ${colors.glyph}${glyph}${RST} ${colors.label}${cat}${RST}`;
|
|
118
|
+
const verb = pending ? state.gerund : state.verb;
|
|
119
|
+
const fixed = `${verb} ×${state.count} (`;
|
|
120
|
+
|
|
121
|
+
// Width budget for the basename list: total columns minus the styled prefix
|
|
122
|
+
// (measured plain), the fixed "verb ×N (" lead, and the trailing ")".
|
|
123
|
+
const cols = getCols();
|
|
124
|
+
const used = termWidth(stripAnsi(head)) + termWidth(stripAnsi(sep)) + termWidth(fixed) + 1;
|
|
125
|
+
const budget = cols - used;
|
|
126
|
+
const joined = state.basenames.join(', ');
|
|
127
|
+
const list = budget > 0 ? truncateLine(joined, budget) : '…';
|
|
128
|
+
|
|
129
|
+
const opSeg = `${colors.op}${fixed}${list})${RST}`;
|
|
130
|
+
return [head, opSeg].join(sep);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Predicate: a persisted display core is a GROUPABLE file-op core (a normal
|
|
134
|
+
// descriptor core for a successful read_file/list_dir). Replay buffers these and
|
|
135
|
+
// re-groups them at the same boundaries the live path flushes. A web core or a
|
|
136
|
+
// non-file / errored / unknown core fails the gate. Tolerant of any input.
|
|
137
|
+
function isGroupableFileCore(core) {
|
|
138
|
+
if (!core || typeof core !== 'object' || core.v !== 1) return false;
|
|
139
|
+
if (isWebCore(core)) return false;
|
|
140
|
+
if (core.status && core.status !== 'ok') return false;
|
|
141
|
+
return isGroupableFileTag(core.tag);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Stateful runtime collapser. Owns one writer "activity" entry per group of
|
|
145
|
+
// consecutive same-key file ops, updating it in place as ops complete and
|
|
146
|
+
// committing on flush(): a single summary line (≥3 ops) or the individual per-op
|
|
147
|
+
// lines (1–2 ops). Tools run sequentially in the agent loop, so at most one group
|
|
148
|
+
// is ever open and there is no concurrency. A SECOND INSTANCE of the web tracker.
|
|
149
|
+
function createFileActivityTracker(deps) {
|
|
150
|
+
const { writerModule } = deps || {};
|
|
151
|
+
let groupId = null;
|
|
152
|
+
let seq = 0;
|
|
153
|
+
let currentKey = null;
|
|
154
|
+
let ended = []; // successful op descriptors committed into this group
|
|
155
|
+
let current = null; // the in-flight op, shown in the live aggregate line
|
|
156
|
+
|
|
157
|
+
function _render() {
|
|
158
|
+
const ops = current ? ended.concat([current]) : ended;
|
|
159
|
+
return formatFileSummaryLine(fileSummaryState(ops), { pending: true });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _refresh() {
|
|
163
|
+
if (groupId === null) return;
|
|
164
|
+
writerModule.updateActivity(groupId, () => _render());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const api = {
|
|
168
|
+
isGroupable: isGroupableFileTag,
|
|
169
|
+
isOpen() { return groupId !== null; },
|
|
170
|
+
|
|
171
|
+
// Open or extend the live group for a starting op. read_file and list_dir
|
|
172
|
+
// share one key, so a read↔list switch does NOT flush — both accumulate into
|
|
173
|
+
// the same group (the key only changes for a different category, which never
|
|
174
|
+
// reaches here). The live row is a growing web-style aggregate: "● file ·
|
|
175
|
+
// reading… ×N (a, b, …)" (or "accessing… ×N" once mixed). `input` is the op's
|
|
176
|
+
// path (used for the live basename).
|
|
177
|
+
start(tag, input) {
|
|
178
|
+
const key = fileGroupKey(tag);
|
|
179
|
+
if (groupId !== null && key !== currentKey) api.flush();
|
|
180
|
+
current = { tag, target: input };
|
|
181
|
+
if (groupId === null) {
|
|
182
|
+
currentKey = key;
|
|
183
|
+
groupId = `file-${seq++}`;
|
|
184
|
+
writerModule.startActivity(groupId, () => _render());
|
|
185
|
+
} else {
|
|
186
|
+
_refresh();
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
// Record a SUCCESSFUL op (a prebuilt ToolOperation descriptor) into the group
|
|
191
|
+
// and re-render the live aggregate. Errored ops are NOT routed here (the
|
|
192
|
+
// caller flushes the group and renders the error standalone).
|
|
193
|
+
end(operation) {
|
|
194
|
+
ended.push(operation);
|
|
195
|
+
current = null;
|
|
196
|
+
_refresh();
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
// Commit the group to scrollback and reset. THRESHOLD decided here: <3 ops →
|
|
200
|
+
// each as its own normal result line (byte-identical to today's per-op
|
|
201
|
+
// output); ≥3 → one collapsed summary line. Exactly one endActivity call, so
|
|
202
|
+
// the commit happens once; a no-op when no group is open (the double-flush
|
|
203
|
+
// guard the boundary+finally flush sites rely on).
|
|
204
|
+
flush() {
|
|
205
|
+
if (groupId === null) return;
|
|
206
|
+
const id = groupId;
|
|
207
|
+
const ops = ended;
|
|
208
|
+
groupId = null;
|
|
209
|
+
currentKey = null;
|
|
210
|
+
ended = [];
|
|
211
|
+
current = null;
|
|
212
|
+
let line = '';
|
|
213
|
+
if (ops.length >= GROUP_THRESHOLD) {
|
|
214
|
+
line = formatFileSummaryLine(fileSummaryState(ops), { pending: false });
|
|
215
|
+
} else {
|
|
216
|
+
line = ops
|
|
217
|
+
.map((op) => renderOperation(op, { mode: 'ansi', phase: 'result' }))
|
|
218
|
+
.join('\n');
|
|
219
|
+
}
|
|
220
|
+
writerModule.endActivity(id, line);
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
return api;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = {
|
|
227
|
+
GROUP_THRESHOLD,
|
|
228
|
+
FILE_GROUP_TAGS,
|
|
229
|
+
normalizeFileTag,
|
|
230
|
+
isGroupableFileTag,
|
|
231
|
+
fileGroupKey,
|
|
232
|
+
fileSummaryState,
|
|
233
|
+
formatFileSummaryLine,
|
|
234
|
+
isGroupableFileCore,
|
|
235
|
+
createFileActivityTracker,
|
|
236
|
+
};
|