@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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ToolOperation descriptor (Output Refactor — Phase 1).
|
|
4
|
+
//
|
|
5
|
+
// The single, immutable description of one tool invocation's display state,
|
|
6
|
+
// produced ONCE from the normalized `[action, ...opts]` tuple the agent loop
|
|
7
|
+
// already computes (so it covers the XML and native rails identically — the
|
|
8
|
+
// tuple is their convergence point). It is the display analogue of the
|
|
9
|
+
// `boundToolOutput` token chokepoint: every chrome line for a tool call is
|
|
10
|
+
// rendered from this one descriptor by `render-operation.js`, instead of each
|
|
11
|
+
// call site assembling its own formatToolLine arguments.
|
|
12
|
+
//
|
|
13
|
+
// Phase 1 is a pure RE-ROUTING — the descriptor carries exactly the data the
|
|
14
|
+
// existing formatters consume, so the renderer can reproduce today's output
|
|
15
|
+
// byte-for-byte. Later phases collapse duplicates and fold in web/MCP/replay.
|
|
16
|
+
//
|
|
17
|
+
// Shape (all fields are plain data — no ANSI, no IO):
|
|
18
|
+
// {
|
|
19
|
+
// id invocation id (writer activity-region key), or null
|
|
20
|
+
// category short tag for the line ("file", "cmd", "net", …) — display only
|
|
21
|
+
// glyph indicator glyph for the status ('●' | '✓' | '✗')
|
|
22
|
+
// tag the ORIGINAL tool tag (formatToolLine normalizes it itself)
|
|
23
|
+
// target the thing acted on — command / path / url / arg, stated once
|
|
24
|
+
// attrs parsed tuple options (the operation detail source)
|
|
25
|
+
// status 'pending' | 'running' | 'ok' | 'error'
|
|
26
|
+
// durationMs elapsed/total ms, or null
|
|
27
|
+
// meta category-specific tail (exit_code, bytes, count, status_code)
|
|
28
|
+
// error error shape ({ message, code }), or null
|
|
29
|
+
// noDuration suppress the duration segment (blocking tools, e.g. ask_user)
|
|
30
|
+
// detail optional body, COLLAPSED per the Phase 5 detail policy:
|
|
31
|
+
// { kind: 'diff', payload: { before, after, path } }
|
|
32
|
+
// — file edits; rendered EXPANDED to diff_max_lines.
|
|
33
|
+
// { kind: 'output', payload: { body, category } }
|
|
34
|
+
// — shell/MCP/subagent SUCCESS output; rendered as a
|
|
35
|
+
// shell_preview_lines preview + `… N more lines`.
|
|
36
|
+
// or null. Errors carry NO detail here — the error body renders
|
|
37
|
+
// EXPANDED on the existing chat-history error path (unchanged).
|
|
38
|
+
// }
|
|
39
|
+
|
|
40
|
+
const { UI_ICONS, categoryForTag } = require('./theme');
|
|
41
|
+
const { extractDisplayBody } = require('./format');
|
|
42
|
+
|
|
43
|
+
// Category resolution (incl. action→tag normalization and the git_*/mcp__
|
|
44
|
+
// prefix rules) lives in theme.js so the descriptor's `category` and the
|
|
45
|
+
// rendered line's category can never diverge.
|
|
46
|
+
function _categoryFor(tag) {
|
|
47
|
+
return categoryForTag(tag);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Normalize an incoming status (runtime forms 'success'/'failure' OR descriptor
|
|
51
|
+
// forms 'ok'/'error') to the descriptor vocabulary. `error` present forces error.
|
|
52
|
+
function _normalizeStatus(status, error) {
|
|
53
|
+
if (status === 'pending') return 'pending';
|
|
54
|
+
if (status === 'running') return 'running';
|
|
55
|
+
if (status === 'error' || status === 'failure' || error) return 'error';
|
|
56
|
+
return 'ok';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _glyphFor(status) {
|
|
60
|
+
if (status === 'pending' || status === 'running') return UI_ICONS.pending;
|
|
61
|
+
if (status === 'error') return UI_ICONS.error;
|
|
62
|
+
return UI_ICONS.success;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Build the detail body per the Phase 5 policy. Order matters: errors carry no
|
|
66
|
+
// preview detail (the error body renders expanded elsewhere); a file edit's diff
|
|
67
|
+
// always wins over an output preview; otherwise a shell/MCP/subagent success
|
|
68
|
+
// with non-empty output gets an output-preview detail.
|
|
69
|
+
function _detailFor(spec, category, status) {
|
|
70
|
+
if (status === 'error') return null;
|
|
71
|
+
const { diff } = spec;
|
|
72
|
+
if (diff && typeof diff.before === 'string' && typeof diff.after === 'string') {
|
|
73
|
+
return { kind: 'diff', payload: { before: diff.before, after: diff.after, path: diff.path || '' } };
|
|
74
|
+
}
|
|
75
|
+
const previewable = category === 'shell' || category === 'mcp' || spec.tag === 'spawn_agent';
|
|
76
|
+
if (previewable && typeof spec.output === 'string') {
|
|
77
|
+
const body = extractDisplayBody(spec.output);
|
|
78
|
+
if (body && body.trim()) return { kind: 'output', payload: { body, category } };
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Produce the immutable descriptor. `spec` mirrors the data already available
|
|
84
|
+
// at the onToolStart/onToolEnd callbacks:
|
|
85
|
+
// { id, tag, arg, attrs, status, durationMs, meta, error, diff, noDuration }
|
|
86
|
+
// `tag`/`arg`/`attrs` are the parsed tuple (call[0], call[1], _attrsFromCall).
|
|
87
|
+
function buildToolOperation(spec) {
|
|
88
|
+
const s = spec || {};
|
|
89
|
+
const tag = s.tag || 'tool';
|
|
90
|
+
const status = _normalizeStatus(s.status, s.error);
|
|
91
|
+
const category = _categoryFor(tag);
|
|
92
|
+
const descriptor = {
|
|
93
|
+
id: s.id != null ? s.id : null,
|
|
94
|
+
category,
|
|
95
|
+
glyph: _glyphFor(status),
|
|
96
|
+
tag,
|
|
97
|
+
target: s.arg != null ? s.arg : '',
|
|
98
|
+
attrs: s.attrs || null,
|
|
99
|
+
status,
|
|
100
|
+
durationMs: Number.isFinite(s.durationMs) ? s.durationMs : null,
|
|
101
|
+
meta: s.meta || null,
|
|
102
|
+
error: s.error || null,
|
|
103
|
+
noDuration: !!s.noDuration,
|
|
104
|
+
detail: _detailFor(s, category, status),
|
|
105
|
+
};
|
|
106
|
+
return Object.freeze(descriptor);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Serialization for replay (Output Refactor — Phase 6) ─────────────────────
|
|
110
|
+
//
|
|
111
|
+
// A built descriptor is transient: it exists only for the duration of one live
|
|
112
|
+
// tool turn. To replay a saved session (/history, /chats, --resume) with the
|
|
113
|
+
// SAME fidelity a fresh turn shows — real diff, real duration, real status —
|
|
114
|
+
// the *terminal-state* core of the descriptor is persisted next to the tool
|
|
115
|
+
// result message (as a sibling `_display` key, see agent.js) and rebuilt here
|
|
116
|
+
// on load. Both helpers are pure data transforms (no ANSI, no IO).
|
|
117
|
+
//
|
|
118
|
+
// `serializeOperation(op)` extracts the plain-data core `renderOperation` reads
|
|
119
|
+
// for a COMPLETED (non-animating) op. The two live/animation-only fields are
|
|
120
|
+
// dropped:
|
|
121
|
+
// - id — the live activity-region key, meaningless once the turn ended.
|
|
122
|
+
// - glyph — recomputed from `status` by the renderer (formatToolLine).
|
|
123
|
+
// Everything the renderer DOES read for phase 'result' / 'detail' is kept:
|
|
124
|
+
// tag, target, attrs (the operation text is derived from attrs by _operation()
|
|
125
|
+
// in format.js — NOT in §1's field list, added here because the result line is
|
|
126
|
+
// not byte-identical without it), category, status, durationMs, meta, error,
|
|
127
|
+
// noDuration (suppresses the duration segment for blocking tools — also beyond
|
|
128
|
+
// §1's list, kept for the same byte-identity reason), and the collapsed detail.
|
|
129
|
+
const DISPLAY_FORMAT_VERSION = 1;
|
|
130
|
+
|
|
131
|
+
// `operationCore(op)` is the SINGLE descriptor→plain-data mapping shared by
|
|
132
|
+
// `serializeOperation` (persistence/replay) and `renderOperation`'s json/event
|
|
133
|
+
// modes (embeddable output — Phase 6d). It emits the descriptor's own vocabulary
|
|
134
|
+
// (tag/target/attrs/category/status/durationMs/meta/error/noDuration/detail) and
|
|
135
|
+
// nothing else — no `v` version tag (that is a persistence concern added by
|
|
136
|
+
// serializeOperation), no ANSI, no IO, no live/animation fields (id, glyph). The
|
|
137
|
+
// two live-only fields are dropped exactly as serializeOperation always dropped
|
|
138
|
+
// them: `id` (the activity-region key) and `glyph` (recomputed from `status`).
|
|
139
|
+
// Returns null for a non-object op so callers degrade gracefully.
|
|
140
|
+
function operationCore(op) {
|
|
141
|
+
if (!op || typeof op !== 'object') return null;
|
|
142
|
+
return {
|
|
143
|
+
tag: op.tag,
|
|
144
|
+
target: op.target,
|
|
145
|
+
attrs: op.attrs || null,
|
|
146
|
+
category: op.category,
|
|
147
|
+
status: op.status,
|
|
148
|
+
durationMs: Number.isFinite(op.durationMs) ? op.durationMs : null,
|
|
149
|
+
meta: op.meta || null,
|
|
150
|
+
error: op.error || null,
|
|
151
|
+
noDuration: !!op.noDuration,
|
|
152
|
+
detail: op.detail || null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function serializeOperation(op) {
|
|
157
|
+
const core = operationCore(op);
|
|
158
|
+
if (!core) return null;
|
|
159
|
+
// `v` first, then the shared core — byte-identical key order to the pre-6d
|
|
160
|
+
// hand-rolled object (replay/persistence depend on this exact shape).
|
|
161
|
+
return { v: DISPLAY_FORMAT_VERSION, ...core };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Rebuild a renderable descriptor from a persisted core. Returns null when the
|
|
165
|
+
// core is absent OR its version is unknown (`v` missing / not 1) — the caller
|
|
166
|
+
// then falls back to the legacy summarizeToolResult path, so old sessions and
|
|
167
|
+
// any future format degrade gracefully instead of crashing. The returned object
|
|
168
|
+
// mirrors buildToolOperation's output shape so renderOperation treats it
|
|
169
|
+
// identically to a fresh descriptor (it is not frozen — replay never mutates it).
|
|
170
|
+
function descriptorFromStored(core) {
|
|
171
|
+
if (!core || typeof core !== 'object') return null;
|
|
172
|
+
if (core.v !== DISPLAY_FORMAT_VERSION) return null;
|
|
173
|
+
const status = core.status || 'ok';
|
|
174
|
+
return {
|
|
175
|
+
id: null,
|
|
176
|
+
category: core.category,
|
|
177
|
+
glyph: _glyphFor(status),
|
|
178
|
+
tag: core.tag || 'tool',
|
|
179
|
+
target: core.target != null ? core.target : '',
|
|
180
|
+
attrs: core.attrs || null,
|
|
181
|
+
status,
|
|
182
|
+
durationMs: Number.isFinite(core.durationMs) ? core.durationMs : null,
|
|
183
|
+
meta: core.meta || null,
|
|
184
|
+
error: core.error || null,
|
|
185
|
+
noDuration: !!core.noDuration,
|
|
186
|
+
detail: core.detail || null,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { buildToolOperation, operationCore, serializeOperation, descriptorFromStored };
|
package/lib/ui/utils.js
CHANGED
|
@@ -45,13 +45,17 @@ function termWidth(str) {
|
|
|
45
45
|
// Walks the string once; CSI escape sequences (`\x1b[…final`) are copied
|
|
46
46
|
// through verbatim without consuming any visible width. Visible-width math
|
|
47
47
|
// uses _cpWidth, so a 2-col CJK glyph or emoji counts as 2 and combining
|
|
48
|
-
// marks count as 0.
|
|
49
|
-
//
|
|
50
|
-
//
|
|
48
|
+
// marks count as 0. Terminates with `\x1b[0m` ONLY when the (possibly
|
|
49
|
+
// truncated) output actually contains an escape, so a cut that left an SGR
|
|
50
|
+
// span open is closed without leaving the terminal colored at the cut point.
|
|
51
|
+
// When the output is escape-free we append nothing: a trailing reset on
|
|
52
|
+
// escape-free text is a stray escape that leaks through NO_COLOR. The gate is
|
|
53
|
+
// content-driven (out.indexOf('\x1b')), not flag-driven, so a preview body
|
|
54
|
+
// line carrying a child process's own ANSI is still closed defensively.
|
|
51
55
|
function truncateVisible(str, maxCols) {
|
|
52
56
|
if (!str) return '';
|
|
53
57
|
const max = Math.max(0, maxCols | 0);
|
|
54
|
-
if (max === 0) return '
|
|
58
|
+
if (max === 0) return '';
|
|
55
59
|
const s = String(str);
|
|
56
60
|
const len = s.length;
|
|
57
61
|
let out = '';
|
|
@@ -82,7 +86,7 @@ function truncateVisible(str, maxCols) {
|
|
|
82
86
|
cols += w;
|
|
83
87
|
i += clen;
|
|
84
88
|
}
|
|
85
|
-
return out + '\x1b[0m';
|
|
89
|
+
return out.indexOf('\x1b') !== -1 ? out + '\x1b[0m' : out;
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
// Repeat a single-column glyph so the visible row is exactly `width` columns.
|
package/lib/ui/web-activity.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// before. Scope: `web_search` + `http_get`. `download` is a file-save, not a page
|
|
19
19
|
// read for the search→fetch flow, so it keeps its own line.
|
|
20
20
|
|
|
21
|
-
const { UI_THEME, UI_ICONS } = require('./theme');
|
|
21
|
+
const { UI_THEME, UI_ICONS, resolveLineColors } = require('./theme');
|
|
22
22
|
const { RST, DIM } = require('./ansi');
|
|
23
23
|
const { formatDuration } = require('./format');
|
|
24
24
|
|
|
@@ -45,6 +45,54 @@ function opSucceeded(op) {
|
|
|
45
45
|
return true;
|
|
46
46
|
}
|
|
47
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
|
+
|
|
48
96
|
// Pure: fold the recorded op list into the counts the summary needs.
|
|
49
97
|
function aggregateWebOps(ops) {
|
|
50
98
|
const state = {
|
|
@@ -99,20 +147,22 @@ function webSummaryText(state) {
|
|
|
99
147
|
// summary reads as a peer of the other tool lines, not a foreign widget.
|
|
100
148
|
function formatWebSummaryLine(state, opts) {
|
|
101
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');
|
|
102
154
|
const glyph = pending ? UI_ICONS.pending : UI_ICONS.success;
|
|
103
|
-
const glyphColor = pending ? UI_THEME.muted : UI_THEME.success;
|
|
104
155
|
const cat = 'web'.padEnd(5);
|
|
105
|
-
const catColor = (UI_THEME.categories && UI_THEME.categories.web) || UI_THEME.accent;
|
|
106
156
|
const sep = ` ${DIM}·${RST} `;
|
|
107
157
|
|
|
108
|
-
const out = [` ${
|
|
158
|
+
const out = [` ${colors.glyph}${glyph}${RST} ${colors.label}${cat}${RST}`];
|
|
109
159
|
for (const seg of webSummarySegments(state)) {
|
|
110
160
|
let color = UI_THEME.subtle;
|
|
111
|
-
if (seg.kind === 'lead') color =
|
|
161
|
+
if (seg.kind === 'lead') color = colors.op;
|
|
112
162
|
else if (seg.kind === 'fail') color = UI_THEME.warning;
|
|
113
163
|
out.push(`${color}${seg.text}${RST}`);
|
|
114
164
|
}
|
|
115
|
-
if (pending) out.push(`${
|
|
165
|
+
if (pending) out.push(`${colors.dur}${formatDuration(durationMs)}…${RST}`);
|
|
116
166
|
return out.join(sep);
|
|
117
167
|
}
|
|
118
168
|
|
|
@@ -208,6 +258,8 @@ function createWebActivityTracker(deps) {
|
|
|
208
258
|
module.exports = {
|
|
209
259
|
WEB_TOOLS,
|
|
210
260
|
isWebTool,
|
|
261
|
+
serializeWebOp,
|
|
262
|
+
isWebCore,
|
|
211
263
|
opSucceeded,
|
|
212
264
|
aggregateWebOps,
|
|
213
265
|
webSummarySegments,
|
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,
|