@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.
Files changed (83) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/ARCHITECTURE.md +6 -95
  3. package/CLAUDE.md +196 -1874
  4. package/README.md +1 -1
  5. package/docs/ARCHITECTURE.md +1321 -0
  6. package/docs/CONFIG.md +340 -0
  7. package/docs/HISTORY.md +245 -0
  8. package/index.js +1 -1
  9. package/lib/agent.js +145 -16
  10. package/lib/api.js +28 -3
  11. package/lib/commands/chat-session.js +188 -4
  12. package/lib/commands/chat-slash.js +16 -0
  13. package/lib/commands/chat-turn.js +319 -52
  14. package/lib/commands/chat.js +12 -8
  15. package/lib/config.js +27 -0
  16. package/lib/constants.js +30 -1
  17. package/lib/headless.js +36 -1
  18. package/lib/images.js +8 -2
  19. package/lib/permissions.js +23 -16
  20. package/lib/prompts.js +15 -3
  21. package/lib/tool_registry.js +357 -53
  22. package/lib/tool_specs.js +42 -8
  23. package/lib/tools.js +80 -19
  24. package/lib/ui/anim.js +86 -0
  25. package/lib/ui/ansi.js +17 -27
  26. package/lib/ui/chat-history.js +253 -71
  27. package/lib/ui/create-ui.js +67 -24
  28. package/lib/ui/diff.js +90 -25
  29. package/lib/ui/file-activity.js +229 -0
  30. package/lib/ui/format.js +173 -28
  31. package/lib/ui/input-field.js +5 -4
  32. package/lib/ui/md-stream.js +234 -0
  33. package/lib/ui/render-operation.js +113 -0
  34. package/lib/ui/select.js +1 -4
  35. package/lib/ui/status-bar.js +99 -57
  36. package/lib/ui/stream.js +20 -13
  37. package/lib/ui/theme.js +190 -45
  38. package/lib/ui/tool-operation.js +190 -0
  39. package/lib/ui/utils.js +9 -5
  40. package/lib/ui/web-activity.js +58 -6
  41. package/lib/ui/writer.js +159 -45
  42. package/lib/ui.js +1 -1
  43. package/package.json +1 -1
  44. package/test/anim-driver.test.js +153 -0
  45. package/test/ask-user-display.test.js +226 -0
  46. package/test/ask-user-gate.test.js +231 -0
  47. package/test/chat-history-nocolor.test.js +155 -0
  48. package/test/chat-relogin.test.js +207 -0
  49. package/test/defer-detail-band.test.js +403 -0
  50. package/test/detail-band-tab-flatten.test.js +242 -0
  51. package/test/exec-diff.test.js +268 -0
  52. package/test/executors.test.js +250 -13
  53. package/test/extract-tool-calls.test.js +37 -3
  54. package/test/file-activity.test.js +542 -0
  55. package/test/grep-path-target.test.js +227 -0
  56. package/test/harness/chat-harness.js +2 -1
  57. package/test/headless.test.js +146 -1
  58. package/test/input-field-ctrl-o.test.js +37 -0
  59. package/test/live-height-physical.test.js +281 -0
  60. package/test/max-iterations.test.js +9 -7
  61. package/test/md-stream.test.js +183 -0
  62. package/test/narration-ordering.test.js +309 -0
  63. package/test/native-dispatch.test.js +53 -0
  64. package/test/native-live-narration.test.js +254 -0
  65. package/test/output-heredoc-leak.test.js +195 -0
  66. package/test/output-preview.test.js +245 -0
  67. package/test/permission-flush.test.js +302 -0
  68. package/test/permissions.test.js +199 -0
  69. package/test/read-paginate.test.js +1 -1
  70. package/test/render-operation.test.js +317 -0
  71. package/test/replay-descriptor-xml.test.js +216 -0
  72. package/test/replay-descriptor.test.js +189 -0
  73. package/test/replay-web-aggregate.test.js +291 -0
  74. package/test/replay-web-persist.test.js +241 -0
  75. package/test/running-glyph-anim.test.js +111 -0
  76. package/test/status-bar-driver.test.js +93 -0
  77. package/test/status-bar-resync.test.js +188 -0
  78. package/test/stream-parser.test.js +24 -0
  79. package/test/theme-palette.test.js +166 -0
  80. package/test/truncate-visible.test.js +78 -0
  81. package/test/view-image.test.js +199 -0
  82. package/test/web-activity-ordering.test.js +12 -3
  83. 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. Always terminates with `\x1b[0m` so the terminal isn't
49
- // left in a colored state at the truncation point (safe when no escape
50
- // preceded the cut a redundant reset is a no-op).
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 '\x1b[0m';
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.
@@ -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 = [` ${glyphColor}${glyph}${RST} ${catColor}${cat}${RST}`];
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 = UI_THEME.default;
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(`${UI_THEME.muted}${formatDuration(durationMs)}…${RST}`);
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 modalLines.length +
19
- // statusLines.length and precise.
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. Wrap-induced row-count drift is the root cause
73
- // of stale-line residue in scrollback: if a single logical line wraps to
74
- // two physical rows, _liveHeight (which tracks logical lines) undercounts,
75
- // and the next _eraseLiveSeq leaves the top wrapped row behind. Using
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
- function _eraseLiveSeq() {
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
- out += _fitOneRow(activityRows[i]);
131
- out += '\n';
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
- out += _fitOneRow(_modalLines[i]);
135
- out += '\n';
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
- out += _fitOneRow(_liveLines[i]);
139
- out += '\n';
220
+ const fitted = _fitOneRow(_liveLines[i]);
221
+ emitted.push(fitted);
222
+ out += fitted + '\n';
140
223
  }
141
- _liveHeight = total;
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
- // refreshActivity() forces a redraw so the lazy `render(elapsedMs)` on
301
- // each entry picks up a new elapsed time. Called by the status-bar ticker;
302
- // a no-op when nothing is visible.
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. Subtract
508
- // that offset so cursor-up lands on the FIRST live row, not above the
509
- // live region (which would erase scrollback). After \x1b[J the cursor
510
- // sits at column 0 of the row immediately after the last scrollback
511
- // line — exactly where new scrollback content should begin.
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
- const offset = _caret ? _caret.rowsFromBottom : 0;
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,