@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
@@ -1,11 +1,55 @@
1
1
  'use strict';
2
2
 
3
- const { RST, DIM, BOLD, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_DARK, FG_GRAY } = require('./ansi');
3
+ const { RST, DIM, BOLD, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_GRAY } = require('./ansi');
4
4
  const { getCols, stripAnsi } = require('./utils');
5
- const { UI_THEME, UI_ICONS, TOOL_CATEGORIES } = require('./theme');
6
- const { summarizeToolResult } = require('./format');
5
+ const { UI_THEME, UI_ICONS, TOOL_CATEGORIES, colorize, colorEnabled } = require('./theme');
6
+ const { StreamMarkdown, renderBlock } = require('./md-stream');
7
+ const { summarizeToolResult, formatOutputPreview } = require('./format');
8
+ const { descriptorFromStored } = require('./tool-operation');
9
+ const { isWebCore } = require('./web-activity');
10
+ const { renderOperation } = require('./render-operation');
7
11
  const writer = require('./writer');
8
12
 
13
+ // Gate a raw SGR code through the single NO_COLOR/non-TTY switch (theme.js).
14
+ // Same idiom as diff.js / md-stream.js — wrap every color-constant interpolation
15
+ // in c(...) so UI chrome emits zero ANSI under NO_COLOR while glyphs/layout stay.
16
+ const c = colorize;
17
+
18
+ // Embedded control chars that would break the "one logical line == one physical
19
+ // row" invariant the detail-band erase/commit math rests on. Mirrors the
20
+ // writer's _CONTROL_CHARS (writer.js): \t (TAB) advances to the next 8-col tab
21
+ // stop, \r/C0/DEL move the cursor unpredictably. ESC (0x1B) is EXCLUDED so SGR
22
+ // color survives — _flattenBandLine strips the non-SGR escapes separately.
23
+ const _BAND_CONTROL_CHARS = /[\x00-\x1A\x1C-\x1F\x7F]/g; // eslint-disable-line no-control-regex
24
+ // A CSI sequence: ESC '[' params final-letter. Used to drop the NON-SGR ones.
25
+ const _CSI_SEQ = /\x1b\[[0-9;?]*[A-Za-z]/g; // eslint-disable-line no-control-regex
26
+
27
+ // Flatten an already-fitted output-preview body line so it occupies EXACTLY one
28
+ // physical terminal row — restoring the invariant the detail band's logical-line
29
+ // clamp and the writer's physicalRows erase math both depend on. formatOutputPreview
30
+ // truncated the line to the render width, but it (via termWidth) counts a TAB as
31
+ // 1 column while the terminal advances it to the next 8-col tab stop, so a
32
+ // "fitted" TAB-bearing line still WRAPS to ≥2 rows. Two flattening steps, both
33
+ // no-ops on plain ASCII (so clean output stays byte-identical):
34
+ // 1. Replace every C0/DEL control (TAB included) with a space — 1:1, so the
35
+ // already-fitted visible width is unchanged. Identical to the writer's
36
+ // _fitOneRow control-char pass that every OTHER live row already gets.
37
+ // 2. Drop stray NON-SGR escapes (e.g. `\x1b[K`): displayRows/termWidth strip
38
+ // only `…m` sequences, so a surviving non-'m' CSI would inflate the measured
39
+ // width past cols and re-introduce a phantom second row. SGR (`…m`) is kept
40
+ // so captured color survives. This closes the stripAnsi-only-'m' gap.
41
+ function _flattenBandLine(line) {
42
+ let s = String(line == null ? '' : line);
43
+ if (_BAND_CONTROL_CHARS.test(s)) {
44
+ _BAND_CONTROL_CHARS.lastIndex = 0; // .test on a /g regex is stateful
45
+ s = s.replace(_BAND_CONTROL_CHARS, ' ');
46
+ }
47
+ if (s.indexOf('\x1b') !== -1) {
48
+ s = s.replace(_CSI_SEQ, (m) => (m.endsWith('m') ? m : ''));
49
+ }
50
+ return s;
51
+ }
52
+
9
53
 
10
54
  function safeContent(text) {
11
55
  if (!text) return '';
@@ -31,9 +75,9 @@ function _dimPaths(text) {
31
75
  if (trail) {
32
76
  const core = m.slice(0, -trail[0].length);
33
77
  if (!core) return m;
34
- return `${UI_THEME.subtle}${core}${UI_THEME.default}${trail[0]}`;
78
+ return `${c(UI_THEME.subtle)}${core}${c(UI_THEME.default)}${trail[0]}`;
35
79
  }
36
- return `${UI_THEME.subtle}${m}${UI_THEME.default}`;
80
+ return `${c(UI_THEME.subtle)}${m}${c(UI_THEME.default)}`;
37
81
  });
38
82
  }
39
83
 
@@ -54,9 +98,13 @@ const BG_USER = '\x1b[48;5;237m';
54
98
  // atomic chunk.
55
99
  function _buildUser(content, ts) {
56
100
  const time = _fmtTime(ts);
57
- let out = `\n${FG_CYAN}▸ You${RST} ${DIM}${time}${RST}\n`;
101
+ let out = `\n${c(FG_CYAN)}▸ You${c(RST)} ${c(DIM)}${time}${c(RST)}\n`;
58
102
  for (const line of (content || '').split('\n')) {
59
- out += `${BG_USER} ${line}\x1b[K${RST}\n`;
103
+ // The \x1b[K (erase-to-EOL) only exists to fill the bg tint to the screen
104
+ // edge — pointless with no bg, and it's not an SGR so stripAnsi can't reach
105
+ // it. Drop it when color is off so a NO_COLOR bubble carries ZERO escapes.
106
+ const el = colorEnabled() ? '\x1b[K' : '';
107
+ out += `${c(BG_USER)} ${line}${el}${c(RST)}\n`;
60
108
  }
61
109
  out += '\n';
62
110
  return out;
@@ -64,10 +112,12 @@ function _buildUser(content, ts) {
64
112
 
65
113
  function _buildAI(content, ts) {
66
114
  const time = _fmtTime(ts);
67
- let out = `\n${FG_GREEN}▸ AI-agent${RST} ${DIM}${time}${RST}\n`;
68
- for (const line of (content || '').split('\n')) {
69
- out += ` ${line}\n`;
70
- }
115
+ let out = `\n${colorize(FG_GREEN)}▸ AI-agent${colorize(RST)} ${colorize(DIM)}${time}${colorize(RST)}\n`;
116
+ // Drive the SAME StreamMarkdown the live streaming path uses, fed line-by-line
117
+ // then flushed, so a replayed (--resume / history) assistant turn renders
118
+ // byte-identical to the live one.
119
+ const block = renderBlock(content);
120
+ if (block) out += block + '\n';
71
121
  out += '\n';
72
122
  return out;
73
123
  }
@@ -82,10 +132,18 @@ class ChatHistory {
82
132
  this._didStream = false;
83
133
  this._msgById = {};
84
134
  this._msgLineCount = {};
85
- this._toolExpanded = {};
86
- this._lastExpandableId = null;
87
- this._toolIdCounter = 0;
88
135
  this._messages = [];
136
+ // The single deferred output-preview slot. While set, the held detail lives
137
+ // in the writer's redrawable detail band (not scrollback). Shape: { msg,
138
+ // lines }. Committed once at a turn boundary (or auto-flushed before any new
139
+ // scrollback this class emits), then cleared back to null. The band is
140
+ // installed once and committed once — never redrawn in place.
141
+ this._deferred = null;
142
+ // Per-instance Markdown styler for agent narration. Holds the cross-line
143
+ // fenced-code-block state so a ``` block split across streamToken calls is
144
+ // styled correctly; reset at every turn boundary (clearStreamingContent /
145
+ // clearMessages).
146
+ this._md = new StreamMarkdown();
89
147
  // Callback into the UI orchestrator to refresh the live region (so the
90
148
  // in-progress streaming line and chrome re-render together). Set by
91
149
  // createUI; safe to leave null for headless/test contexts.
@@ -106,6 +164,23 @@ class ChatHistory {
106
164
  isStreaming() { return this._streamActive; }
107
165
 
108
166
  _commit(text) { writer.scrollback(text); }
167
+ // Phase 7b detail-band seams (overridable in tests, like _commit): install /
168
+ // replace the live detail band, and atomically commit it to scrollback.
169
+ _setDetail(lines) { writer.setDetail(lines); }
170
+ _commitDetail(text) { writer.commitDetail(text); }
171
+
172
+ // Commit an ALREADY-STYLED single line straight to scrollback, with no
173
+ // reformatting and without touching `_messages` / live-region state. Used by
174
+ // replay (Phase 6c-ii) to land an aggregated `✓ web · …` web-activity summary
175
+ // exactly as the live path's `writer.endActivity(id, line)` does — both route
176
+ // the same styled string through `writer.scrollback` (which appends the single
177
+ // trailing newline), so a replayed web summary is byte-identical to the live
178
+ // committed one. Deliberately NOT funnelled through summarizeToolResult.
179
+ addRawLine(line) {
180
+ if (line == null || line === '') return;
181
+ this._flushStream();
182
+ this._commit(line);
183
+ }
109
184
 
110
185
  _notifyLive() {
111
186
  if (this._onLiveUpdate) {
@@ -118,6 +193,11 @@ class ChatHistory {
118
193
  const content = safeContent(msg.content || '');
119
194
  if ((msg.role === 'assistant' || msg.role === 'system') && !content.trim()) return;
120
195
 
196
+ // Phase 7b — any held detail band is the PREVIOUS op's output; commit it to
197
+ // scrollback before this new bubble so ordering is preserved (tool output
198
+ // above the message that follows it). No-op when nothing is deferred.
199
+ if (this._deferred) this.commitDeferredDetail();
200
+
121
201
  // Any in-progress stream must close before we emit a new scrollback
122
202
  // block — otherwise the stream's partial line would sit between the
123
203
  // header and body of the new bubble.
@@ -135,10 +215,14 @@ class ChatHistory {
135
215
  } else if (msg.role === 'shell') {
136
216
  const cmd = msg.cmd || '';
137
217
  const shellOut = safeContent(msg.content || '');
138
- out = `\n${DIM} $ ${cmd}${RST}\n`;
218
+ // Replayed shell preview (/history, --resume). Was wholesale-DIM and
219
+ // illegible; now the `$` command line carries the shell category colour
220
+ // and the output is subtle (one step up from dim) so it stays readable.
221
+ const shellColor = (UI_THEME.categories && UI_THEME.categories.shell) || UI_THEME.accent;
222
+ out = `\n ${c(shellColor)}$ ${cmd}${c(RST)}\n`;
139
223
  if (shellOut.trim()) {
140
224
  for (const line of shellOut.split('\n')) {
141
- if (line.trim()) out += `${DIM} ${line}${RST}\n`;
225
+ if (line.trim()) out += ` ${c(UI_THEME.subtle)}${line}${c(RST)}\n`;
142
226
  }
143
227
  }
144
228
  out += '\n';
@@ -154,23 +238,53 @@ class ChatHistory {
154
238
  // to a single live-activity-style line. Callers that pass `output`
155
239
  // (debug blocks, live-activity error pass-through) keep the legacy
156
240
  // header chrome.
157
- if (msg.content && !msg.output) {
241
+ //
242
+ // Phase 6a — replay fidelity. A saved native tool message may carry a
243
+ // serialized display descriptor (`_display`). When present and its
244
+ // version is known, render the result line (and any diff / output detail)
245
+ // through the SAME renderOperation a fresh turn uses — full fidelity
246
+ // (real diff, real duration, real status), not the lossy summary. A
247
+ // missing / unknown-version core yields null here → the legacy
248
+ // summarizeToolResult fall-through below, unchanged.
249
+ // Phase 6c-i — a web op now persists a {v:1,kind:'web',…} core in `_display`
250
+ // (it used to persist nothing). Treat a web-core as "no descriptor" so it
251
+ // takes the SAME legacy summarizeToolResult fall-through a web message took
252
+ // before 6c-i — byte-identical until 6c-ii flips this to aggregation.
253
+ const restored = (msg._display && !isWebCore(msg._display)) ? descriptorFromStored(msg._display) : null;
254
+ if (restored) {
255
+ out = renderOperation(restored, { mode: 'ansi', phase: 'result' }) + '\n';
256
+ lineCount = 1;
257
+ const detail = restored.detail;
258
+ if (detail && detail.kind === 'diff') {
259
+ // The file-edit diff, expanded exactly as the live path commits it.
260
+ const diffStr = renderOperation(restored, { mode: 'ansi', phase: 'detail', maxLines: msg.diffMaxLines });
261
+ if (diffStr) {
262
+ out += diffStr + '\n';
263
+ lineCount += diffStr.split('\n').length;
264
+ }
265
+ } else if (detail && detail.kind === 'output') {
266
+ // Route the shell/MCP/subagent preview body through the SAME output
267
+ // branch below (identical DIM/indent chrome + collapsed hint).
268
+ msg.output = detail.payload.body;
269
+ if (!Number.isInteger(msg.previewLines) || msg.previewLines <= 0) msg.previewLines = 5;
270
+ }
271
+ } else if (msg.content && !msg.output) {
158
272
  const summary = safeContent(summarizeToolResult(msg.content));
159
273
  if (summary) {
160
274
  const indicator = msg.isError
161
- ? `${UI_THEME.error}${UI_ICONS.error}${RST}`
162
- : `${UI_THEME.success}${UI_ICONS.success}${RST}`;
163
- const sep = ` ${DIM}·${RST} `;
275
+ ? `${c(UI_THEME.error)}${UI_ICONS.error}${c(RST)}`
276
+ : `${c(UI_THEME.success)}${UI_ICONS.success}${c(RST)}`;
277
+ const sep = ` ${c(DIM)}·${c(RST)} `;
164
278
  const styled = summary.split(' · ').map((p) => _dimPaths(p)).join(sep);
165
279
  out = ` ${indicator} ${styled}\n`;
166
280
  lineCount = 1;
167
281
  }
168
282
  } else if (content) {
169
283
  const indicator = msg.isError
170
- ? `${UI_THEME.error}${UI_ICONS.error}${RST}`
171
- : `${UI_THEME.success}${UI_ICONS.success}${RST}`;
284
+ ? `${c(UI_THEME.error)}${UI_ICONS.error}${c(RST)}`
285
+ : `${c(UI_THEME.success)}${UI_ICONS.success}${c(RST)}`;
172
286
  const category = TOOL_CATEGORIES[msg.tag] || msg.tag || 'tool';
173
- const head = `${UI_THEME.accent}${category}${UI_THEME.muted}:${RST}`;
287
+ const head = `${c(UI_THEME.accent)}${category}${c(UI_THEME.muted)}:${c(RST)}`;
174
288
  const desc = _dimPaths(content);
175
289
  out = ` ${indicator} ${head} ${desc}\n`;
176
290
  lineCount = 1;
@@ -178,7 +292,18 @@ class ChatHistory {
178
292
  out = '';
179
293
  lineCount = 0;
180
294
  }
181
- if (msg.output) {
295
+ if (msg.output && Number.isInteger(msg.previewLines) && msg.previewLines > 0) {
296
+ // Phase 5 — collapsed output preview (shell/MCP/subagent). The body is
297
+ // committed line-by-line to SCROLLBACK (not the live region), each line
298
+ // fitted to one physical row by formatOutputPreview, so the Phase-4
299
+ // single-row invariant holds. Rendered ONCE, statically: the first
300
+ // previewLines + a `… N more lines` hint, with no interactive affordance.
301
+ const { lines: pvLines } = this._renderOutputPreviewLines(msg);
302
+ for (const ol of pvLines) {
303
+ out += ol + '\n';
304
+ lineCount++;
305
+ }
306
+ } else if (msg.output) {
182
307
  const wrapAt = Math.max(60, getCols() - 8);
183
308
  const outLines = [];
184
309
  for (const raw of msg.output.split('\n')) {
@@ -192,32 +317,19 @@ class ChatHistory {
192
317
  }
193
318
  }
194
319
  const truncatable = outLines.length > MAX_TOOL_DISPLAY;
195
-
196
- if (truncatable && !msg.id) {
197
- msg.id = `tool-${++this._toolIdCounter}`;
198
- }
199
- if (truncatable && msg.id) {
200
- this._msgById[msg.id] = msg;
201
- this._lastExpandableId = msg.id;
202
- }
203
-
204
- const expanded = truncatable && msg.id && this._toolExpanded[msg.id];
205
- const visible = (truncatable && !expanded) ? outLines.slice(0, MAX_TOOL_DISPLAY) : outLines;
320
+ const visible = truncatable ? outLines.slice(0, MAX_TOOL_DISPLAY) : outLines;
206
321
  const remaining = outLines.length - visible.length;
207
322
 
208
- const outerOpen = msg.tag === 'debug' ? ' ' : `${DIM} `;
209
- const outerClose = msg.tag === 'debug' ? '' : RST;
323
+ const outerOpen = msg.tag === 'debug' ? ' ' : `${c(DIM)} `;
324
+ const outerClose = msg.tag === 'debug' ? '' : c(RST);
210
325
  for (const ol of visible) { out += `${outerOpen}${ol}${outerClose}\n`; lineCount++; }
211
326
  if (remaining > 0) {
212
- out += `${DIM} … ${remaining} more lines ${FG_DARK}(ctrl+o to expand ${remaining} rows)${RST}\n`;
213
- lineCount++;
214
- } else if (truncatable && expanded) {
215
- out += `${DIM} ${FG_DARK}(ctrl+o to collapse)${RST}\n`;
327
+ out += `${c(DIM)} … ${remaining} more lines${c(RST)}\n`;
216
328
  lineCount++;
217
329
  }
218
330
  }
219
331
  } else if (msg.role === 'permission') {
220
- out = `\n ${UI_THEME.warning}${UI_ICONS.warn}${RST} ${UI_THEME.warning}Permission required${RST}${UI_THEME.muted}:${RST} ${content}\n`;
332
+ out = `\n ${c(UI_THEME.warning)}${UI_ICONS.warn}${c(RST)} ${c(UI_THEME.warning)}Permission required${c(RST)}${c(UI_THEME.muted)}:${c(RST)} ${content}\n`;
221
333
  } else {
222
334
  // Fallback for role: 'system' and anything unrecognised.
223
335
  const lines = content.split('\n');
@@ -225,25 +337,25 @@ class ChatHistory {
225
337
  const stripGlyph = (s) => s.replace(/^[✓✗✕⚠]\s*/, '');
226
338
  let rendered;
227
339
  if (msg.isError && !msg.isWarning) {
228
- rendered = ` ${UI_THEME.error}${UI_ICONS.error}${RST} ${UI_THEME.error}${stripGlyph(first)}${RST}`;
340
+ rendered = ` ${c(UI_THEME.error)}${UI_ICONS.error}${c(RST)} ${c(UI_THEME.error)}${stripGlyph(first)}${c(RST)}`;
229
341
  } else if (msg.isWarning) {
230
- rendered = ` ${UI_THEME.warning}${UI_ICONS.warn}${RST} ${UI_THEME.warning}${stripGlyph(first)}${RST}`;
342
+ rendered = ` ${c(UI_THEME.warning)}${UI_ICONS.warn}${c(RST)} ${c(UI_THEME.warning)}${stripGlyph(first)}${c(RST)}`;
231
343
  } else if (_isAutoApproveMeta(first)) {
232
- const body = first.replace(/(auto-approv\w*)/gi, (m) => `${UI_THEME.warning}${m}${UI_THEME.subtle}`);
233
- rendered = ` ${UI_THEME.subtle}${body}${RST}`;
344
+ const body = first.replace(/(auto-approv\w*)/gi, (m) => `${c(UI_THEME.warning)}${m}${c(UI_THEME.subtle)}`);
345
+ rendered = ` ${c(UI_THEME.subtle)}${body}${c(RST)}`;
234
346
  } else if (first.startsWith('✓')) {
235
- rendered = ` ${UI_THEME.success}${UI_ICONS.success}${RST} ${first.slice(1).trimStart()}`;
347
+ rendered = ` ${c(UI_THEME.success)}${UI_ICONS.success}${c(RST)} ${first.slice(1).trimStart()}`;
236
348
  } else if (first.startsWith('✗')) {
237
- rendered = ` ${UI_THEME.error}${UI_ICONS.error}${RST} ${UI_THEME.error}${first.slice(1).trimStart()}${RST}`;
349
+ rendered = ` ${c(UI_THEME.error)}${UI_ICONS.error}${c(RST)} ${c(UI_THEME.error)}${first.slice(1).trimStart()}${c(RST)}`;
238
350
  } else {
239
- rendered = ` ${UI_THEME.subtle}${UI_ICONS.bullet}${RST} ${UI_THEME.subtle}${first}${RST}`;
351
+ rendered = ` ${c(UI_THEME.subtle)}${UI_ICONS.bullet}${c(RST)} ${c(UI_THEME.subtle)}${first}${c(RST)}`;
240
352
  }
241
353
  out = `${rendered}\n`;
242
354
  const contColor = (msg.isError && !msg.isWarning) ? UI_THEME.error
243
355
  : msg.isWarning ? UI_THEME.warning
244
356
  : UI_THEME.subtle;
245
357
  for (let i = 1; i < lines.length; i++) {
246
- out += ` ${contColor}${lines[i]}${RST}\n`;
358
+ out += ` ${c(contColor)}${lines[i]}${c(RST)}\n`;
247
359
  }
248
360
  lineCount = lines.length;
249
361
  }
@@ -260,12 +372,15 @@ class ChatHistory {
260
372
  // the live region while it's still in-flight.
261
373
  streamToken(token) {
262
374
  if (!token) return;
375
+ // Phase 7b — the assistant answer is about to flow into scrollback; commit
376
+ // the prior op's held detail band first so it stays above the answer text.
377
+ if (this._deferred) this.commitDeferredDetail();
263
378
  if (!this._streamActive) {
264
379
  // Emit header immediately so users see the new AI turn before any
265
380
  // tokens arrive. No blank line before the header since scrollback
266
381
  // flows continuously.
267
382
  const time = _fmtTime(new Date());
268
- this._commit(`\n${FG_GREEN}▸ AI-agent${RST} ${DIM}${time}${RST}\n`);
383
+ this._commit(`\n${colorize(FG_GREEN)}▸ AI-agent${colorize(RST)} ${colorize(DIM)}${time}${colorize(RST)}\n`);
269
384
  this._streamActive = true;
270
385
  this._streamStart = new Date();
271
386
  this._streamPartial = '';
@@ -274,7 +389,11 @@ class ChatHistory {
274
389
  let nlIdx;
275
390
  while ((nlIdx = this._streamPartial.indexOf('\n')) >= 0) {
276
391
  const line = this._streamPartial.slice(0, nlIdx);
277
- this._commit(' ' + line + '\n');
392
+ // Style each COMPLETE line before committing to scrollback. The styler
393
+ // returns null while buffering a fenced code-block body (the box is
394
+ // emitted whole at the closing ```), so nothing is committed then.
395
+ const styled = this._md.feedLine(line);
396
+ if (styled !== null) this._commit(styled + '\n');
278
397
  this._streamPartial = this._streamPartial.slice(nlIdx + 1);
279
398
  }
280
399
  this._notifyLive();
@@ -283,9 +402,17 @@ class ChatHistory {
283
402
  _flushStream() {
284
403
  if (!this._streamActive) return;
285
404
  if (this._streamPartial !== '') {
286
- this._commit(' ' + this._streamPartial + '\n');
405
+ // The trailing partial is the turn's last (newline-less) line — style it
406
+ // as a complete line, mirroring the live per-line path exactly.
407
+ const styled = this._md.feedLine(this._streamPartial);
408
+ if (styled !== null) this._commit(styled + '\n');
287
409
  this._streamPartial = '';
288
410
  }
411
+ // Close any open inline span / still-buffering fenced code block so no
412
+ // stranded SGR or unclosed box reaches immutable scrollback.
413
+ const tail = this._md.flush();
414
+ if (tail !== null) this._commit(tail + '\n');
415
+ this._md.reset();
289
416
  this._commit('\n');
290
417
  this._streamActive = false;
291
418
  this._streamStart = null;
@@ -294,10 +421,14 @@ class ChatHistory {
294
421
 
295
422
  clearStreamingContent() {
296
423
  this._flushStream();
424
+ this._md.reset();
297
425
  this._notifyLive();
298
426
  }
299
427
 
300
428
  finalizeLastMessage(cleanContent) {
429
+ // Phase 7b — commit any held detail band before the terminal assistant
430
+ // bubble (covers the non-streaming path, where no streamToken fired).
431
+ if (this._deferred) this.commitDeferredDetail();
301
432
  const wasStreaming = this._streamActive;
302
433
  const streamStart = this._streamStart;
303
434
  if (wasStreaming) {
@@ -329,9 +460,12 @@ class ChatHistory {
329
460
  this._didStream = false;
330
461
  this._msgById = {};
331
462
  this._msgLineCount = {};
332
- this._toolExpanded = {};
333
- this._lastExpandableId = null;
334
463
  this._messages = [];
464
+ this._md.reset();
465
+ // Drop any held detail band (it belongs to the cleared session)
466
+ // and erase it from the writer's detail region before the viewport wipe.
467
+ this._deferred = null;
468
+ this._setDetail([]);
335
469
  // Wipe the terminal's visible scrollback and the saved scrollback buffer
336
470
  // so the cleared state feels clean. Serialized through the writer so
337
471
  // the clear can't land inside a pending scrollback burst, then redraw
@@ -344,18 +478,69 @@ class ChatHistory {
344
478
  this._notifyLive();
345
479
  }
346
480
 
347
- // In append-only scrollback we cannot rewrite a previous bubble in place;
348
- // these methods now simply re-render the updated version as new
349
- // scrollback. Expand/collapse prints the full (or summary) view below the
350
- // prior truncated view. This is a small visual regression from the old
351
- // scroll-region mode in exchange for eliminating the torn-frame race.
352
- toggleLastExpand() {
353
- const id = this._lastExpandableId;
354
- if (!id) return;
355
- const msg = this._msgById[id];
356
- if (!msg) return;
357
- this._toolExpanded[id] = !this._toolExpanded[id];
358
- this.addMessage(msg);
481
+ // Render the chrome for a collapsed output-preview body (the Phase-5 DIM /
482
+ // subtle-indent lines + a static `… N more lines` hint) as an ARRAY of styled
483
+ // lines (no trailing newline each). This is the single source for the immediate
484
+ // addMessage commit and the deferred (live-band) preview. The preview is always
485
+ // collapsed and static there is no expand affordance; full viewing is deferred
486
+ // to the transcript viewer.
487
+ _renderOutputPreviewLines(msg) {
488
+ // Fit to the width LESS the 5-col indent so indent+content stays within one
489
+ // physical row at the render width (the Phase-4 single-row invariant). Each
490
+ // emitted preview line is therefore exactly one physical row.
491
+ const preview = formatOutputPreview(msg.output, {
492
+ previewLines: msg.previewLines,
493
+ cols: Math.max(1, getCols() - 5),
494
+ });
495
+ // Flatten each fitted line to exactly one physical row, so the band's
496
+ // logical-line count == its physical-row count (the load-bearing invariant the
497
+ // writer's erase/commit math depends on). Without this a TAB-bearing line
498
+ // counts as 1 here but wraps to ≥2 rows on screen, desyncing the writer's
499
+ // physicalRows erase math → duplication on wrapping output. No-op on plain
500
+ // ASCII, so clean output stays byte-identical. Applied here (the single source
501
+ // for the live band, the immediate/committed scrollback, and replay) so every
502
+ // render path is byte-consistent.
503
+ const bodyLines = preview.lines.map(_flattenBandLine);
504
+ const lines = [];
505
+ for (const ol of bodyLines) {
506
+ lines.push(`${c(DIM)} ${c(UI_THEME.subtle)}${ol}${c(RST)}`);
507
+ }
508
+ if (preview.hiddenCount > 0) {
509
+ const plural = preview.hiddenCount === 1 ? 'line' : 'lines';
510
+ lines.push(`${c(DIM)} … ${preview.hiddenCount} more ${plural}${c(RST)}`);
511
+ }
512
+ return { lines, truncatable: preview.truncatable };
513
+ }
514
+
515
+ // DEFER the output-preview detail into the writer's redrawable detail band
516
+ // instead of committing it straight to immutable scrollback. It stays in the
517
+ // band until a turn boundary commits it once. The collapsed lines are rendered
518
+ // once here and committed verbatim — the band is installed once and committed
519
+ // once, never redrawn in place. Only the output-preview detail is deferred —
520
+ // the result line (endActivity) and the diff (scrollback) still commit
521
+ // immediately in chat-turn.js, unchanged.
522
+ deferToolOutput(msg) {
523
+ // Single slot: a new preview commits any previously-held one first (the
524
+ // next-op boundary normally already did this; belt-and-suspenders).
525
+ if (this._deferred) this.commitDeferredDetail();
526
+ const { lines } = this._renderOutputPreviewLines(msg);
527
+ this._deferred = { msg, lines };
528
+ this._setDetail(lines);
529
+ this._notifyLive();
530
+ }
531
+
532
+ // Commit the held detail band to scrollback and clear the slot, atomically via
533
+ // the writer (no duplicate frame, no stranded rows). No-op when nothing is held.
534
+ // Called at each turn boundary (next-op start, assistant answer, turn end) and
535
+ // auto-fired before any new scrollback this class emits, so ordering is always
536
+ // preserved. The held collapsed lines are committed verbatim — they already
537
+ // carry no interactive affordance.
538
+ commitDeferredDetail() {
539
+ if (!this._deferred) return;
540
+ const lines = this._deferred.lines;
541
+ const text = lines && lines.length ? lines.join('\n') + '\n' : '';
542
+ this._deferred = null;
543
+ this._commitDetail(text);
359
544
  }
360
545
 
361
546
  rerenderById(id) {
@@ -365,17 +550,14 @@ class ChatHistory {
365
550
  }
366
551
 
367
552
  collapseById(id) {
368
- // Nothing to erase in the append-only model — drop the tracking so a
369
- // future expand toggle doesn't target the stale entry.
553
+ // Nothing to erase in the append-only model — drop the tracking.
370
554
  delete this._msgById[id];
371
555
  delete this._msgLineCount[id];
372
- if (this._lastExpandableId === id) this._lastExpandableId = null;
373
556
  }
374
557
 
375
558
  removeById(id) {
376
559
  delete this._msgById[id];
377
560
  delete this._msgLineCount[id];
378
- if (this._lastExpandableId === id) this._lastExpandableId = null;
379
561
  }
380
562
 
381
563
  invalidateCache() {}
@@ -5,8 +5,15 @@ const readline = require('readline');
5
5
  const { RST, FG_YELLOW, FG_CYAN, FG_GRAY } = require('./ansi');
6
6
  const { ChatHistory } = require('./chat-history');
7
7
  const writer = require('./writer');
8
+ const { getCols } = require('./utils');
9
+ const { wrapPromptLines } = require('./format');
8
10
  const { registerTerminalCleanup } = require('./terminal');
9
11
 
12
+ // Cap a long ask_user question to this many wrapped lines in the modal header
13
+ // (mirrors the permission picker's MAX_DESC_LINES in permissions.js) so it can
14
+ // never overflow the live modal band; the rest collapses to "… N more lines".
15
+ const MAX_PROMPT_LINES = 12;
16
+
10
17
  function _createNoOpUI() {
11
18
  const chatHistory = {
12
19
  addMessage: (msg) => {
@@ -19,6 +26,10 @@ function _createNoOpUI() {
19
26
  clearStreamingContent: () => {},
20
27
  finalizeLastMessage: () => {},
21
28
  clearMessages: () => {},
29
+ // Phase 7b — no live region in the non-TTY no-op UI, so the deferred detail
30
+ // band is inert; the output preview just never displays. Present so the
31
+ // chat-turn boundary calls are safe in a non-TTY interactive run.
32
+ deferToolOutput: () => {}, commitDeferredDetail: () => {},
22
33
  scrollUp: () => {}, scrollDown: () => {},
23
34
  rerenderById: () => {},
24
35
  removeById: () => {},
@@ -62,7 +73,6 @@ function createUI(opts) {
62
73
  const { LayoutManager } = require('./layout');
63
74
  const { FullStatusBar } = require('./status-bar');
64
75
  const { InputField } = require('./input-field');
65
- const { interactiveSelect } = require('./select');
66
76
 
67
77
  const layout = new LayoutManager();
68
78
  const chatHistory = new ChatHistory();
@@ -153,34 +163,67 @@ function createUI(opts) {
153
163
 
154
164
  // ── captureSelect (modal menu) ───────────────────────────────────────────────
155
165
  //
156
- // Numbered-options picker for tools like ask_user. interactiveSelect
157
- // renders each frame into the writer's modal region (above status,
158
- // below scrollback) and routes keys through the input field's
159
- // captureNavigation API so the menu cohabits with the live region
160
- // instead of taking over the screen, and Enter/Esc resolve in place.
166
+ // ask_user-PRIVATE numbered-options picker (the model/rewind/permission
167
+ // pickers call interactiveSelect directly this wrapper is NOT on that path).
168
+ // It manages its OWN modal frame (mirroring permissions.js requestPermission)
169
+ // rather than going through interactiveSelect, because it prepends the QUESTION
170
+ // as NON-SELECTABLE header rows above the options: navigation only ever cycles
171
+ // the options, so the prompt rows can never be landed on or returned. The frame
172
+ // redraws in place in the writer's modal region (above status, below
173
+ // scrollback) and keys route through the input field's captureNavigation API,
174
+ // so the menu cohabits with the live region instead of taking over the screen.
175
+ //
176
+ // `menu` = { prompt, options }. `prompt` (the question prose, already stripped
177
+ // of the numbered options by the executor) is word-wrapped to terminal width
178
+ // and clamped to MAX_PROMPT_LINES so a long question can't overflow the band.
161
179
  inputField.captureSelect = (menu) => new Promise((resolve) => {
180
+ const options = (menu && Array.isArray(menu.options)) ? menu.options : [];
162
181
  if (!process.stdin.isTTY) {
163
- resolve(menu.options[0]);
182
+ resolve(options[0]);
164
183
  return;
165
184
  }
166
- interactiveSelect(
167
- menu.options,
168
- (opt, isSelected) => isSelected
169
- ? ` ${FG_YELLOW}❯${RST} ${FG_CYAN}${opt}${RST}`
170
- : ` ${FG_GRAY}${opt}${RST}`,
171
- {
172
- initialIndex: 0,
173
- onExpand: () => chatHistory.toggleLastExpand(),
174
- captureNavigation: (handler) => {
175
- inputField.captureNavigation(handler);
176
- return () => inputField.releaseNavigation();
177
- },
185
+ if (options.length === 0) {
186
+ resolve(null);
187
+ return;
188
+ }
189
+
190
+ // Wrap + clamp the question into non-selectable header rows.
191
+ const promptLines = wrapPromptLines(menu && menu.prompt, {
192
+ cols: Math.max(20, getCols() - 4),
193
+ maxLines: MAX_PROMPT_LINES,
194
+ });
195
+
196
+ let idx = 0;
197
+ const buildLines = () => {
198
+ const lines = [];
199
+ for (const p of promptLines) lines.push(` ${FG_GRAY}${p}${RST}`);
200
+ if (promptLines.length) lines.push('');
201
+ for (let i = 0; i < options.length; i++) {
202
+ lines.push(i === idx
203
+ ? ` ${FG_YELLOW}❯${RST} ${FG_CYAN}${options[i]}${RST}`
204
+ : ` ${FG_GRAY}${options[i]}${RST}`);
205
+ }
206
+ return lines;
207
+ };
208
+
209
+ writer.setModal(buildLines());
210
+ inputField.captureNavigation((action) => {
211
+ if (action === 'prev') {
212
+ idx = (idx - 1 + options.length) % options.length;
213
+ writer.setModal(buildLines());
214
+ } else if (action === 'next') {
215
+ idx = (idx + 1) % options.length;
216
+ writer.setModal(buildLines());
217
+ } else if (action === 'select' || action === 'cancel') {
218
+ // Order matters (mirrors interactiveSelect): clearModal first while the
219
+ // caret is still suppressed, then releaseNavigation so the host's render
220
+ // hooks restore the input caret on the next setLive frame.
221
+ writer.clearModal();
222
+ inputField.releaseNavigation();
223
+ // Cancel → last option (typically "No"/decline) so callers don't need to
224
+ // special-case cancellation — matches the prior contract.
225
+ resolve(action === 'select' ? options[idx] : options[options.length - 1]);
178
226
  }
179
- ).then((idx) => {
180
- // Cancel returns null. Match the prior contract: pick the last
181
- // option (typically "No"/decline) so callers don't need to
182
- // special-case cancellation.
183
- resolve(idx === null ? menu.options[menu.options.length - 1] : menu.options[idx]);
184
227
  });
185
228
  });
186
229