@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
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Web-activity process summary (Task W.3, Part 1).
|
|
4
|
+
//
|
|
5
|
+
// A web task runs `web_search` (find candidate pages) then targeted `http_get`
|
|
6
|
+
// (read the relevant ones). By default each operation printed its own tool line
|
|
7
|
+
// (one "tool · web_search" / "net · GET …" row per call), which reads as a noisy
|
|
8
|
+
// list rather than one coherent process. This module collapses a run of
|
|
9
|
+
// consecutive web operations into a SINGLE compact process-summary line —
|
|
10
|
+
//
|
|
11
|
+
// ✓ web · search "коррупционные скандалы…" · 2 queries · 3 sources read · 1 blocked
|
|
12
|
+
//
|
|
13
|
+
// — while `--debug` keeps the full per-operation lines (in debug mode chat-turn.js
|
|
14
|
+
// bypasses this collapser and renders each op the normal way).
|
|
15
|
+
//
|
|
16
|
+
// Display only: the audit log still records every individual operation (that
|
|
17
|
+
// happens in the executors, untouched here), and NON-web tools render exactly as
|
|
18
|
+
// before. Scope: `web_search` + `http_get`. `download` is a file-save, not a page
|
|
19
|
+
// read for the search→fetch flow, so it keeps its own line.
|
|
20
|
+
|
|
21
|
+
const { UI_THEME, UI_ICONS, resolveLineColors } = require('./theme');
|
|
22
|
+
const { RST, DIM } = require('./ansi');
|
|
23
|
+
const { formatDuration } = require('./format');
|
|
24
|
+
|
|
25
|
+
// The tools collapsed into the web-activity summary.
|
|
26
|
+
const WEB_TOOLS = new Set(['web_search', 'http_get']);
|
|
27
|
+
|
|
28
|
+
function isWebTool(tag) { return WEB_TOOLS.has(tag); }
|
|
29
|
+
|
|
30
|
+
function _truncate(text, max) {
|
|
31
|
+
const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
|
|
32
|
+
if (s.length <= max) return s;
|
|
33
|
+
return s.slice(0, Math.max(0, max - 1)) + '…';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Whether a finished web op counts as a success. A `web_search` is ok unless the
|
|
37
|
+
// executor flagged an error (backend down). An `http_get` is ok only when it both
|
|
38
|
+
// avoided a transport error (timeout/DNS — surfaced as `op.error`) AND the server
|
|
39
|
+
// answered < 400: a 403/406 is a real "blocked" even though the fetch itself
|
|
40
|
+
// completed and returned a status code.
|
|
41
|
+
function opSucceeded(op) {
|
|
42
|
+
if (!op) return false;
|
|
43
|
+
if (op.error) return false;
|
|
44
|
+
if (op.tag === 'http_get' && typeof op.status === 'number' && op.status >= 400) return false;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Web-op persistence core (Output Refactor — Phase 6c-i) ───────────────────
|
|
49
|
+
//
|
|
50
|
+
// Web tools (`web_search`/`http_get`) are intercepted in chat-turn.js BEFORE
|
|
51
|
+
// they become a normal ToolOperation descriptor (they collapse into the live
|
|
52
|
+
// web-activity summary instead). So the per-call `_display` slot the agent loop
|
|
53
|
+
// persists would be `null` for a web op, and on replay the whole turn drops to
|
|
54
|
+
// the legacy summary. 6c-i closes that gap WITHOUT changing any pixel: the
|
|
55
|
+
// interception now returns a dedicated web-op core (below) which is persisted
|
|
56
|
+
// into the slot, while every replay reader is taught to treat a web-core as
|
|
57
|
+
// "no descriptor → fallback" (see isWebCore + chat-history/chat-session). 6c-ii
|
|
58
|
+
// will later flip the readers to aggregate these cores into a replayed summary.
|
|
59
|
+
//
|
|
60
|
+
// The core is a SEPARATE shape from serializeOperation's normal core, with a
|
|
61
|
+
// MANDATORY `kind:'web'` discriminator: a normal core buries `query` in `attrs`
|
|
62
|
+
// and `status` in `meta`, whereas aggregateWebOps reads `op.query/url/status/
|
|
63
|
+
// bytes/error/tag` FLAT — so these cores can feed aggregateWebOps directly in
|
|
64
|
+
// 6c-ii. `kind:'web'` is mandatory because descriptorFromStored accepts any
|
|
65
|
+
// `v===1` object and would otherwise render a web-core as a broken descriptor.
|
|
66
|
+
const WEB_DISPLAY_FORMAT_VERSION = 1;
|
|
67
|
+
|
|
68
|
+
// Build a persistable web-op core from the tool ctx (NOT the tracker's mutable
|
|
69
|
+
// ended[]/current state), mirroring exactly what web-activity.end() reads from
|
|
70
|
+
// ctx (`:180-191`): query/url from ctx.attrs, status/bytes from ctx.meta, error
|
|
71
|
+
// from ctx.error. Decoupling persistence from live-region state keeps the two
|
|
72
|
+
// independent.
|
|
73
|
+
function serializeWebOp(ctx, tag, durationMs) {
|
|
74
|
+
const meta = ctx && ctx.meta;
|
|
75
|
+
const attrs = ctx && ctx.attrs;
|
|
76
|
+
return {
|
|
77
|
+
v: WEB_DISPLAY_FORMAT_VERSION,
|
|
78
|
+
kind: 'web',
|
|
79
|
+
tag,
|
|
80
|
+
query: (attrs && attrs.query) || undefined,
|
|
81
|
+
url: (attrs && attrs.url) || undefined,
|
|
82
|
+
status: meta && typeof meta.status_code === 'number' ? meta.status_code : undefined,
|
|
83
|
+
bytes: meta && typeof meta.bytes === 'number' ? meta.bytes : undefined,
|
|
84
|
+
error: ctx && ctx.error ? (ctx.error.message || String(ctx.error)) : undefined,
|
|
85
|
+
durationMs: Number.isFinite(durationMs) ? durationMs : null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Predicate: a persisted display core is a web-op core (vs a normal descriptor
|
|
90
|
+
// core). Replay readers use this to route web-cores to the legacy fallback so
|
|
91
|
+
// 6c-i is invisible. Tolerant of any non-object input.
|
|
92
|
+
function isWebCore(core) {
|
|
93
|
+
return !!core && typeof core === 'object' && core.v === WEB_DISPLAY_FORMAT_VERSION && core.kind === 'web';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Pure: fold the recorded op list into the counts the summary needs.
|
|
97
|
+
function aggregateWebOps(ops) {
|
|
98
|
+
const state = {
|
|
99
|
+
searchCount: 0, searchFailed: 0, queries: [],
|
|
100
|
+
fetchCount: 0, fetchOk: 0, fetchFailed: 0,
|
|
101
|
+
};
|
|
102
|
+
for (const op of (ops || [])) {
|
|
103
|
+
if (!op) continue;
|
|
104
|
+
const ok = opSucceeded(op);
|
|
105
|
+
if (op.tag === 'web_search') {
|
|
106
|
+
state.searchCount += 1;
|
|
107
|
+
if (!ok) state.searchFailed += 1;
|
|
108
|
+
if (op.query) state.queries.push(op.query);
|
|
109
|
+
} else if (op.tag === 'http_get') {
|
|
110
|
+
state.fetchCount += 1;
|
|
111
|
+
if (ok) state.fetchOk += 1; else state.fetchFailed += 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return state;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Pure: the plain-text segments of the summary (no ANSI). Each segment is tagged
|
|
118
|
+
// with a `kind` so the styled renderer can colour failures distinctly. Exposed
|
|
119
|
+
// for tests and reused by the styled renderer. Failures (a blocked source / a
|
|
120
|
+
// failed search) are ALWAYS represented so the compact view never silently drops
|
|
121
|
+
// a source that didn't load.
|
|
122
|
+
function webSummarySegments(state) {
|
|
123
|
+
const segs = [];
|
|
124
|
+
if (state.searchCount > 0) {
|
|
125
|
+
const q = state.queries[0] ? `"${_truncate(state.queries[0], 48)}"` : '';
|
|
126
|
+
segs.push({ kind: 'lead', text: `search ${q}`.trim() });
|
|
127
|
+
if (state.searchCount > 1) segs.push({ kind: 'count', text: `${state.searchCount} queries` });
|
|
128
|
+
if (state.searchFailed > 0) {
|
|
129
|
+
segs.push({ kind: 'fail', text: `${state.searchFailed} search${state.searchFailed === 1 ? '' : 'es'} failed` });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (state.fetchCount > 0) {
|
|
133
|
+
segs.push({ kind: state.searchCount > 0 ? 'count' : 'lead', text: `${state.fetchOk} ${state.fetchOk === 1 ? 'source' : 'sources'} read` });
|
|
134
|
+
if (state.fetchFailed > 0) segs.push({ kind: 'fail', text: `${state.fetchFailed} blocked` });
|
|
135
|
+
}
|
|
136
|
+
if (segs.length === 0) segs.push({ kind: 'lead', text: 'web' });
|
|
137
|
+
return segs;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Plain-text one-liner (no ANSI). The text the tests assert on.
|
|
141
|
+
function webSummaryText(state) {
|
|
142
|
+
return webSummarySegments(state).map((s) => s.text).join(' · ');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Styled, chrome-consistent line for the writer's activity region / scrollback.
|
|
146
|
+
// Mirrors formatToolLine's "<glyph> <category> · <segments…>" layout so the
|
|
147
|
+
// summary reads as a peer of the other tool lines, not a foreign widget.
|
|
148
|
+
function formatWebSummaryLine(state, opts) {
|
|
149
|
+
const { pending = false, durationMs = 0 } = opts || {};
|
|
150
|
+
// Glyph/label/duration colour resolves through the single theme resolver so
|
|
151
|
+
// the web summary reads as a peer of the other tool lines (web → cyan 44,
|
|
152
|
+
// running glyph cyan not gray, duration subtle not muted).
|
|
153
|
+
const colors = resolveLineColors('web', pending ? 'pending' : 'success');
|
|
154
|
+
const glyph = pending ? UI_ICONS.pending : UI_ICONS.success;
|
|
155
|
+
const cat = 'web'.padEnd(5);
|
|
156
|
+
const sep = ` ${DIM}·${RST} `;
|
|
157
|
+
|
|
158
|
+
const out = [` ${colors.glyph}${glyph}${RST} ${colors.label}${cat}${RST}`];
|
|
159
|
+
for (const seg of webSummarySegments(state)) {
|
|
160
|
+
let color = UI_THEME.subtle;
|
|
161
|
+
if (seg.kind === 'lead') color = colors.op;
|
|
162
|
+
else if (seg.kind === 'fail') color = UI_THEME.warning;
|
|
163
|
+
out.push(`${color}${seg.text}${RST}`);
|
|
164
|
+
}
|
|
165
|
+
if (pending) out.push(`${colors.dur}${formatDuration(durationMs)}…${RST}`);
|
|
166
|
+
return out.join(sep);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Batch renderer / policy seam (used by tests, and documents the runtime split):
|
|
170
|
+
// debug → one full per-operation tool line per op (nothing hidden), built
|
|
171
|
+
// with the SAME formatToolLine the runtime uses.
|
|
172
|
+
// default → a single collapsed summary line.
|
|
173
|
+
function renderWebActivity(ops, opts) {
|
|
174
|
+
const { debug = false, formatToolLine } = opts || {};
|
|
175
|
+
if (debug) {
|
|
176
|
+
return (ops || []).map((op) => formatToolLine({
|
|
177
|
+
status: opSucceeded(op) ? 'success' : 'failure',
|
|
178
|
+
tag: op.tag,
|
|
179
|
+
arg: op.query || op.url || '',
|
|
180
|
+
attrs: op.tag === 'web_search' ? { query: op.query } : { url: op.url },
|
|
181
|
+
durationMs: op.durationMs,
|
|
182
|
+
meta: op.tag === 'http_get' ? { status_code: op.status, bytes: op.bytes } : null,
|
|
183
|
+
error: op.error ? { message: String(op.error) } : null,
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
return [formatWebSummaryLine(aggregateWebOps(ops), { pending: false })];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Stateful runtime collapser. Owns one writer "activity" entry per group of
|
|
190
|
+
// consecutive web ops, updating it in place as ops complete and committing a
|
|
191
|
+
// single final summary line to scrollback on flush(). Tools run sequentially in
|
|
192
|
+
// the agent loop, so at most one group is ever open and there is no concurrency.
|
|
193
|
+
function createWebActivityTracker(deps) {
|
|
194
|
+
const { writerModule } = deps || {};
|
|
195
|
+
let groupId = null;
|
|
196
|
+
let seq = 0;
|
|
197
|
+
let ended = [];
|
|
198
|
+
let current = null; // the in-flight op, shown in the pending line before it ends
|
|
199
|
+
|
|
200
|
+
function _render(durationMs) {
|
|
201
|
+
const state = aggregateWebOps(current ? ended.concat([current]) : ended);
|
|
202
|
+
return formatWebSummaryLine(state, { pending: true, durationMs });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function _refresh() {
|
|
206
|
+
if (groupId === null) return;
|
|
207
|
+
writerModule.updateActivity(groupId, (elapsedMs) => _render(elapsedMs));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
isWeb: isWebTool,
|
|
212
|
+
isOpen() { return groupId !== null; },
|
|
213
|
+
|
|
214
|
+
start(tag, input) {
|
|
215
|
+
current = {
|
|
216
|
+
tag,
|
|
217
|
+
query: tag === 'web_search' ? input : undefined,
|
|
218
|
+
url: tag === 'http_get' ? input : undefined,
|
|
219
|
+
};
|
|
220
|
+
if (groupId === null) {
|
|
221
|
+
groupId = `web-${seq++}`;
|
|
222
|
+
writerModule.startActivity(groupId, (elapsedMs) => _render(elapsedMs));
|
|
223
|
+
} else {
|
|
224
|
+
_refresh();
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
end(tag, result, durationMs, toolCtx) {
|
|
229
|
+
const meta = toolCtx && toolCtx.meta;
|
|
230
|
+
const attrs = toolCtx && toolCtx.attrs;
|
|
231
|
+
ended.push({
|
|
232
|
+
tag,
|
|
233
|
+
durationMs,
|
|
234
|
+
query: (attrs && attrs.query) || (current && current.query),
|
|
235
|
+
url: (attrs && attrs.url) || (current && current.url),
|
|
236
|
+
status: meta && typeof meta.status_code === 'number' ? meta.status_code : undefined,
|
|
237
|
+
bytes: meta && typeof meta.bytes === 'number' ? meta.bytes : undefined,
|
|
238
|
+
error: toolCtx && toolCtx.error ? (toolCtx.error.message || String(toolCtx.error)) : undefined,
|
|
239
|
+
});
|
|
240
|
+
current = null;
|
|
241
|
+
_refresh();
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
// Commit the collapsed summary for the current group to scrollback and reset.
|
|
245
|
+
// A no-op when no group is open.
|
|
246
|
+
flush() {
|
|
247
|
+
if (groupId === null) return;
|
|
248
|
+
const id = groupId;
|
|
249
|
+
const line = formatWebSummaryLine(aggregateWebOps(ended), { pending: false });
|
|
250
|
+
groupId = null;
|
|
251
|
+
ended = [];
|
|
252
|
+
current = null;
|
|
253
|
+
writerModule.endActivity(id, line);
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
module.exports = {
|
|
259
|
+
WEB_TOOLS,
|
|
260
|
+
isWebTool,
|
|
261
|
+
serializeWebOp,
|
|
262
|
+
isWebCore,
|
|
263
|
+
opSucceeded,
|
|
264
|
+
aggregateWebOps,
|
|
265
|
+
webSummarySegments,
|
|
266
|
+
webSummaryText,
|
|
267
|
+
formatWebSummaryLine,
|
|
268
|
+
renderWebActivity,
|
|
269
|
+
createWebActivityTracker,
|
|
270
|
+
};
|
package/lib/ui/writer.js
CHANGED
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
// STATUS REGION— 1–N lines at the bottom of the viewport. Redrawn in
|
|
16
16
|
// place whenever setLive() is called. Each live line is
|
|
17
17
|
// truncated to (cols − 1) so it occupies exactly one
|
|
18
|
-
// physical row; combined height is
|
|
19
|
-
//
|
|
18
|
+
// physical row; combined height is the physical-row sum
|
|
19
|
+
// (displayRows over each emitted entry) — equal to the entry
|
|
20
|
+
// count while every entry stays single-row, wrap-aware once
|
|
21
|
+
// Phase 7b emits multi-row detail.
|
|
20
22
|
//
|
|
21
23
|
// Invariant: after any operation resolves, the cursor is at column 0 of the
|
|
22
24
|
// row immediately below the status region. Every operation erases the
|
|
@@ -28,12 +30,21 @@
|
|
|
28
30
|
// synchronous, so each task's compound write is emitted in one burst and
|
|
29
31
|
// can't interleave with another task's writes.
|
|
30
32
|
|
|
31
|
-
const { stripAnsi, termWidth, truncateVisible } = require('./utils');
|
|
33
|
+
const { stripAnsi, termWidth, truncateVisible, displayRows } = require('./utils');
|
|
32
34
|
const dbg = require('../debug');
|
|
33
35
|
|
|
34
36
|
let _queue = Promise.resolve();
|
|
35
37
|
let _liveLines = [];
|
|
36
38
|
let _modalLines = [];
|
|
39
|
+
// Deferred-detail band (Output Refactor — Phase 7b). The redrawable pseudo-tail
|
|
40
|
+
// at the TOP of the live region (below committed scrollback, above the activity
|
|
41
|
+
// / modal / status rows) that holds the most-recent output-preview detail until
|
|
42
|
+
// a turn boundary commits it. Emitted RAW — NOT through _fitOneRow — so the lines
|
|
43
|
+
// held at op-end width wrap naturally to multiple physical rows on a narrower
|
|
44
|
+
// terminal; physicalRows counts their real height so the erase math stays
|
|
45
|
+
// correct. The band is installed once (setDetail) and committed once
|
|
46
|
+
// (commitDetail moves it into scrollback atomically) — never redrawn in place.
|
|
47
|
+
let _detailLines = [];
|
|
37
48
|
// Activity region: currently-executing tools with live timers. Sits above
|
|
38
49
|
// modal and status (scrollback | activity | modal | status). Entries keyed
|
|
39
50
|
// by invocation id so concurrent tools each get their own slot.
|
|
@@ -67,17 +78,39 @@ let _caret = null;
|
|
|
67
78
|
|
|
68
79
|
function _cols() { return process.stdout.columns || 80; }
|
|
69
80
|
|
|
81
|
+
// Match embedded control chars that would break the one-logical=one-physical
|
|
82
|
+
// invariant: a bare \n forces a second physical row, \r jumps to column 0
|
|
83
|
+
// mid-row, \t and other C0/DEL controls move the cursor unpredictably. ESC
|
|
84
|
+
// (0x1B) is deliberately EXCLUDED so legitimate ANSI/SGR sequences survive —
|
|
85
|
+
// every other byte of an SGR sequence (`[`, digits, `m`) is printable, so
|
|
86
|
+
// stripping the rest of the C0 range can't corrupt color chrome.
|
|
87
|
+
const _CONTROL_CHARS = /[\x00-\x1A\x1C-\x1F\x7F]/g; // eslint-disable-line no-control-regex
|
|
88
|
+
|
|
70
89
|
// ANSI-aware, display-width-aware truncate to (cols-1) visible columns so
|
|
71
90
|
// each rendered live line occupies exactly one physical terminal row — no
|
|
72
|
-
// wrap, no height mis-count.
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
91
|
+
// wrap, no height mis-count. This keeps every single-row caller's emitted
|
|
92
|
+
// entry at displayRows == 1, so the physical-row height (Phase 7a) equals the
|
|
93
|
+
// entry count for them and the erase math stays byte-identical. Un-fitted
|
|
94
|
+
// multi-row callers (Phase 7b) are counted by their real physical rows. Using
|
|
76
95
|
// termWidth for column counting (rather than a raw char count that treats
|
|
77
96
|
// wide chars and combining marks as 1) keeps the guarantee honest for
|
|
78
97
|
// non-ASCII chrome.
|
|
98
|
+
//
|
|
99
|
+
// Structural guard (Phase 4 fix-A, Part 2): embedded control chars are
|
|
100
|
+
// replaced with a space BEFORE width truncation, so a single fitted row is
|
|
101
|
+
// guaranteed exactly one physical row regardless of caller discipline. No
|
|
102
|
+
// future live-row source can desync the erase math by passing un-flattened
|
|
103
|
+
// content (e.g. a heredoc command that slipped its newlines through).
|
|
79
104
|
function _fitOneRow(line) {
|
|
80
105
|
if (!line) return '';
|
|
106
|
+
// Sanitize control chars first — before the fast path, since control chars
|
|
107
|
+
// are ASCII (≤0x7F) and would otherwise ride through the ASCII fast path
|
|
108
|
+
// unchanged. truncateVisible treats \n as a width-1 char and copies it
|
|
109
|
+
// verbatim, so sanitizing here is the only place the invariant is enforced.
|
|
110
|
+
if (_CONTROL_CHARS.test(line)) {
|
|
111
|
+
_CONTROL_CHARS.lastIndex = 0; // reset; .test on a /g regex is stateful
|
|
112
|
+
line = line.replace(_CONTROL_CHARS, ' ');
|
|
113
|
+
}
|
|
81
114
|
const max = Math.max(0, _cols() - 1);
|
|
82
115
|
// Fast path: pure ASCII (and thus display width == stripped length) and
|
|
83
116
|
// already within budget — skip the parse. The ASCII gate is important
|
|
@@ -94,7 +127,20 @@ function _fitOneRow(line) {
|
|
|
94
127
|
return truncateVisible(line, max);
|
|
95
128
|
}
|
|
96
129
|
|
|
97
|
-
|
|
130
|
+
// The single source of truth for the live-region erase sequence. Returns the
|
|
131
|
+
// cursor-up + carriage-return + clear-to-end-of-screen string that lands the
|
|
132
|
+
// cursor on the first live row (or below it when there is no region). Both the
|
|
133
|
+
// per-frame erase (_eraseLiveSeq) and teardown call this — consolidated in
|
|
134
|
+
// Phase 4 fix-A (Part 3) so the deferred (B) wrap-aware physical-row rewrite
|
|
135
|
+
// changes ONE site instead of two divergent copies.
|
|
136
|
+
//
|
|
137
|
+
// NOTE: _liveHeight is now a PHYSICAL-row total (Phase 7a / fix (B)) — the sum
|
|
138
|
+
// of displayRows() over each emitted live entry at the current cols (see
|
|
139
|
+
// physicalRows + _drawLiveSeq). For today's all-single-row content (each entry
|
|
140
|
+
// fitted to ≤cols-1 by _fitOneRow) that physical sum equals the old logical
|
|
141
|
+
// line count, so this erase math is byte-identical to before; the wrap-aware
|
|
142
|
+
// count only diverges once Phase 7b emits un-truncated multi-row detail.
|
|
143
|
+
function _eraseSeqForHeight() {
|
|
98
144
|
if (_liveHeight <= 0) return '';
|
|
99
145
|
// When the previous frame repositioned the OS cursor up to the caret, the
|
|
100
146
|
// cursor is sitting _caret.rowsFromBottom rows ABOVE "below-live-region".
|
|
@@ -106,6 +152,10 @@ function _eraseLiveSeq() {
|
|
|
106
152
|
return `${up > 0 ? `\x1b[${up}A` : ''}\r\x1b[J`;
|
|
107
153
|
}
|
|
108
154
|
|
|
155
|
+
function _eraseLiveSeq() {
|
|
156
|
+
return _eraseSeqForHeight();
|
|
157
|
+
}
|
|
158
|
+
|
|
109
159
|
// Collect visible activity entries in insertion order. Entries whose grace
|
|
110
160
|
// period hasn't elapsed are hidden and don't contribute to _liveHeight.
|
|
111
161
|
function _visibleActivity() {
|
|
@@ -121,24 +171,57 @@ function _visibleActivity() {
|
|
|
121
171
|
return rows;
|
|
122
172
|
}
|
|
123
173
|
|
|
174
|
+
// (B) wrap-aware physical-row height primitive. Sums displayRows() — the true
|
|
175
|
+
// physical-row count at `cols` — over each ALREADY-EMITTED live entry, so an
|
|
176
|
+
// entry that wraps to N physical rows contributes N (not 1). It MUST be fed the
|
|
177
|
+
// exact strings written to the terminal: _drawLiveSeq passes the post-_fitOneRow
|
|
178
|
+
// lines, each fitted to ≤cols-1 → displayRows == 1 → the sum equals the entry
|
|
179
|
+
// count, i.e. numerically identical to the old logical `array.length` height for
|
|
180
|
+
// all single-row content today. When Phase 7b emits an un-truncated multi-row
|
|
181
|
+
// detail entry, this counts its real physical rows so _eraseSeqForHeight()
|
|
182
|
+
// cursor-ups by the correct physical total (and recomputes on resize, since the
|
|
183
|
+
// count is taken at the current `cols`). Exported for direct unit coverage.
|
|
184
|
+
function physicalRows(lines, cols) {
|
|
185
|
+
if (!Array.isArray(lines)) return 0;
|
|
186
|
+
const c = cols || _cols();
|
|
187
|
+
let h = 0;
|
|
188
|
+
for (let i = 0; i < lines.length; i++) h += displayRows(lines[i], c);
|
|
189
|
+
return h;
|
|
190
|
+
}
|
|
191
|
+
|
|
124
192
|
function _drawLiveSeq() {
|
|
125
193
|
const activityRows = _visibleActivity();
|
|
126
|
-
const total = activityRows.length + _modalLines.length + _liveLines.length;
|
|
194
|
+
const total = _detailLines.length + activityRows.length + _modalLines.length + _liveLines.length;
|
|
127
195
|
if (total === 0) { _liveHeight = 0; return ''; }
|
|
196
|
+
// Collect each entry exactly as written, so the height is measured on the
|
|
197
|
+
// same bytes the terminal receives — the (B) correctness rule.
|
|
198
|
+
const cols = _cols();
|
|
199
|
+
const emitted = [];
|
|
128
200
|
let out = '';
|
|
201
|
+
// Deferred-detail band first (Phase 7b): emitted RAW (no _fitOneRow) so a held
|
|
202
|
+
// op-end-width line wraps to its real physical rows on a narrower terminal;
|
|
203
|
+
// physicalRows() counts that wrap so _eraseSeqForHeight() cursor-ups by the
|
|
204
|
+
// correct physical total. Sits at the very top of the live region.
|
|
205
|
+
for (let i = 0; i < _detailLines.length; i++) {
|
|
206
|
+
emitted.push(_detailLines[i]);
|
|
207
|
+
out += _detailLines[i] + '\n';
|
|
208
|
+
}
|
|
129
209
|
for (let i = 0; i < activityRows.length; i++) {
|
|
130
|
-
|
|
131
|
-
|
|
210
|
+
const fitted = _fitOneRow(activityRows[i]);
|
|
211
|
+
emitted.push(fitted);
|
|
212
|
+
out += fitted + '\n';
|
|
132
213
|
}
|
|
133
214
|
for (let i = 0; i < _modalLines.length; i++) {
|
|
134
|
-
|
|
135
|
-
|
|
215
|
+
const fitted = _fitOneRow(_modalLines[i]);
|
|
216
|
+
emitted.push(fitted);
|
|
217
|
+
out += fitted + '\n';
|
|
136
218
|
}
|
|
137
219
|
for (let i = 0; i < _liveLines.length; i++) {
|
|
138
|
-
|
|
139
|
-
|
|
220
|
+
const fitted = _fitOneRow(_liveLines[i]);
|
|
221
|
+
emitted.push(fitted);
|
|
222
|
+
out += fitted + '\n';
|
|
140
223
|
}
|
|
141
|
-
_liveHeight =
|
|
224
|
+
_liveHeight = physicalRows(emitted, cols);
|
|
142
225
|
return out;
|
|
143
226
|
}
|
|
144
227
|
|
|
@@ -254,11 +337,51 @@ function clearLive() {
|
|
|
254
337
|
if (entry.graceTimer) { try { clearTimeout(entry.graceTimer); } catch {} }
|
|
255
338
|
}
|
|
256
339
|
_activity.clear();
|
|
340
|
+
_detailLines = [];
|
|
257
341
|
_liveHeight = 0;
|
|
258
342
|
_caret = null;
|
|
259
343
|
});
|
|
260
344
|
}
|
|
261
345
|
|
|
346
|
+
// Replace the deferred-detail band (Phase 7b) and redraw in place. The band sits
|
|
347
|
+
// at the TOP of the live region and is emitted RAW (un-fitted, may wrap). Used to
|
|
348
|
+
// install the held output-preview detail without touching scrollback. _caret is
|
|
349
|
+
// left as-is (the band only exists while input is disabled, so the caret is null
|
|
350
|
+
// anyway); this mirrors redrawLive rather than setModal (which suppresses the
|
|
351
|
+
// caret).
|
|
352
|
+
function setDetail(lines) {
|
|
353
|
+
return _enqueue(() => {
|
|
354
|
+
// Erase using the previous caret/liveHeight (the band's rows are part of the
|
|
355
|
+
// current _liveHeight) — see setLive() for why erase precedes the state swap.
|
|
356
|
+
const eraseSeq = _eraseLiveSeq();
|
|
357
|
+
_detailLines = Array.isArray(lines) ? lines.map((l) => (l == null ? '' : String(l))) : [];
|
|
358
|
+
_writeSync(_HIDE + eraseSeq + _drawLiveSeq() + _positionCaretSeq() + _caretShowSeq());
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Commit the deferred-detail band to scrollback and clear it, in ONE compound
|
|
363
|
+
// write: erase the live region (the band's physical rows are included via the
|
|
364
|
+
// current _liveHeight), append the held lines as immutable scrollback, then
|
|
365
|
+
// redraw the live region with the band gone. Mirrors endActivity's atomic
|
|
366
|
+
// erase+commit+redraw so there is no duplicate-band frame and no stranded rows
|
|
367
|
+
// (the live-row race the discovery flagged). A no-op (plain erase+redraw) when
|
|
368
|
+
// `text` is empty.
|
|
369
|
+
function commitDetail(text) {
|
|
370
|
+
return _enqueue(() => {
|
|
371
|
+
let out = _HIDE + _eraseLiveSeq();
|
|
372
|
+
// Drop the band BEFORE _drawLiveSeq so the redraw omits it; the erase above
|
|
373
|
+
// already used the pre-clear _liveHeight (band included).
|
|
374
|
+
_detailLines = [];
|
|
375
|
+
if (text !== undefined && text !== null && text !== '') {
|
|
376
|
+
let t = String(text);
|
|
377
|
+
if (!t.endsWith('\n')) t += '\n';
|
|
378
|
+
out += t;
|
|
379
|
+
}
|
|
380
|
+
out += _drawLiveSeq() + _positionCaretSeq() + _caretShowSeq();
|
|
381
|
+
_writeSync(out);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
262
385
|
// Replace the modal region with `lines`. Modal sits above status and below
|
|
263
386
|
// scrollback. Used for permission pickers, confirm dialogs, text input
|
|
264
387
|
// prompts, etc. — any widget that must redraw on every keystroke without
|
|
@@ -297,9 +420,11 @@ function hasModal() { return _modalLines.length > 0; }
|
|
|
297
420
|
// the row without a scrollback commit (used by aborts where the final line
|
|
298
421
|
// is already written elsewhere, or for tools that produced no output).
|
|
299
422
|
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
423
|
+
// The lazy `render(elapsedMs)` on each entry picks up a fresh elapsed time on
|
|
424
|
+
// every live-region repaint. Phase 3: those periodic repaints are driven by the
|
|
425
|
+
// single animation driver (lib/ui/anim.js) via the status bar's coordinated
|
|
426
|
+
// _notify → setLive, which re-runs each entry's render here — so the per-tool
|
|
427
|
+
// elapsed meter + running glyph animate without a second timer of their own.
|
|
303
428
|
|
|
304
429
|
const DEFAULT_ACTIVITY_GRACE_MS = 200;
|
|
305
430
|
|
|
@@ -381,23 +506,6 @@ function cancelActivity(id) {
|
|
|
381
506
|
});
|
|
382
507
|
}
|
|
383
508
|
|
|
384
|
-
// Ticker hook: re-emit the live region so each activity entry's
|
|
385
|
-
// `render(elapsedMs)` is re-invoked with a fresh elapsed time. No-op when
|
|
386
|
-
// nothing is visible (avoids burning cycles when idle).
|
|
387
|
-
function refreshActivity() {
|
|
388
|
-
if (_activity.size === 0) return _queue;
|
|
389
|
-
let anyVisible = false;
|
|
390
|
-
for (const entry of _activity.values()) { if (entry.visible) { anyVisible = true; break; } }
|
|
391
|
-
if (!anyVisible) return _queue;
|
|
392
|
-
return _enqueue(() => {
|
|
393
|
-
if (_destroyed) return;
|
|
394
|
-
let stillAnyVisible = false;
|
|
395
|
-
for (const entry of _activity.values()) { if (entry.visible) { stillAnyVisible = true; break; } }
|
|
396
|
-
if (!stillAnyVisible) return;
|
|
397
|
-
_writeSync(_HIDE + _eraseLiveSeq() + _drawLiveSeq() + _positionCaretSeq() + _caretShowSeq());
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
|
|
401
509
|
function hasActivity() {
|
|
402
510
|
for (const entry of _activity.values()) { if (entry.visible) return true; }
|
|
403
511
|
return false;
|
|
@@ -426,6 +534,7 @@ function enqueue(fn) {
|
|
|
426
534
|
|
|
427
535
|
function getLiveHeight() { return _liveHeight; }
|
|
428
536
|
function getLiveLines() { return _liveLines.slice(); }
|
|
537
|
+
function getDetailLines() { return _detailLines.slice(); }
|
|
429
538
|
|
|
430
539
|
function destroy() { _destroyed = true; }
|
|
431
540
|
|
|
@@ -504,15 +613,16 @@ function teardown(opts) {
|
|
|
504
613
|
|
|
505
614
|
// Erase the live region. The cursor may be sitting inside the region —
|
|
506
615
|
// _caret.rowsFromBottom rows above the "below-live-region" baseline —
|
|
507
|
-
// because the last setLive positioned it at the input caret.
|
|
508
|
-
// that offset so cursor-up lands on
|
|
509
|
-
// live region (which would erase
|
|
510
|
-
// sits at column 0 of the row
|
|
511
|
-
// line — exactly where new
|
|
616
|
+
// because the last setLive positioned it at the input caret. The shared
|
|
617
|
+
// _eraseSeqForHeight() helper subtracts that offset so cursor-up lands on
|
|
618
|
+
// the FIRST live row, not above the live region (which would erase
|
|
619
|
+
// scrollback). After \x1b[J the cursor sits at column 0 of the row
|
|
620
|
+
// immediately after the last scrollback line — exactly where new
|
|
621
|
+
// scrollback content should begin. (Consolidated with _eraseLiveSeq in
|
|
622
|
+
// Phase 4 fix-A Part 3 so Phase 7a's (B) physical-row rewrite touched one
|
|
623
|
+
// site — _liveHeight is now that physical-row total.)
|
|
512
624
|
if (_liveHeight > 0) {
|
|
513
|
-
|
|
514
|
-
const up = Math.max(0, _liveHeight - offset);
|
|
515
|
-
parts.push(up > 0 ? `\x1b[${up}A\r\x1b[J` : '\r\x1b[J');
|
|
625
|
+
parts.push(_eraseSeqForHeight());
|
|
516
626
|
} else {
|
|
517
627
|
// No live region active: ensure cursor is at column 0 so artifacts
|
|
518
628
|
// (and any eventual shell prompt) don't print mid-line if some
|
|
@@ -564,6 +674,7 @@ function teardown(opts) {
|
|
|
564
674
|
if (entry.graceTimer) { try { clearTimeout(entry.graceTimer); } catch {} }
|
|
565
675
|
}
|
|
566
676
|
_activity.clear();
|
|
677
|
+
_detailLines = [];
|
|
567
678
|
_liveHeight = 0;
|
|
568
679
|
|
|
569
680
|
try { process.stdout.write(parts.join('')); } catch {}
|
|
@@ -582,6 +693,8 @@ module.exports = {
|
|
|
582
693
|
scrollback,
|
|
583
694
|
setLive,
|
|
584
695
|
clearLive,
|
|
696
|
+
setDetail,
|
|
697
|
+
commitDetail,
|
|
585
698
|
setModal,
|
|
586
699
|
clearModal,
|
|
587
700
|
hasModal,
|
|
@@ -589,13 +702,14 @@ module.exports = {
|
|
|
589
702
|
updateActivity,
|
|
590
703
|
endActivity,
|
|
591
704
|
cancelActivity,
|
|
592
|
-
refreshActivity,
|
|
593
705
|
hasActivity,
|
|
594
706
|
redrawLive,
|
|
595
707
|
flush,
|
|
596
708
|
enqueue,
|
|
597
709
|
getLiveHeight,
|
|
598
710
|
getLiveLines,
|
|
711
|
+
getDetailLines,
|
|
712
|
+
physicalRows,
|
|
599
713
|
destroy,
|
|
600
714
|
teardown,
|
|
601
715
|
};
|
package/lib/ui.js
CHANGED
|
@@ -27,7 +27,7 @@ module.exports = {
|
|
|
27
27
|
isPrintableKey: utils.isPrintableKey, approxTokens: utils.approxTokens,
|
|
28
28
|
|
|
29
29
|
// Diff + Markdown
|
|
30
|
-
renderDiff: diff.renderDiff, renderMarkdown: diff.renderMarkdown,
|
|
30
|
+
renderDiff: diff.renderDiff, buildExecutionDiff: diff.buildExecutionDiff, renderMarkdown: diff.renderMarkdown,
|
|
31
31
|
|
|
32
32
|
// Stream renderer
|
|
33
33
|
StreamRenderer: stream.StreamRenderer,
|