@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
@@ -10,7 +10,11 @@
10
10
  // session ctx — those callbacks use only the per-tool fields, never session state.
11
11
 
12
12
  const { resolveMaxIterations } = require('../config');
13
- const { createWebActivityTracker } = require('../ui/web-activity');
13
+ const { createWebActivityTracker, serializeWebOp } = require('../ui/web-activity');
14
+ const { createFileActivityTracker } = require('../ui/file-activity');
15
+ const { buildToolOperation, serializeOperation } = require('../ui/tool-operation');
16
+ const { renderOperation } = require('../ui/render-operation');
17
+ const { normalizeCmdForDisplay } = require('../ui/format');
14
18
 
15
19
  function createTurnHandler(ctx, slashHandlers) {
16
20
  // The session ctx — the per-tool callbacks below intentionally shadow `ctx`
@@ -19,7 +23,7 @@ function createTurnHandler(ctx, slashHandlers) {
19
23
  const sessionCtx = ctx;
20
24
  const {
21
25
  inputField, statusBar, chatHistory, getConfig, approxTokens, resolveCommand,
22
- runAgentLoop, opts, TAG_REGISTRY, formatToolLine, writerModule,
26
+ runAgentLoop, opts, TAG_REGISTRY, writerModule,
23
27
  collapseListMsg, handlePendingSelection, showPendingStep, activateNavCapture, finalizeListMsg,
24
28
  createChatIfNeeded, saveTurnToDashboard, saveSession,
25
29
  } = ctx;
@@ -134,6 +138,30 @@ function createTurnHandler(ctx, slashHandlers) {
134
138
  // think block (Qwen3-style: plain text followed by </think>, no opening tag).
135
139
  let implicitThinkPhase = !opts.showThink;
136
140
  let implicitThinkBuffer = '';
141
+ // Live-narration safety signals for the NATIVE rail only (XML rail ignores
142
+ // all three and keeps the buffered-until-boundary behavior unchanged):
143
+ // • nativeRail — set from onStreamStart's first arg; true only
144
+ // when this stream is on the native tool-call rail.
145
+ // • reasoningSeen — signal (a): a delta.reasoning_content arrived this
146
+ // iteration, proving the model uses the structured
147
+ // reasoning channel → subsequent content is narration.
148
+ // • inlineReasoningFalse — signal (b): the active profile asserts
149
+ // inline_reasoning:false → never inlines reasoning.
150
+ // When nativeRail AND (reasoningSeen OR inlineReasoningFalse), onToken may
151
+ // eager-open the implicit-think gate and stream narration live (see below).
152
+ let nativeRail = false;
153
+ let reasoningSeen = false;
154
+ let inlineReasoningFalse = false;
155
+ // Orphan closing reasoning tag, emitted VERBATIM by the StreamParser: a lone
156
+ // </think> (or </reasoning>/</reflection>/</plan>) has no matching open tag, so
157
+ // the parser's closing form `/think` is not a TAG_REGISTRY key (the registry
158
+ // keys are the bare names) and it streams the literal `</tag>` through onToken
159
+ // (agent.js StreamParser: `if (!entry) this.onToken('<' + tagRaw + '>')`).
160
+ // MiniMax-style models emit reasoning via BOTH reasoning_content AND an inline
161
+ // </think> terminator in content; that stray tag must never reach the terminal.
162
+ // Persisted history is already clean (cleanAssistantContent strips it), so this
163
+ // guard is live-stream-only. Match exactly the raw-emitted closing shapes.
164
+ const ORPHAN_CLOSE_TAG_RE = /^<\/(think|reasoning|reflection|plan)>$/i;
137
165
 
138
166
  // Web-activity collapse (Task W.3): in the default (non-debug) view, a run of
139
167
  // consecutive web ops (web_search → http_get) renders as ONE process-summary
@@ -141,6 +169,14 @@ function createTurnHandler(ctx, slashHandlers) {
141
169
  // is bypassed and web ops render the normal per-op way (full detail).
142
170
  const webTracker = createWebActivityTracker({ writerModule });
143
171
 
172
+ // File-activity collapse (parallel instance of the web tracker): in the
173
+ // default (non-debug) view, a run of consecutive same-type pure file reads
174
+ // (read_file / list_dir) collapses into ONE process-summary line instead of a
175
+ // per-op line each — unless the run is only 1–2 ops, which still commit as
176
+ // individual lines (decided at flush). read and list group SEPARATELY. Fresh
177
+ // per turn. In --debug the tracker is bypassed (full per-op detail).
178
+ const fileTracker = createFileActivityTracker({ writerModule });
179
+
144
180
  const callbacks = {
145
181
  onThinking: () => statusBar.update('thinking', 'Thinking...'),
146
182
  onRequestSent: () => {
@@ -148,14 +184,56 @@ function createTurnHandler(ctx, slashHandlers) {
148
184
  // Reset think-phase detection for each new agent iteration.
149
185
  implicitThinkPhase = !opts.showThink;
150
186
  implicitThinkBuffer = '';
187
+ // Reset the live-narration safety signals alongside the gate — each API
188
+ // call re-establishes the rail and re-observes the reasoning channel.
189
+ nativeRail = false;
190
+ reasoningSeen = false;
191
+ inlineReasoningFalse = false;
151
192
  },
152
- onStreamStart: () => {
193
+ onStreamStart: (isNativeRail, inlineReasoning) => {
194
+ // Capture the rail + inline-reasoning assertion threaded from agent.js
195
+ // (signal b). Recorded BEFORE the first content token so onToken's
196
+ // eager-open check below sees them on the very first token.
197
+ nativeRail = !!isNativeRail;
198
+ inlineReasoningFalse = inlineReasoning === false;
153
199
  // If showThink is on, switch to streaming immediately.
154
200
  // Otherwise keep "Thinking…" until </think> is resolved.
155
201
  if (opts.showThink) statusBar.update('streaming', 'Streaming response');
156
202
  },
203
+ onReasoningStart: () => {
204
+ // Signal (a): the model emitted reasoning_content this iteration, so the
205
+ // structured reasoning channel is in use. Fires before any delta.content
206
+ // token, so the eager-open in onToken sees it for the first token.
207
+ reasoningSeen = true;
208
+ },
157
209
  onTagOpen: (tag, attrs) => {
158
210
  const entry = TAG_REGISTRY[tag];
211
+ // Positive-evidence early exit from the implicit-think gate (live narration).
212
+ // The gate buffers leading bare text because a Qwen3-style model emits implicit
213
+ // reasoning as bare text terminated by an orphan </think> — and that reasoning
214
+ // is indistinguishable from ordinary narration until the boundary arrives. So
215
+ // for a plain bare-text preamble we STAY buffered until </think> (handled in
216
+ // onToken); flipping early there could stream hidden reasoning = a leak.
217
+ // But two tag openings are positive PROOF the bare text is NOT implicit
218
+ // reasoning, so we can open the gate and start streaming live immediately:
219
+ // • a <think>/<reasoning>/<reflection>/<plan> tag (display:'think_bubble')
220
+ // means THIS model delimits reasoning with explicit tags; that inner
221
+ // content is consumed by the StreamParser and never reaches onToken (and is
222
+ // suppressed by handleTag when !showThink), so any bare text outside the
223
+ // tag is narration — safe to stream.
224
+ // • a <final_answer> (type:'final') streams its inner content THROUGH onToken
225
+ // (streamInner in the parser), so the gate must open or the answer is
226
+ // swallowed by the buffer.
227
+ // We deliberately do NOT exit on a tool tag: bare-reasoning-then-tool with no
228
+ // </think> is possible (malformed implicit-think), so opening there could leak.
229
+ // Any buffered leading text is DISCARDED here (treated as reasoning), never
230
+ // flushed — preserving implicit-think suppression.
231
+ if (!opts.showThink && implicitThinkPhase &&
232
+ (entry?.display === 'think_bubble' || entry?.type === 'final')) {
233
+ implicitThinkPhase = false;
234
+ implicitThinkBuffer = '';
235
+ statusBar.update('streaming', 'Streaming response');
236
+ }
159
237
  if (entry?.type === 'tool') {
160
238
  const actionLabel = entry.label || tag;
161
239
  const detail = attrs.path || attrs.url || attrs.key || attrs.src || '';
@@ -176,13 +254,36 @@ function createTurnHandler(ctx, slashHandlers) {
176
254
  statusBar.update('streaming', 'Streaming response');
177
255
  },
178
256
  onPermissionAsk: (tag, input) => {
257
+ // Flush any open file/web activity group BEFORE the permission picker
258
+ // opens. The permission gate fires ahead of onToolStart (agent.js — the
259
+ // "ask before onToolStart" comment), so the non-groupable flush that
260
+ // onToolStart normally performs (below) is sequenced AFTER the modal and
261
+ // can't fire while it's open — leaving an open group stranded LIVE in the
262
+ // writer's activity region beside the prompt for the modal's whole life.
263
+ // Flush here so the group commits to scrollback ABOVE the prompt instead.
264
+ // This is safe to do unconditionally: groupable tools (read_file/list_dir)
265
+ // are read-only with a NULL permission descriptor, so onPermissionAsk
266
+ // NEVER fires for them — by the time we get here the prompting tool is by
267
+ // definition non-groupable, exactly the case onToolStart already flushes.
268
+ // flush() is idempotent (isOpen()/groupId===null guard), so the later
269
+ // onToolStart flush, the turn-end finally flush, or the deny path all
270
+ // become no-ops — no double commit. Covers the deny case too: a denied
271
+ // tool's group is committed here rather than stranded until the finally.
272
+ if (webTracker.isOpen()) webTracker.flush();
273
+ if (fileTracker.isOpen()) fileTracker.flush();
179
274
  // Status-bar update fires while the permission picker is open so
180
275
  // the user can see what's pending in the side label, not just
181
276
  // inside the modal. Mirrors the labels onToolStart uses post-grant
182
277
  // — the next streaming/idle state will overwrite this when the
183
278
  // picker closes (whether granted or denied).
184
279
  const actionLabel = TAG_REGISTRY[tag]?.label || tag;
185
- const short = input && input.length > 40 ? input.slice(0, 40) + '…' : (input || '');
280
+ // Flatten embedded newlines/tabs (e.g. heredoc commands) BEFORE the
281
+ // slice so the status label is a single physical row. A raw slice of
282
+ // multi-line input rides a \n into the status string → the live region
283
+ // mis-counts rows (1 logical line spanning 2+ physical rows) and leaks
284
+ // stale rules/spinners into scrollback. See Phase 4 fix-A.
285
+ const flat = normalizeCmdForDisplay(input);
286
+ const short = flat.length > 40 ? flat.slice(0, 40) + '…' : flat;
186
287
  const isDownload = tag === 'download' || tag === 'http_get';
187
288
  if (isDownload) {
188
289
  statusBar.update('waiting_download', `Waiting for download: ${short}`);
@@ -191,24 +292,42 @@ function createTurnHandler(ctx, slashHandlers) {
191
292
  }
192
293
  },
193
294
  onToolStart: (tag, input, ctx) => {
295
+ // Phase 7b boundary — commit the PREVIOUS op's held detail band to
296
+ // scrollback BEFORE this op's running line (or web group) is installed,
297
+ // so the committed preview lands above the new activity row. Mirrors the
298
+ // web tracker's "flush previous, then start new" sequencing. No-op when
299
+ // nothing is deferred. Runs before the web branch too, so a non-web
300
+ // preview followed by a web op still commits in chronological order.
301
+ chatHistory.commitDeferredDetail();
194
302
  const actionLabel = TAG_REGISTRY[tag]?.label || tag;
195
- const short = input && input.length > 40 ? input.slice(0, 40) + '…' : (input || '');
303
+ // Flatten before slicing see onPermissionAsk above (Phase 4 fix-A).
304
+ const flat = normalizeCmdForDisplay(input);
305
+ const short = flat.length > 40 ? flat.slice(0, 40) + '…' : flat;
196
306
  const isDownload = tag === 'download' || tag === 'http_get';
197
307
  if (isDownload) {
198
308
  statusBar.update('waiting_download', `Waiting for download: ${short}`);
199
309
  } else {
200
310
  statusBar.update('tool', `${actionLabel}: ${short}`);
201
311
  }
202
- // Web-activity collapse (Task W.3): in the default view, fold this web op
203
- // into the running process-summary line instead of its own activity row.
204
- // --debug keeps the per-op line (fall through to the normal path below).
205
- if (!sessionCtx.debugMode && webTracker.isWeb(tag)) {
312
+ // Web- and file-activity collapse: in the default view, fold this op into
313
+ // its running process-summary line instead of its own activity row.
314
+ // --debug bypasses both trackers (full per-op detail). Switching group
315
+ // type or starting a non-grouped tool — closes the OTHER open group
316
+ // first, so its committed summary lands ABOVE this op in scrollback. (A
317
+ // read↔list key change within the file group is handled inside
318
+ // fileTracker.start.)
319
+ const webOp = !sessionCtx.debugMode && webTracker.isWeb(tag);
320
+ const fileOp = !sessionCtx.debugMode && fileTracker.isGroupable(tag);
321
+ if (!webOp && webTracker.isOpen()) webTracker.flush();
322
+ if (!fileOp && fileTracker.isOpen()) fileTracker.flush();
323
+ if (webOp) {
206
324
  webTracker.start(tag, input);
207
325
  return;
208
326
  }
209
- // A non-web tool (or debug mode) closes any open web group first, so its
210
- // committed summary lands ABOVE this tool's line in scrollback.
211
- if (webTracker.isOpen()) webTracker.flush();
327
+ if (fileOp) {
328
+ fileTracker.start(tag, input);
329
+ return;
330
+ }
212
331
  // Register the invocation with the writer's activity region.
213
332
  // The render function is re-invoked by the writer on every
214
333
  // redraw so the pending line's elapsed time stays current with
@@ -223,23 +342,21 @@ function createTurnHandler(ctx, slashHandlers) {
223
342
  // name check with a category flag (e.g. blocking: true on the
224
343
  // tool spec) if more blocking tools appear.
225
344
  if (ctx && ctx.id) {
345
+ // Output Refactor (Phase 1): the interactive core tool line is now
346
+ // produced via a ToolOperation descriptor → the pure renderOperation,
347
+ // instead of an inline formatToolLine call. Byte-for-byte identical —
348
+ // this is a re-routing, not a re-styling.
226
349
  if (tag === 'ask_user') {
227
- const staticLine = formatToolLine({
228
- status: 'pending',
229
- tag,
230
- arg: input,
231
- attrs: ctx.attrs,
232
- noDuration: true,
233
- });
350
+ const staticLine = renderOperation(
351
+ buildToolOperation({ id: ctx.id, tag, arg: input, attrs: ctx.attrs, status: 'pending', noDuration: true }),
352
+ { mode: 'ansi', phase: 'pending' },
353
+ );
234
354
  writerModule.startActivity(ctx.id, () => staticLine);
235
355
  } else {
236
- writerModule.startActivity(ctx.id, (elapsedMs) => formatToolLine({
237
- status: 'pending',
238
- tag,
239
- arg: input,
240
- attrs: ctx.attrs,
241
- durationMs: elapsedMs,
242
- }));
356
+ writerModule.startActivity(ctx.id, (elapsedMs) => renderOperation(
357
+ buildToolOperation({ id: ctx.id, tag, arg: input, attrs: ctx.attrs, status: 'pending', durationMs: elapsedMs }),
358
+ { mode: 'ansi', phase: 'pending' },
359
+ ));
243
360
  }
244
361
  }
245
362
  },
@@ -250,21 +367,55 @@ function createTurnHandler(ctx, slashHandlers) {
250
367
  // failure (a 403/406 or timeout shows as "blocked"); the detailed error
251
368
  // body stays hidden in the collapsed view (visible under --debug).
252
369
  if (!sessionCtx.debugMode && webTracker.isWeb(tag)) {
370
+ // Live display unchanged — the tracker still owns the collapsed web
371
+ // summary region. Phase 6c-i: instead of returning `undefined` (which
372
+ // persisted a `null` slot → web vanished into the legacy whole-blob
373
+ // summary on replay), hand back a dedicated web-op core so the agent
374
+ // loop's `displayCore || null` push stores it on BOTH rails (native
375
+ // {role:'tool'} `_display`; XML `_display[]` slot). Nothing in the live
376
+ // render path reads this return value, so the live region is untouched;
377
+ // every replay reader treats the web-core as fallback (chat-history /
378
+ // chat-session) so the screen stays byte-identical until 6c-ii.
253
379
  webTracker.end(tag, result, durationMs, ctx);
254
380
  if (hasError) statusBar.update('streaming', 'Streaming response');
255
- return;
381
+ return serializeWebOp(ctx, tag, durationMs);
256
382
  }
257
383
  const isBlocking = tag === 'ask_user';
258
- const finalLine = formatToolLine({
259
- status: hasError ? 'failure' : 'success',
384
+ // Output Refactor (Phase 1): build ONE descriptor for this finished call
385
+ // and render both the committed result line and (below) its diff detail
386
+ // from it — the single source of truth, replacing the inline
387
+ // formatToolLine + buildExecutionDiff pair. Byte-for-byte identical.
388
+ const operation = buildToolOperation({
389
+ id: ctx ? ctx.id : null,
260
390
  tag,
261
391
  arg: ctx && ctx.attrs ? (ctx.attrs.command || ctx.attrs.path || ctx.attrs.url || ctx.attrs.src || ctx.attrs.key || ctx.attrs.name || ctx.attrs.pattern) : '',
262
392
  attrs: ctx ? ctx.attrs : null,
393
+ status: hasError ? 'error' : 'ok',
263
394
  durationMs,
264
395
  meta: ctx ? ctx.meta : null,
265
396
  error: ctx ? ctx.error : null,
397
+ diff: ctx ? ctx.diff : null,
398
+ // Phase 5: hand the model-facing result to the descriptor so it can
399
+ // derive an output-preview detail (shell/MCP/subagent). Chrome only —
400
+ // the model already received the full result via boundToolOutput.
401
+ output: typeof result === 'string' ? result : null,
266
402
  noDuration: isBlocking,
267
403
  });
404
+ // File-activity collapse: a SUCCESSFUL read_file/list_dir folds into the
405
+ // running file-group aggregate instead of committing its own line — the
406
+ // group's single summary (or, for a 1–2 op run, the individual lines)
407
+ // commits at flush. The op core is STILL persisted here (serializeOperation
408
+ // below) so replay re-groups it. An ERRORED file op does NOT join the
409
+ // group: it falls through to flush the success-group first (so its summary
410
+ // lands ABOVE), then renders the error standalone + error body.
411
+ if (!sessionCtx.debugMode && !hasError && fileTracker.isGroupable(tag)) {
412
+ fileTracker.end(operation);
413
+ return serializeOperation(operation);
414
+ }
415
+ // A non-grouped tool end (or an errored file op) closes any open file
416
+ // group first, so its committed summary lands ABOVE this line.
417
+ if (fileTracker.isOpen()) fileTracker.flush();
418
+ const finalLine = renderOperation(operation, { mode: 'ansi', phase: 'result' });
268
419
  if (ctx && ctx.id) {
269
420
  writerModule.endActivity(ctx.id, finalLine);
270
421
  } else {
@@ -273,6 +424,37 @@ function createTurnHandler(ctx, slashHandlers) {
273
424
  // to a direct scrollback line so the tool still leaves a trace.
274
425
  writerModule.scrollback(finalLine);
275
426
  }
427
+ // Execution-time file-edit diff. This is the SINGLE site the full diff of
428
+ // a successful mutating edit renders — decoupled from the permission modal,
429
+ // so an auto-approved edit shows its diff exactly like a manual one, and
430
+ // every entry mode (fresh / --resume / /history / /chats) renders it the
431
+ // same way. Loaded history replays through displayLoadedMessages (summaries
432
+ // only), never onToolEnd, so past turns carry no diff payload and are not
433
+ // replayed. Capped at config.diff_max_lines (head+tail for a large edit).
434
+ if (!hasError && operation.detail && operation.detail.kind === 'diff') {
435
+ const diffStr = renderOperation(operation, {
436
+ mode: 'ansi',
437
+ phase: 'detail',
438
+ maxLines: (getConfig() || {}).diff_max_lines,
439
+ });
440
+ if (diffStr) writerModule.scrollback(diffStr);
441
+ }
442
+ // Phase 5/7b: collapsed output preview for shell/MCP/subagent successes.
443
+ // DEFERRED into the writer's redrawable detail band (not committed to
444
+ // scrollback yet) — the held slot commits once at the next boundary
445
+ // (next-op start / assistant answer / turn end). The preview is static
446
+ // (first N lines + `… M more lines`, no expand affordance). Model-facing
447
+ // context is untouched (the full output already reached the model). The
448
+ // result line above and any diff still commit immediately.
449
+ if (!hasError && operation.detail && operation.detail.kind === 'output') {
450
+ chatHistory.deferToolOutput({
451
+ role: 'tool',
452
+ tag,
453
+ content: '',
454
+ output: operation.detail.payload.body,
455
+ previewLines: (getConfig() || {}).shell_preview_lines || 5,
456
+ });
457
+ }
276
458
  if (hasError) {
277
459
  // Preserve the expandable error body as a follow-up tool
278
460
  // bubble. Empty content suppresses its header so the scrollback
@@ -283,46 +465,123 @@ function createTurnHandler(ctx, slashHandlers) {
283
465
  }
284
466
  statusBar.update('streaming', 'Streaming response');
285
467
  }
468
+ // Phase 6a — hand the SAME descriptor back to the agent loop (serialized)
469
+ // so the native rail can persist it as a `_display` sibling on the tool
470
+ // result message; replay then rebuilds it for full-fidelity rendering.
471
+ // Display chrome only — never touches the model-facing `content`. The
472
+ // web-activity path above returns its own web-op core (Phase 6c-i) which
473
+ // every replay reader routes to the legacy fallback, so web ops still
474
+ // render via the summary on replay (aggregation lands in 6c-ii).
475
+ return serializeOperation(operation);
286
476
  },
287
477
  onToken: (token) => {
288
478
  if (!opts.showThink && implicitThinkPhase) {
289
- // Check if this token is the closing think tag (Qwen3-style implicit think).
290
- if (/^<\/(think|reasoning|reflection)>$/i.test(token.trim())) {
291
- // Thinking phase is over discard buffered reasoning, start streaming.
479
+ // NATIVE-RAIL eager-open (live token-by-token narration). Gated on a
480
+ // safety signal so reasoning is NEVER leaked: open the gate eagerly
481
+ // ONLY when this stream is on the native rail AND the model has proven
482
+ // (a) it uses the structured reasoning channel this iteration
483
+ // (reasoningSeen) OR (b) it asserts inline_reasoning:false. In either
484
+ // case the leading content is narration, so we open the gate, drop the
485
+ // (empty) buffer, and fall through to stream THIS token live. The XML
486
+ // rail and the no-signal native case skip this branch entirely and keep
487
+ // the buffered-until-boundary fallback below (no behavior change, no
488
+ // leak). Mirror the think_bubble/orphan-</think> exits' status update.
489
+ if (nativeRail && (reasoningSeen || inlineReasoningFalse)) {
292
490
  implicitThinkPhase = false;
293
491
  implicitThinkBuffer = '';
294
492
  statusBar.update('streaming', 'Streaming response');
493
+ // fall through — stream this and all subsequent tokens live. The
494
+ // orphan-close-tag filter below still runs so a stray </think> that
495
+ // MiniMax inlines alongside reasoning_content never reaches the
496
+ // terminal (regression from 938f583's eager-open, which skipped the
497
+ // else-branch's drop guard for this and every subsequent token).
498
+ } else {
499
+ // Check if this token is the closing think tag (Qwen3-style implicit think).
500
+ if (ORPHAN_CLOSE_TAG_RE.test(token.trim())) {
501
+ // Thinking phase is over — discard buffered reasoning, start streaming.
502
+ implicitThinkPhase = false;
503
+ implicitThinkBuffer = '';
504
+ statusBar.update('streaming', 'Streaming response');
505
+ return;
506
+ }
507
+ // Buffer the token; keep the thinking animation visible.
508
+ implicitThinkBuffer += token;
295
509
  return;
296
510
  }
297
- // Buffer the token; keep the thinking animation visible.
298
- implicitThinkBuffer += token;
299
- return;
511
+ }
512
+ // Drop any orphan closing reasoning tag on every token, regardless of which
513
+ // branch opened the gate (eager-open or showThink). The StreamParser emits
514
+ // these verbatim, so once the gate is open they would otherwise stream live.
515
+ if (ORPHAN_CLOSE_TAG_RE.test(token.trim())) return;
516
+ // Ordering fix (Option b) — flush any open file/web activity group BEFORE
517
+ // the FIRST content-bearing narration token commits to scrollback. Streamed
518
+ // narration commits INCREMENTALLY: streamToken() emits the "▸ AI-agent"
519
+ // header and each complete line to immutable scrollback (chat-history.js)
520
+ // BEFORE onAssistantMessage/finalizeLastMessage ever fires. So flushing only
521
+ // at onAssistantMessage is too LATE for the streamed path — the narration
522
+ // lines are already above a still-open group, which then commits BELOW the
523
+ // conclusion it's based on (the "list ×3 below 'directory almost empty'"
524
+ // bug). Flushing here, at streaming-start, guarantees the group's summary
525
+ // commits ABOVE the first visible narration line.
526
+ //
527
+ // Gate strictly: only when the stream has NOT yet started (so we flush once,
528
+ // before the header) AND this token carries non-whitespace content — pure
529
+ // whitespace streaming artifacts in a silent read,read,read run must NOT
530
+ // flush, so such runs still collapse to one "explored ×N". flush() is
531
+ // idempotent (groupId===null guard), so the later onAssistantMessage,
532
+ // onToolStart, onPermissionAsk, and turn-end finally flushes all no-op —
533
+ // exactly one commit.
534
+ if (token && token.trim() && !chatHistory.isStreaming?.()) {
535
+ if (webTracker.isOpen()) webTracker.flush();
536
+ if (fileTracker.isOpen()) fileTracker.flush();
300
537
  }
301
538
  chatHistory.streamToken(token);
302
539
  statusBar.onToken();
303
540
  },
304
- onAssistantMessage: (cleanContent) => {
305
- // If </think> was never seen, the model had no implicit think block —
306
- // flush whatever was buffered as normal streaming content.
541
+ onAssistantMessage: (cleanContent, meta) => {
542
+ // If </think> was never seen, the model had no implicit think block — its
543
+ // leading text was ordinary narration. Drop the raw buffered tokens: the
544
+ // cleaned, canonical narration arrives as `cleanContent` and is rendered by
545
+ // finalizeLastMessage below (as a pre-tool bubble when nothing streamed live),
546
+ // so re-emitting the raw buffer would double it.
307
547
  if (implicitThinkPhase && implicitThinkBuffer) {
308
548
  implicitThinkPhase = false;
309
549
  implicitThinkBuffer = '';
310
550
  }
311
- // Web-activity ordering (W.3 regression fix): commit any still-open web
312
- // group BEFORE the answer is finalized, so the collapsed "✓ web · …"
313
- // summary lands ABOVE the answer in scrollback (pre-W.3 ordering).
551
+ // Terminal-iteration signal. agent.js now passes `{ terminal }` explicitly
552
+ // (true only on the final, no-tool-call answer). Fall back to the legacy
553
+ // "content is non-empty" proxy when the flag is absent (older callers / the
554
+ // web-ordering unit tests drive these callbacks directly with one arg).
555
+ const terminal = meta && typeof meta.terminal === 'boolean'
556
+ ? meta.terminal
557
+ : !!(cleanContent && cleanContent.trim());
558
+ // Ordering fix (Option b) — commit any still-open file/web activity group
559
+ // BEFORE the answer is finalized, so the collapsed summary lands ABOVE the
560
+ // narration in scrollback (correct chronological ordering: a conclusion has
561
+ // the group it's based on committed above it).
314
562
  //
315
- // Guard on non-empty content: that is exactly the "terminal response"
316
- // signal. Intermediate web-tool iterations pass cleanContent === ''
317
- // (suppressed because they carried tool calls agent.js), so they do
318
- // NOT flush the group stays open and the multi-step search→fetch
319
- // activity stays collapsed into a single line (the W.3 guarantee).
320
- // The final-answer iteration passes non-empty content flush once.
321
- // Empty/interrupted turns (no non-empty message ever arrives) fall back
322
- // to the turn-end `finally` flush, which is now the safety net.
323
- if (cleanContent && cleanContent.trim() && webTracker.isOpen()) {
563
+ // Flush on the TERMINAL signal (the final no-tool answer) OR on any
564
+ // CONTENT-BEARING intermediate narration. This is the deliberate Option-(b)
565
+ // tradeoff: an intermediate narration that carries visible content now
566
+ // flushes the open group, so a "chatty" multi-read run FRAGMENTS into
567
+ // correctly-ordered sub-groups (each "explored ×N" above its narration)
568
+ // rather than collapsing across a conclusion that was based on it. A SILENT
569
+ // multi-read run (empty/whitespace-only interim narration pure streaming
570
+ // artifacts) does NOT flush, so it still collapses fully to one summary.
571
+ //
572
+ // For the STREAMED path the open group is already committed above by the
573
+ // onToken streaming-start flush; this is the backstop for the non-streaming
574
+ // / finalize-only path (and the direct-callback unit tests). flush() is
575
+ // idempotent, so when both fire only one commit results. Empty/interrupted
576
+ // turns (no terminal message, no content) fall back to the turn-end
577
+ // `finally` flush, which is the safety net.
578
+ const contentful = !!(cleanContent && cleanContent.trim());
579
+ if ((terminal || contentful) && webTracker.isOpen()) {
324
580
  webTracker.flush();
325
581
  }
582
+ if ((terminal || contentful) && fileTracker.isOpen()) {
583
+ fileTracker.flush();
584
+ }
326
585
  chatHistory.finalizeLastMessage(cleanContent);
327
586
  },
328
587
  onMetricsUpdate: (data) => statusBar.updateMetrics(data),
@@ -330,8 +589,8 @@ function createTurnHandler(ctx, slashHandlers) {
330
589
  statusBar.update('thinking', `Retrying (${attempt}/${max})...`);
331
590
  },
332
591
  onDebug: (block) => {
333
- // Render in-history as a tool-style bubble so ctrl+O expand works and
334
- // the RAW RESPONSE text survives TUI redraws (stderr would be clobbered).
592
+ // Render in-history as a tool-style bubble so the RAW RESPONSE text
593
+ // survives TUI redraws (stderr would be clobbered).
335
594
  chatHistory.addMessage({ role: 'tool', tag: 'debug', content: 'DEBUG', output: block });
336
595
  },
337
596
  onError: (err) => {
@@ -430,9 +689,17 @@ function createTurnHandler(ctx, slashHandlers) {
430
689
  statusBar.update('error', err.message || 'Agent error');
431
690
  chatHistory.addMessage({ role: 'system', content: err.message || 'Agent error', isError: true });
432
691
  } finally {
692
+ // Phase 7b boundary — commit any trailing op's held detail band before the
693
+ // turn unwinds (the turn may have ended right after a tool with no
694
+ // following message). No-op when nothing is deferred; ordered before the
695
+ // web flush (the two are mutually exclusive in practice).
696
+ try { chatHistory.commitDeferredDetail(); } catch { /* never block turn teardown */ }
433
697
  // Commit any still-open web-activity summary (the turn may have ended right
434
698
  // after a web op, or been interrupted mid-group) before the turn unwinds.
435
699
  try { webTracker.flush(); } catch { /* never block turn teardown */ }
700
+ // Commit any still-open file-activity group (turn ended right after a read
701
+ // run, or was interrupted mid-group) before the turn unwinds.
702
+ try { fileTracker.flush(); } catch { /* never block turn teardown */ }
436
703
  inputField.removeListener('abort', _onAbort);
437
704
  }
438
705
 
@@ -86,13 +86,11 @@ function createChatCommand(deps) {
86
86
  onRemoveMessage: (id) => chatHistory.removeById(id),
87
87
  // Modal-region API: setModal replaces the modal live band above the
88
88
  // status region; clearModal drops it. Arrow-key redraws go through
89
- // setModal only — no scrollback churn. When the picker resolves we
90
- // clear the modal and push a single summary line to scrollback.
89
+ // setModal only — no scrollback churn. When the picker resolves we just
90
+ // clear the modal the execution result line is the sole post-approval
91
+ // confirmation, so no summary line is pushed to scrollback (Phase 2 D1).
91
92
  onShowModal: (lines) => writer.setModal(lines),
92
- onCloseModal: (summary) => {
93
- writer.clearModal();
94
- if (summary) chatHistory.addMessage({ role: 'system', content: summary });
95
- },
93
+ onCloseModal: () => { writer.clearModal(); },
96
94
  onCaptureNavigation: (handler) => {
97
95
  inputField.captureNavigation(handler);
98
96
  return () => inputField.releaseNavigation();
@@ -100,8 +98,6 @@ function createChatCommand(deps) {
100
98
  captureSelect: (menu) => inputField.captureSelect(menu),
101
99
  });
102
100
 
103
- inputField.on('expand', () => chatHistory.toggleLastExpand());
104
-
105
101
  const cwd = process.cwd();
106
102
  let currentModel = opts.model || getConfig().default_model;
107
103
  let resolvedTokenLimit = await resolveTokenLimit(currentModel);
@@ -331,6 +327,14 @@ function createChatCommand(deps) {
331
327
 
332
328
 
333
329
  statusBar.update('idle');
330
+ // Re-sync the clock to the input field's actual idle state. update('idle')
331
+ // unconditionally restarts the clock (the not-paused ⇒ clock-running
332
+ // invariant), but if an await above (resume / MCP connectAll) yielded the
333
+ // event loop, the one-shot _goIdle already fired and no active→idle
334
+ // transition will re-fire pause(). Converge both paths: if the field is
335
+ // already idle, pause the clock so the viewport can scroll. On a no-await
336
+ // start the field is not yet idle here, so this is a no-op.
337
+ if (inputField.isIdle()) statusBar.pause();
334
338
 
335
339
  // Slash-command handlers (lib/commands/chat-slash.js), keyed by the canonical
336
340
  // registry name. The parity check below guarantees registry ↔ handler stay
package/lib/config.js CHANGED
@@ -206,6 +206,16 @@ function normalizeConfig(cfg = {}) {
206
206
  // native_tools defaults to true; only explicit false/0/"false"/"0" opts out.
207
207
  const nt = entry.native_tools;
208
208
  normalized.native_tools = !(nt === false || nt === 0 || nt === '0' || nt === 'false');
209
+ // inline_reasoning (live-narration safety signal): an OPTIONAL explicit
210
+ // boolean assertion about whether this model inlines its reasoning into
211
+ // delta.content (Qwen3-style bare text + orphan </think>). Left unset by
212
+ // default → assume it MIGHT inline (safe default: keep buffering until a
213
+ // boundary). Only an explicit `false` asserts "never inlines" → the agent
214
+ // loop may stream narration live from token 1 on the native rail. Only an
215
+ // explicit boolean is persisted; any other value is dropped (stays unset).
216
+ if (typeof entry.inline_reasoning === 'boolean') {
217
+ normalized.inline_reasoning = entry.inline_reasoning;
218
+ }
209
219
  // Multimodal image input (Task 5.4). `vision` (only when an explicit
210
220
  // boolean) marks the profile vision-capable or text-only; a text-only
211
221
  // profile makes an image attach fail LOUD rather than silently drop.
@@ -418,6 +428,22 @@ function isNativeToolsActive(model) {
418
428
  return !(profile && profile.native_tools === false);
419
429
  }
420
430
 
431
+ // Resolves the active profile's `inline_reasoning` assertion (live-narration
432
+ // safety signal b). Returns the explicit boolean if the profile sets one, else
433
+ // `undefined` ("unknown — assume it might inline reasoning"). Mirrors the
434
+ // profile lookup used by isNativeToolsActive. The agent loop treats only an
435
+ // explicit `false` as the safe-to-stream-live signal.
436
+ function getInlineReasoning(model) {
437
+ const cfg = loadConfig();
438
+ if (!Array.isArray(cfg.models)) return undefined;
439
+ const profile = cfg.models.find(
440
+ (p) => p && p.api_base === cfg.api_base && p.model === model
441
+ );
442
+ return profile && typeof profile.inline_reasoning === 'boolean'
443
+ ? profile.inline_reasoning
444
+ : undefined;
445
+ }
446
+
421
447
  const REDACTED_KEYS = new Set(['api_key', 'auth_token']);
422
448
 
423
449
  function configShow(systemPromptOverride = null) {
@@ -457,6 +483,7 @@ module.exports = {
457
483
  configSet,
458
484
  configShow,
459
485
  isNativeToolsActive,
486
+ getInlineReasoning,
460
487
  loadConfig,
461
488
  loadUserConfig,
462
489
  normalizeConfig,