@semalt-ai/code 1.8.5 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/.claude/settings.local.json +7 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/ARCHITECTURE.md +6 -95
  4. package/CLAUDE.md +196 -316
  5. package/README.md +148 -4
  6. package/docs/ARCHITECTURE.md +1321 -0
  7. package/docs/CONFIG.md +340 -0
  8. package/docs/HISTORY.md +245 -0
  9. package/examples/embed.js +74 -0
  10. package/index.js +251 -10
  11. package/lib/agent.js +856 -120
  12. package/lib/api.js +239 -50
  13. package/lib/args.js +74 -2
  14. package/lib/audit.js +23 -1
  15. package/lib/background.js +584 -0
  16. package/lib/checkpoints.js +757 -0
  17. package/lib/commands/auth.js +94 -0
  18. package/lib/commands/chat-session.js +489 -0
  19. package/lib/commands/chat-slash.js +415 -0
  20. package/lib/commands/chat-turn.js +669 -0
  21. package/lib/commands/chat.js +407 -0
  22. package/lib/commands/custom.js +157 -0
  23. package/lib/commands/history-utils.js +66 -0
  24. package/lib/commands/index.js +268 -0
  25. package/lib/commands/mcp.js +113 -0
  26. package/lib/commands/oneshot.js +193 -0
  27. package/lib/commands/registry.js +269 -0
  28. package/lib/commands/tasks.js +89 -0
  29. package/lib/compact.js +87 -0
  30. package/lib/config.js +360 -11
  31. package/lib/constants.js +401 -3
  32. package/lib/deny.js +199 -0
  33. package/lib/doctor.js +160 -0
  34. package/lib/headless.js +202 -0
  35. package/lib/hooks.js +286 -0
  36. package/lib/images.js +270 -0
  37. package/lib/internals.js +49 -0
  38. package/lib/mcp/boundary.js +131 -0
  39. package/lib/mcp/client.js +270 -0
  40. package/lib/mcp/oauth.js +134 -0
  41. package/lib/memory.js +209 -0
  42. package/lib/metrics.js +37 -2
  43. package/lib/payload.js +54 -0
  44. package/lib/permission-rules.js +401 -0
  45. package/lib/permissions.js +123 -26
  46. package/lib/pricing.js +67 -0
  47. package/lib/proc.js +62 -0
  48. package/lib/prompts.js +99 -8
  49. package/lib/sandbox.js +568 -0
  50. package/lib/sdk.js +328 -0
  51. package/lib/secrets.js +211 -0
  52. package/lib/skills.js +223 -0
  53. package/lib/subagents.js +516 -0
  54. package/lib/tool_registry.js +2862 -0
  55. package/lib/tool_specs.js +263 -9
  56. package/lib/tools.js +352 -1039
  57. package/lib/ui/anim.js +86 -0
  58. package/lib/ui/ansi.js +17 -27
  59. package/lib/ui/chat-history.js +253 -71
  60. package/lib/ui/create-ui.js +67 -24
  61. package/lib/ui/diff.js +90 -25
  62. package/lib/ui/file-activity.js +236 -0
  63. package/lib/ui/format.js +195 -29
  64. package/lib/ui/input-field.js +21 -11
  65. package/lib/ui/md-stream.js +234 -0
  66. package/lib/ui/render-operation.js +113 -0
  67. package/lib/ui/select.js +1 -4
  68. package/lib/ui/status-bar.js +146 -36
  69. package/lib/ui/stream.js +20 -13
  70. package/lib/ui/theme.js +190 -44
  71. package/lib/ui/tool-operation.js +190 -0
  72. package/lib/ui/utils.js +9 -5
  73. package/lib/ui/web-activity.js +270 -0
  74. package/lib/ui/writer.js +159 -45
  75. package/lib/ui.js +1 -1
  76. package/lib/verify.js +229 -0
  77. package/lib/web-extract.js +213 -0
  78. package/lib/web-summarize.js +68 -0
  79. package/package.json +19 -4
  80. package/scripts/lint.js +57 -0
  81. package/test/agent-loop.test.js +389 -0
  82. package/test/anim-driver.test.js +153 -0
  83. package/test/ask-user-display.test.js +226 -0
  84. package/test/ask-user-gate.test.js +231 -0
  85. package/test/background.test.js +414 -0
  86. package/test/chat-history-nocolor.test.js +155 -0
  87. package/test/chat-relogin.test.js +207 -0
  88. package/test/chat.test.js +114 -0
  89. package/test/checkpoints-agent.test.js +181 -0
  90. package/test/checkpoints.test.js +650 -0
  91. package/test/command-registry.test.js +160 -0
  92. package/test/compact.test.js +116 -0
  93. package/test/completion-lazy.test.js +52 -0
  94. package/test/config-merge.test.js +324 -0
  95. package/test/config-quarantine.test.js +128 -0
  96. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  97. package/test/config-write-guard-skip.test.js +46 -0
  98. package/test/config-write-guard.test.js +153 -0
  99. package/test/context-split.test.js +215 -0
  100. package/test/cost-doctor.test.js +142 -0
  101. package/test/custom-commands-chat.test.js +106 -0
  102. package/test/custom-commands.test.js +230 -0
  103. package/test/defer-detail-band.test.js +403 -0
  104. package/test/deny-windows.test.js +120 -0
  105. package/test/deny.test.js +83 -0
  106. package/test/detail-band-tab-flatten.test.js +242 -0
  107. package/test/download-allow-anywhere.test.js +66 -0
  108. package/test/download-confine.test.js +153 -0
  109. package/test/exec-diff.test.js +268 -0
  110. package/test/executors.test.js +599 -0
  111. package/test/extract-tool-calls.test.js +349 -0
  112. package/test/fetch-url-validation.test.js +219 -0
  113. package/test/file-activity.test.js +522 -0
  114. package/test/fixtures/tool-calls.js +57 -0
  115. package/test/fixtures/web-page.js +91 -0
  116. package/test/git-tools.test.js +384 -0
  117. package/test/grep-glob-serialize.test.js +242 -0
  118. package/test/grep-glob.test.js +268 -0
  119. package/test/grep-path-target.test.js +227 -0
  120. package/test/harness/README.md +57 -0
  121. package/test/harness/chat-harness.js +143 -0
  122. package/test/harness/memwarn-headless-child.js +65 -0
  123. package/test/harness/mock-llm.js +120 -0
  124. package/test/harness/mock-mcp-server.js +142 -0
  125. package/test/harness/sse-server.js +69 -0
  126. package/test/headless.test.js +348 -0
  127. package/test/history-utils.test.js +88 -0
  128. package/test/hooks-agent.test.js +238 -0
  129. package/test/hooks-verify-sandbox.test.js +232 -0
  130. package/test/hooks.test.js +216 -0
  131. package/test/http-get-user-agent.test.js +142 -0
  132. package/test/images-api.test.js +208 -0
  133. package/test/images.test.js +238 -0
  134. package/test/input-field-ctrl-o.test.js +37 -0
  135. package/test/live-height-physical.test.js +281 -0
  136. package/test/max-iterations.test.js +218 -0
  137. package/test/mcp-boundary.test.js +57 -0
  138. package/test/mcp-client.test.js +267 -0
  139. package/test/mcp-oauth.test.js +86 -0
  140. package/test/md-stream.test.js +183 -0
  141. package/test/memory-truncation-warning.test.js +222 -0
  142. package/test/memory.test.js +198 -0
  143. package/test/native-dispatch.test.js +409 -0
  144. package/test/native-live-narration.test.js +254 -0
  145. package/test/output-chokepoint.test.js +188 -0
  146. package/test/output-heredoc-leak.test.js +195 -0
  147. package/test/output-preview.test.js +245 -0
  148. package/test/path-guards.test.js +134 -0
  149. package/test/payload.test.js +99 -0
  150. package/test/permission-rules-agent.test.js +210 -0
  151. package/test/permission-rules.test.js +297 -0
  152. package/test/permissions.test.js +362 -0
  153. package/test/plan-mode.test.js +167 -0
  154. package/test/read-paginate.test.js +275 -0
  155. package/test/readonly-tools.test.js +177 -0
  156. package/test/render-operation.test.js +317 -0
  157. package/test/replay-descriptor-xml.test.js +216 -0
  158. package/test/replay-descriptor.test.js +189 -0
  159. package/test/replay-web-aggregate.test.js +291 -0
  160. package/test/replay-web-persist.test.js +241 -0
  161. package/test/result-cap.test.js +233 -0
  162. package/test/running-glyph-anim.test.js +111 -0
  163. package/test/sandbox-agent.test.js +147 -0
  164. package/test/sandbox-integration.test.js +216 -0
  165. package/test/sandbox.test.js +408 -0
  166. package/test/sdk.test.js +234 -0
  167. package/test/shell-output-cap.test.js +181 -0
  168. package/test/skills-chat.test.js +110 -0
  169. package/test/skills.test.js +295 -0
  170. package/test/smoke.test.js +68 -0
  171. package/test/status-bar-driver.test.js +93 -0
  172. package/test/status-bar-pause.test.js +164 -0
  173. package/test/status-bar-resync.test.js +188 -0
  174. package/test/stream-parser.test.js +171 -0
  175. package/test/subagents-agent.test.js +178 -0
  176. package/test/subagents.test.js +222 -0
  177. package/test/theme-palette.test.js +166 -0
  178. package/test/tool-registry.test.js +85 -0
  179. package/test/trim-budget.test.js +101 -0
  180. package/test/truncate-visible.test.js +78 -0
  181. package/test/verify-agent.test.js +317 -0
  182. package/test/verify.test.js +141 -0
  183. package/test/view-image.test.js +199 -0
  184. package/test/web-activity-ordering.test.js +203 -0
  185. package/test/web-activity.test.js +207 -0
  186. package/test/web-data-extraction-guidance.test.js +71 -0
  187. package/test/web-extract.test.js +185 -0
  188. package/test/web-fetch-agent.test.js +291 -0
  189. package/test/web-fetch-mode.test.js +193 -0
  190. package/test/web-search.test.js +380 -0
  191. package/lib/commands.js +0 -1438
  192. package/path +0 -1
package/lib/ui/format.js CHANGED
@@ -16,8 +16,17 @@
16
16
  // duration formatDuration(ms), pending lines trail with "…"
17
17
  // meta type-specific tail — exit codes, byte counts, match counts, …
18
18
 
19
- const { RST, DIM } = require('./ansi');
20
- const { UI_THEME, UI_ICONS, TOOL_CATEGORIES } = require('./theme');
19
+ const { RST, DIM, SPINNER_DEFS } = require('./ansi');
20
+ const { UI_ICONS, categoryForTag, resolveLineColors, colorEnabled } = require('./theme');
21
+ const { truncateVisible, stripAnsi } = require('./utils');
22
+
23
+ // Per-frame cadence for the animated running glyph. Matches the single
24
+ // animation driver's base interval (lib/ui/anim.js BASE_INTERVAL_MS) so that,
25
+ // as the driver repaints a running op every ~100 ms with a fresh elapsed time,
26
+ // the derived spinner frame advances by one. Deriving the frame from the
27
+ // elapsed duration (rather than a shared counter) keeps formatToolLine a pure
28
+ // input→string function — no animation state to thread.
29
+ const RUNNING_GLYPH_INTERVAL_MS = 100;
21
30
 
22
31
  // Adaptive precision. ms < 1s shows "Nms", under a minute shows "N.Ns"
23
32
  // (sub-10s) or "Ns", under an hour shows "MmSs", above uses "HhMm". Never
@@ -90,8 +99,8 @@ function _normalizeTag(tag) {
90
99
  // operation columns; category names of 5 chars ("shell") fit exactly.
91
100
  const CATEGORY_WIDTH = 5;
92
101
 
93
- function _categoryLabel(tag) {
94
- const cat = TOOL_CATEGORIES[_normalizeTag(tag)] || 'tool';
102
+ function _categoryLabel(category) {
103
+ const cat = category || 'tool';
95
104
  return cat.length >= CATEGORY_WIDTH ? cat.slice(0, CATEGORY_WIDTH) : cat.padEnd(CATEGORY_WIDTH);
96
105
  }
97
106
 
@@ -153,7 +162,28 @@ function _operation(tag, arg, attrs) {
153
162
  case 'recall_memory': return _truncate(`recall ${a.key || arg || ''}`, max);
154
163
  case 'list_memories': return 'list memories';
155
164
  case 'system_info': return 'system info';
156
- default: return _truncate(arg ? `${tag} ${arg}` : tag, max);
165
+ default:
166
+ if (_normalizeTag(tag).startsWith('git_')) return _truncate(_gitOperation(_normalizeTag(tag), a), max);
167
+ // arg may be a structured options object for newer tools — avoid rendering
168
+ // "[object Object]" by only appending string/number args.
169
+ return _truncate((arg && typeof arg !== 'object') ? `${tag} ${arg}` : tag, max);
170
+ }
171
+ }
172
+
173
+ // Human-readable one-liner for the native git tools (Task 5.1). `a` is the
174
+ // options object (attrs) carried by the call.
175
+ function _gitOperation(tag, a) {
176
+ const paths = Array.isArray(a.paths) ? a.paths.join(' ') : (a.paths || '');
177
+ switch (tag) {
178
+ case 'git_status': return 'git status';
179
+ case 'git_diff': return `git diff${a.staged ? ' --staged' : ''}${a.path ? ' ' + a.path : ''}`;
180
+ case 'git_log': return `git log${a.count ? ' -n ' + a.count : ''}${a.path ? ' ' + a.path : ''}`;
181
+ case 'git_add': return `git add ${a.all ? '-A' : paths}`.trim();
182
+ case 'git_commit': return `git commit${a.all ? ' -a' : ''} -m "${normalizeCmdForDisplay(a.message || '')}"`;
183
+ case 'git_branch': return a.name ? `git branch ${a.delete ? '-d ' : ''}${a.name}` : 'git branch';
184
+ case 'git_checkout': return `git checkout ${a.create ? '-b ' : ''}${a.name || ''}`.trim();
185
+ case 'git_worktree': return `git worktree ${a.op || 'list'}${a.path ? ' ' + a.path : ''}`;
186
+ default: return tag;
157
187
  }
158
188
  }
159
189
 
@@ -199,10 +229,55 @@ function _metaParts(tag, meta, error) {
199
229
  if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
200
230
  if (meta && meta.kind) out.push(meta.kind);
201
231
  break;
232
+ case 'ask_user':
233
+ // The user's chosen answer, surfaced in scrollback (it was previously only
234
+ // sent to the model). Truncated so a long option keeps the result line on
235
+ // one physical row.
236
+ if (meta && meta.answer != null && String(meta.answer) !== '') {
237
+ out.push(`→ ${_truncate(String(meta.answer), 40)}`);
238
+ }
239
+ break;
202
240
  }
203
241
  return out;
204
242
  }
205
243
 
244
+ // Word-wrap `text` to `cols` columns and clamp to `maxLines`, appending a
245
+ // "… N more lines" tail when truncated. Mirrors the permission picker's
246
+ // MAX_DESC_LINES handling (permissions.js) so a long ask_user question can't
247
+ // overflow the modal band. Pure: returns an array of plain (unstyled) lines;
248
+ // the caller applies any colour. An empty/whitespace `text` returns [] so the
249
+ // caller can skip rendering a header entirely.
250
+ function wrapPromptLines(text, opts) {
251
+ const o = opts || {};
252
+ const cols = (Number.isInteger(o.cols) && o.cols > 0) ? o.cols : 80;
253
+ const maxLines = (Number.isInteger(o.maxLines) && o.maxLines > 0) ? o.maxLines : 12;
254
+ const src = String(text == null ? '' : text);
255
+ if (!src.trim()) return [];
256
+ const wrapped = [];
257
+ for (const para of src.split('\n')) {
258
+ if (para.trim() === '') { wrapped.push(''); continue; }
259
+ let line = '';
260
+ for (const word of para.split(/\s+/).filter(Boolean)) {
261
+ if (line && (line.length + 1 + word.length) > cols) {
262
+ wrapped.push(line);
263
+ line = '';
264
+ }
265
+ if (!line && word.length > cols) {
266
+ // Hard-break a single token longer than the column budget.
267
+ let w = word;
268
+ while (w.length > cols) { wrapped.push(w.slice(0, cols)); w = w.slice(cols); }
269
+ line = w;
270
+ } else {
271
+ line = line ? `${line} ${word}` : word;
272
+ }
273
+ }
274
+ if (line) wrapped.push(line);
275
+ }
276
+ if (wrapped.length <= maxLines) return wrapped;
277
+ const hidden = wrapped.length - maxLines;
278
+ return wrapped.slice(0, maxLines).concat([`… ${hidden} more ${hidden === 1 ? 'line' : 'lines'}`]);
279
+ }
280
+
206
281
  // Build the full styled 4-segment tool line. `status` is one of
207
282
  // 'pending' | 'success' | 'failure'. For 'failure', caller may pass an
208
283
  // Error-shaped `error` and/or partial `meta` — we format both when
@@ -217,44 +292,55 @@ function formatToolLine(args) {
217
292
  meta,
218
293
  error,
219
294
  noDuration,
295
+ category,
220
296
  } = args || {};
221
297
 
298
+ // Colour is keyed by the descriptor's {category, status} via the single
299
+ // resolver in theme.js — not re-derived here. Callers going through the
300
+ // descriptor (render-operation.js) pass `category`; direct callers let us
301
+ // resolve it from the tag. The glyph CHARACTER stays here (status → '●/✓/✗');
302
+ // only its colour comes from the resolver.
303
+ const cat = category || categoryForTag(tag);
304
+ const colors = resolveLineColors(cat, error ? 'error' : status);
305
+ const enabled = colorEnabled();
306
+ const R = enabled ? RST : '';
307
+
222
308
  let glyph;
223
- let glyphColor;
224
309
  if (status === 'pending') {
225
- glyph = UI_ICONS.pending;
226
- glyphColor = UI_THEME.muted;
227
- } else if (status === 'failure' || error) {
228
- glyph = UI_ICONS.error;
229
- glyphColor = UI_THEME.error;
230
- } else {
231
- glyph = UI_ICONS.success;
232
- glyphColor = UI_THEME.success;
233
- }
234
-
235
- const cat = _categoryLabel(tag);
236
- const catColor = (UI_THEME.categories && UI_THEME.categories[_normalizeTag(tag) && (TOOL_CATEGORIES[_normalizeTag(tag)] || 'tool')]) || UI_THEME.accent;
310
+ if (noDuration) {
311
+ // Blocking tools (ask_user) render once and freeze — a ticking spinner
312
+ // would falsely imply work is happening. Keep the static pending dot.
313
+ glyph = UI_ICONS.pending;
314
+ } else {
315
+ // Running op: animate the spinner frames (the `tool` SPINNER_DEF) in the
316
+ // category-tinted pending colour (colors.glyph). The frame is derived
317
+ // from the elapsed duration so it advances each time the driver repaints
318
+ // the row with a fresh elapsedMs — keeping the formatter pure.
319
+ const frames = SPINNER_DEFS.tool.frames;
320
+ const idx = Math.floor(Math.max(0, durationMs || 0) / RUNNING_GLYPH_INTERVAL_MS) % frames.length;
321
+ glyph = frames[idx];
322
+ }
323
+ } else if (status === 'failure' || error) glyph = UI_ICONS.error;
324
+ else glyph = UI_ICONS.success;
237
325
 
326
+ const label = _categoryLabel(cat);
238
327
  const op = _operation(tag, arg, attrs);
239
328
  const metaParts = _metaParts(tag, meta, error);
240
329
 
241
- // Segment-by-segment styling. Each fragment carries its own ANSI codes
242
- // so the " · " separator — neutral DIM — can sit between them without
243
- // leaking the surrounding color into the separator itself.
244
- const sep = ` ${DIM}·${RST} `;
245
-
246
- const durColor = status === 'failure' ? UI_THEME.error : UI_THEME.muted;
247
- const metaColor = status === 'failure' ? UI_THEME.error : UI_THEME.subtle;
330
+ // Segment-by-segment styling. Each fragment carries its own ANSI codes so the
331
+ // " · " separator — neutral DIM — can sit between them without leaking the
332
+ // surrounding colour. Under NO_COLOR/non-TTY the separator and resets go plain.
333
+ const sep = enabled ? ` ${DIM}·${R} ` : ' · ';
248
334
 
249
335
  const segments = [];
250
- segments.push(` ${glyphColor}${glyph}${RST} ${catColor}${cat}${RST}`);
251
- segments.push(`${UI_THEME.default}${op}${RST}`);
336
+ segments.push(` ${colors.glyph}${glyph}${R} ${colors.label}${label}${R}`);
337
+ segments.push(`${colors.op}${op}${R}`);
252
338
  if (!noDuration) {
253
339
  const durStr = formatDuration(durationMs || 0) + (status === 'pending' ? '…' : '');
254
- segments.push(`${durColor}${durStr}${RST}`);
340
+ segments.push(`${colors.dur}${durStr}${R}`);
255
341
  }
256
342
  for (const m of metaParts) {
257
- if (m) segments.push(`${metaColor}${m}${RST}`);
343
+ if (m) segments.push(`${colors.meta}${m}${R}`);
258
344
  }
259
345
  return segments.join(sep);
260
346
  }
@@ -311,6 +397,79 @@ function summarizeToolResult(content) {
311
397
  return trimmed;
312
398
  }
313
399
 
400
+ // ── Collapsible output preview (Output Refactor — Phase 5) ───────────────────
401
+ //
402
+ // Shell / MCP / subagent output is shown in MODERATION in the chrome: a short
403
+ // preview of the leading lines + an exact `… N more lines` hint.
404
+ // These two helpers are PURE (no IO, no config) so the policy is unit-testable
405
+ // and single-sourced: both the descriptor renderer (render-operation.js) and the
406
+ // live commit path (chat-history.js) drive their preview from here.
407
+
408
+ // Default preview budget when a caller passes none. Mirrors
409
+ // constants.DEFAULT_SHELL_PREVIEW_LINES; duplicated as a bare fallback so this
410
+ // module stays config-free (the real value flows in from config via the caller).
411
+ const DEFAULT_PREVIEW_LINES = 5;
412
+
413
+ const _FENCE_OPEN = '<<<UNTRUSTED_EXTERNAL_CONTENT';
414
+ const _FENCE_CLOSE = '<<<END_UNTRUSTED_EXTERNAL_CONTENT>>>';
415
+
416
+ // Recover the human-facing OUTPUT body from a model-facing tool-result string,
417
+ // stripping the framing the model needs but the user has already seen on the
418
+ // result line above:
419
+ // - shell: `Command \`<cmd>\`:\nExit code: <N>\n<body>` → <body>
420
+ // - MCP / subagent: `<prefix>:\n<<<UNTRUSTED…>>>\n<content>\n<<<END…>>>` → <content>
421
+ // Pure. Returns '' when there's nothing worth previewing. NOTE: this NEVER feeds
422
+ // the model — it is chrome only; the model receives the full framed result via
423
+ // boundToolOutput, untouched.
424
+ function extractDisplayBody(result) {
425
+ if (typeof result !== 'string' || !result) return '';
426
+ let body = result;
427
+ const shellMatch = result.match(/^Command `[\s\S]*?`:\nExit code: -?\d+\n([\s\S]*)$/);
428
+ if (shellMatch) body = shellMatch[1];
429
+ // Strip an untrusted fence (MCP/subagent/web): drop everything up to and
430
+ // including the fence-open line, and the closing marker. The model-facing
431
+ // prefix line (before the fence) is dropped with it.
432
+ const open = body.indexOf(_FENCE_OPEN);
433
+ if (open !== -1) {
434
+ const afterOpenNl = body.indexOf('\n', open);
435
+ const close = body.lastIndexOf(_FENCE_CLOSE);
436
+ if (afterOpenNl !== -1 && close > afterOpenNl) {
437
+ body = body.slice(afterOpenNl + 1, close);
438
+ }
439
+ }
440
+ return body.replace(/[ \t\r\n]+$/, '');
441
+ }
442
+
443
+ // Slice `body` into a single-row-fitted preview. Returns:
444
+ // { lines, hiddenCount, total, truncatable }
445
+ // where `lines` is what to show (the first `previewLines` when collapsed, ALL
446
+ // when `expanded`), each truncated to one physical row (cols−1), `total` is the
447
+ // line count, `hiddenCount` is EXACTLY total − previewLines when collapsed (0
448
+ // otherwise), and `truncatable` says whether an affordance applies at all.
449
+ // Pure. Trailing blank lines are dropped so they never inflate the count.
450
+ function formatOutputPreview(body, opts) {
451
+ const o = opts || {};
452
+ const previewLines = (Number.isInteger(o.previewLines) && o.previewLines > 0) ? o.previewLines : DEFAULT_PREVIEW_LINES;
453
+ const expanded = !!o.expanded;
454
+ const cols = (Number.isInteger(o.cols) && o.cols > 0) ? o.cols : 80;
455
+ const max = Math.max(0, cols - 1);
456
+ const raw = String(body == null ? '' : body).replace(/[\r\n]+$/, '');
457
+ const allLines = raw === '' ? [] : raw.split('\n');
458
+ // A child process's own SGR escapes can ride in `body`. Under NO_COLOR (or
459
+ // non-TTY) strip them BEFORE truncateVisible so it receives escape-free input
460
+ // and, by its content-driven gate, appends no reset — leaving the body line
461
+ // byte-clean. With color on we preserve the captured color (mirrors the
462
+ // md-stream.js inline() precedent: colorEnabled() ? styled : stripAnsi).
463
+ const keepColor = colorEnabled();
464
+ const fitted = allLines.map((l) => truncateVisible(keepColor ? l : stripAnsi(l), max));
465
+ const total = fitted.length;
466
+ const truncatable = total > previewLines;
467
+ if (expanded || !truncatable) {
468
+ return { lines: fitted, hiddenCount: 0, total, truncatable };
469
+ }
470
+ return { lines: fitted.slice(0, previewLines), hiddenCount: total - previewLines, total, truncatable };
471
+ }
472
+
314
473
  module.exports = {
315
474
  formatDuration,
316
475
  formatBytes,
@@ -318,4 +477,11 @@ module.exports = {
318
477
  formatToolLine,
319
478
  summarizeToolResult,
320
479
  normalizeCmdForDisplay,
480
+ // The word-boundary-aware single-line truncator (used by the file-activity
481
+ // summary to fit the basename list to the remaining columns). Exported under a
482
+ // public name; the internal `_truncate` is otherwise module-private.
483
+ truncateLine: _truncate,
484
+ extractDisplayBody,
485
+ formatOutputPreview,
486
+ wrapPromptLines,
321
487
  };
@@ -7,10 +7,13 @@ const { RST, DIM, FG_CYAN, FG_GREEN, FG_YELLOW } = require('./ansi');
7
7
  const { stripAnsi, termWidth } = require('./utils');
8
8
  const writer = require('./writer');
9
9
 
10
- const SLASH_CMDS = [
11
- '/help','/file','/new','/model','/models','/shell','/compact',
12
- '/clear','/approve','/debug','/config','/history','/login','/whoami','/logout','/chats',
13
- ];
10
+ // Tab-completion command list is generated from the slash-command registry —
11
+ // the single source of truth — so it can never drift from the dispatcher.
12
+ // Resolved LIVE (not snapshotted at load) so commands registered after this
13
+ // module loads — notably the Markdown custom commands discovered at chat
14
+ // startup (Task 3.1) — appear in completion. The exported `SLASH_CMDS` is kept
15
+ // for back-compat (since 1.3) but backed by the same live getter.
16
+ const { completionNames } = require('../commands/registry');
14
17
 
15
18
  // ─── Key sequence parser ──────────────────────────────────────────────────────
16
19
 
@@ -24,7 +27,7 @@ function parseKeySequence(buf) {
24
27
  0x09:'tab', 0x01:'ctrl+a', 0x02:'ctrl+b', 0x05:'ctrl+e',
25
28
  0x06:'ctrl+f', 0x07:'ctrl+g', 0x0b:'ctrl+k', 0x0e:'ctrl+n',
26
29
  0x10:'ctrl+p', 0x12:'ctrl+r', 0x14:'ctrl+t', 0x15:'ctrl+u',
27
- 0x17:'ctrl+w', 0x0c:'ctrl+l', 0x03:'ctrl+c', 0x04:'ctrl+d', 0x0f:'ctrl+o',
30
+ 0x17:'ctrl+w', 0x0c:'ctrl+l', 0x03:'ctrl+c', 0x04:'ctrl+d',
28
31
  };
29
32
  if (SINGLE[b0]) return { key: SINGLE[b0], len: 1 };
30
33
 
@@ -198,6 +201,10 @@ class InputField extends EventEmitter {
198
201
  this._render();
199
202
  }
200
203
  getValue() { return this._chars.join(''); }
204
+ // True once the field has settled into its idle state (the one-shot _goIdle
205
+ // fired) and not since reactivated. Lets startup re-sync the status-bar clock
206
+ // to the field's real idle state after an await may have fired _goIdle early.
207
+ isIdle() { return this._idle; }
201
208
  onSubmit(cb) { this.on('submit', cb); }
202
209
 
203
210
  captureSelect(menu) {
@@ -571,7 +578,7 @@ class InputField extends EventEmitter {
571
578
  sources.push({ type: 'history', text });
572
579
  for (const item of this._searchExtraItems)
573
580
  sources.push(item);
574
- for (const cmd of SLASH_CMDS)
581
+ for (const cmd of completionNames())
575
582
  sources.push({ type: 'command', text: cmd });
576
583
  return sources;
577
584
  }
@@ -635,7 +642,7 @@ class InputField extends EventEmitter {
635
642
  }
636
643
 
637
644
  if (!val.startsWith('/')) return;
638
- const matches = SLASH_CMDS.filter(c => c.startsWith(val));
645
+ const matches = completionNames().filter(c => c.startsWith(val));
639
646
  if (!matches.length) return;
640
647
  if (matches.length === 1) {
641
648
  this._setValue(matches[0] + ' ');
@@ -797,7 +804,6 @@ class InputField extends EventEmitter {
797
804
  case 'ctrl+t': this._transposeChars(); this._render(); break;
798
805
  case 'ctrl+l': this._chatHistory.clearMessages(); break;
799
806
  case 'ctrl+r': this._enterSearchMode(); break;
800
- case 'ctrl+o': if (this._navCapture) this._navCapture('expand'); else this.emit('expand'); break;
801
807
  case 'ctrl+g': this.emit('interrupt'); break;
802
808
  case 'ctrl+c': this._onCtrlC(); break;
803
809
  case 'ctrl+d': this._onCtrlD(); break;
@@ -902,8 +908,6 @@ class InputField extends EventEmitter {
902
908
  this.emit('abort');
903
909
  } else if (buf[0] === 0x04) {
904
910
  // Ctrl+D while agent active: ignored (bash readline semantics).
905
- } else if (buf[0] === 0x0f) {
906
- this.emit('expand');
907
911
  } else if (buf[0] === 0x1b && buf.length === 1) {
908
912
  // Bare ESC while agent is running: buffer and confirm after 20ms so that
909
913
  // escape sequences (arrow keys, etc.) arriving as separate bytes are ignored.
@@ -1266,4 +1270,10 @@ class InputField extends EventEmitter {
1266
1270
  }
1267
1271
  }
1268
1272
 
1269
- module.exports = { InputField, parseKeySequence, SLASH_CMDS };
1273
+ module.exports = { InputField, parseKeySequence };
1274
+ // SLASH_CMDS kept for back-compat (since 1.3), but now a live getter so callers
1275
+ // reading it after custom-command registration see the current set.
1276
+ Object.defineProperty(module.exports, 'SLASH_CMDS', {
1277
+ enumerable: true,
1278
+ get() { return completionNames(); },
1279
+ });
@@ -0,0 +1,234 @@
1
+ 'use strict';
2
+
3
+ // Stateful, line-at-a-time Markdown → ANSI styler for the interactive TUI's
4
+ // agent narration. It COMPOSES the existing hand-rolled helpers rather than
5
+ // reinventing them (zero-dep invariant #13):
6
+ // - diff.js `_mdInline` — inline bold/italic/code span styler
7
+ // - diff.js `_truncateByWidth` — plain-text width truncation (code bodies)
8
+ // - stream.js `colorizeCode` — per-line code syntax highlight
9
+ // - theme.js `colorEnabled` — the single NO_COLOR + non-TTY gate
10
+ // - theme/ansi palette — FG_CODE_BG/BORDER/LANG, RST, EL, BOLD, DIM
11
+ //
12
+ // It is fed COMPLETE lines (the caller splits the token stream on '\n'); inline
13
+ // spans are therefore always line-complete and never strand an open SGR across
14
+ // calls. The only cross-line state is the fenced-code-block buffer: while a
15
+ // ``` block is open, body lines are buffered and the whole width-aware code box
16
+ // is emitted at the closing fence. `flush()` closes a still-open fence cleanly.
17
+ //
18
+ // Both the live streaming path AND the --resume / history replay path drive the
19
+ // SAME implementation (see `renderBlock`), so a replayed turn is byte-identical
20
+ // to the live one.
21
+
22
+ const { colorEnabled, FG_CODE_BG, FG_CODE_BORDER, FG_CODE_LANG, THEME } = require('./theme');
23
+ const { RST, EL, BOLD, DIM } = require('./ansi');
24
+ const { _mdInline, _truncateByWidth } = require('./diff');
25
+ const { colorizeCode } = require('./stream');
26
+ const { getCols, repeatToWidth, termWidth, stripAnsi } = require('./utils');
27
+
28
+ // Gate a raw SGR code through the single NO_COLOR/non-TTY switch.
29
+ function c(code) { return colorEnabled() ? code : ''; }
30
+
31
+ // Inline span styler, color-gated. Reuses diff.js `_mdInline`; under NO_COLOR we
32
+ // strip the SGR it injects, which also drops the `**`/`*`/`` ` `` markers, so the
33
+ // plain output reads as clean prose with no escape codes.
34
+ function inline(text) {
35
+ const styled = _mdInline(text);
36
+ return colorEnabled() ? styled : stripAnsi(styled);
37
+ }
38
+
39
+ class StreamMarkdown {
40
+ constructor(opts) {
41
+ const o = opts || {};
42
+ // Leading indent every emitted line carries (matches the 2-space narration
43
+ // gutter the AI bubble used before styling).
44
+ this.indent = o.indent != null ? String(o.indent) : ' ';
45
+ this.reset();
46
+ }
47
+
48
+ // Drop all per-turn state. Called between turns so a fresh turn never inherits
49
+ // an open fence or buffered body from the previous one.
50
+ reset() {
51
+ this.inCodeBlock = false;
52
+ this.codeLang = '';
53
+ this._codeBuf = [];
54
+ }
55
+
56
+ // Style a COMPLETE line. Returns the styled string to commit (no trailing
57
+ // newline — the caller appends one), or null when there is nothing to emit yet
58
+ // (a buffered code-block body line, or the opening ``` fence). A code block's
59
+ // box is returned in full when its closing ``` arrives.
60
+ feedLine(line) {
61
+ if (this.inCodeBlock) {
62
+ if (line.trim() === '```') {
63
+ const box = this._renderCodeBox();
64
+ this.inCodeBlock = false;
65
+ this.codeLang = '';
66
+ this._codeBuf = [];
67
+ return box;
68
+ }
69
+ this._codeBuf.push(line);
70
+ return null;
71
+ }
72
+ if (line.length >= 3 && line[0] === '`' && line[1] === '`' && line[2] === '`') {
73
+ this.inCodeBlock = true;
74
+ this.codeLang = line.slice(3).trim();
75
+ this._codeBuf = [];
76
+ return null;
77
+ }
78
+ return this._renderProse(line);
79
+ }
80
+
81
+ // Close any open state and return the styled remainder. If a fenced block is
82
+ // still buffering (no closing ```), emit the buffered body as a finished box so
83
+ // scrollback never strands an unclosed fence or a leaked SGR. Returns null when
84
+ // there is nothing pending.
85
+ flush() {
86
+ if (this.inCodeBlock) {
87
+ const box = this._renderCodeBox();
88
+ this.inCodeBlock = false;
89
+ this.codeLang = '';
90
+ this._codeBuf = [];
91
+ return box;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ // Style one prose/heading/list/blockquote/rule line. Mirrors diff.js
97
+ // `renderMarkdown`'s block grammar, but every SGR routes through `c()` and the
98
+ // configured indent prefixes each line. Always ends with a reset so no ANSI
99
+ // bleeds into the next immutable scrollback line.
100
+ _renderProse(line) {
101
+ const ind = this.indent;
102
+ const barW = Math.max(1, getCols() - ind.length);
103
+ const trimmed = line.trim();
104
+
105
+ if (trimmed === '') return ind;
106
+
107
+ // Horizontal rule.
108
+ if (trimmed === '---' || trimmed === '===') {
109
+ return ind + c(THEME.dim) + '─'.repeat(barW) + c(RST);
110
+ }
111
+
112
+ // ATX heading (levels 1–3), optionally underlined like renderMarkdown.
113
+ let level = 0;
114
+ while (level < line.length && line[level] === '#') level++;
115
+ if (level >= 1 && level <= 3 && level < line.length && line[level] === ' ') {
116
+ const htext = line.slice(level + 1);
117
+ if (level === 1) {
118
+ return ind + c(THEME.agent) + c(BOLD) + htext + c(RST)
119
+ + '\n' + ind + c(THEME.dim) + '═'.repeat(barW) + c(RST);
120
+ }
121
+ if (level === 2) {
122
+ return ind + c(BOLD) + htext + c(RST)
123
+ + '\n' + ind + c(THEME.dim) + '─'.repeat(barW) + c(RST);
124
+ }
125
+ return ind + c(BOLD) + htext + c(RST);
126
+ }
127
+
128
+ // Blockquote.
129
+ if (line[0] === '>') {
130
+ const inner = line.length > 1 && line[1] === ' ' ? line.slice(2) : line.slice(1);
131
+ return ind + c(THEME.tool) + '│' + c(RST) + ' ' + inline(inner) + c(RST);
132
+ }
133
+
134
+ // Unordered list item (with simple nesting by leading-space depth).
135
+ let lead = 0;
136
+ while (lead < line.length && line[lead] === ' ') lead++;
137
+ const rest = line.slice(lead);
138
+ if (rest.length > 2 && (rest[0] === '-' || rest[0] === '*') && rest[1] === ' ') {
139
+ const nest = ' '.repeat(Math.floor(lead / 2));
140
+ return ind + nest + c(THEME.agent) + '❯' + c(RST) + ' ' + inline(rest.slice(2)) + c(RST);
141
+ }
142
+
143
+ // Ordered list item.
144
+ let ni = 0;
145
+ while (ni < line.length && line[ni] >= '0' && line[ni] <= '9') ni++;
146
+ if (ni > 0 && ni < line.length - 1 && line[ni] === '.' && line[ni + 1] === ' ') {
147
+ return ind + c(THEME.dim) + line.slice(0, ni) + '.' + c(RST) + ' ' + inline(line.slice(ni + 2)) + c(RST);
148
+ }
149
+
150
+ // Plain prose.
151
+ return ind + inline(line) + c(RST);
152
+ }
153
+
154
+ // Render the buffered code-block body as a width-aware box: a top border with
155
+ // the language label, syntax-highlighted body lines whose background fills to
156
+ // the physical right edge via EL (the diff.js technique), and a bottom border.
157
+ // The body is capped at the existing `max_output_lines` config (no new limit).
158
+ _renderCodeBox() {
159
+ const ind = this.indent;
160
+ const cols = getCols();
161
+ const { loadConfig } = require('../config');
162
+ const maxLines = (loadConfig().max_output_lines) || 50;
163
+
164
+ let body = this._codeBuf;
165
+ let overflow = 0;
166
+ if (body.length > maxLines) {
167
+ overflow = body.length - maxLines;
168
+ body = body.slice(0, maxLines);
169
+ }
170
+
171
+ const lines = [];
172
+
173
+ // Top border with language label, reaching the current width.
174
+ const label = this.codeLang ? ` ${this.codeLang} ` : '';
175
+ const prefix = '╭─── ';
176
+ const used = ind.length + termWidth(prefix) + termWidth(label);
177
+ const topFill = repeatToWidth('─', cols, used);
178
+ lines.push(
179
+ ind + c(FG_CODE_BORDER) + prefix + c(RST)
180
+ + c(FG_CODE_LANG) + label + c(RST)
181
+ + c(FG_CODE_BORDER) + topFill + c(RST)
182
+ );
183
+
184
+ // Body. Truncate the PLAIN text to the code width first (diff.js order), THEN
185
+ // colorize, so the syntax spans never get cut mid-escape; EL then fills the
186
+ // background to the edge.
187
+ const CODE_W = Math.max(1, cols - (ind.length + 2)); // │ + space
188
+ for (const raw of body) {
189
+ const disp = (raw || '').replace(/\t/g, ' ');
190
+ const code = _truncateByWidth(disp, CODE_W);
191
+ const colored = colorEnabled() ? colorizeCode(code) : code;
192
+ lines.push(
193
+ ind + c(FG_CODE_BORDER) + '│' + c(RST) + ' '
194
+ + c(FG_CODE_BG) + colored + c(EL) + c(RST)
195
+ );
196
+ }
197
+
198
+ if (overflow > 0) {
199
+ lines.push(ind + c(DIM) + `[... ${overflow} more lines]` + c(RST));
200
+ }
201
+
202
+ // Bottom border.
203
+ const botFill = repeatToWidth('─', cols, ind.length + 1);
204
+ lines.push(ind + c(FG_CODE_BORDER) + '╰' + botFill + c(RST));
205
+
206
+ return lines.join('\n');
207
+ }
208
+ }
209
+
210
+ // Render a complete stored content block through a fresh StreamMarkdown, line by
211
+ // line, EXACTLY as the live path does: feed every line but the last via
212
+ // feedLine; feed the trailing line only when non-empty (the live partial path
213
+ // skips an empty partial); then flush. Returns the joined styled body with NO
214
+ // trailing newline. This is the single seam that keeps --resume / history replay
215
+ // byte-identical to live narration.
216
+ function renderBlock(content, opts) {
217
+ const md = new StreamMarkdown(opts);
218
+ const lines = String(content == null ? '' : content).split('\n');
219
+ const partial = lines.pop();
220
+ const out = [];
221
+ for (const line of lines) {
222
+ const s = md.feedLine(line);
223
+ if (s !== null) out.push(s);
224
+ }
225
+ if (partial !== '') {
226
+ const s = md.feedLine(partial);
227
+ if (s !== null) out.push(s);
228
+ }
229
+ const tail = md.flush();
230
+ if (tail !== null) out.push(tail);
231
+ return out.join('\n');
232
+ }
233
+
234
+ module.exports = { StreamMarkdown, renderBlock };