@bahulam/code 2.6.14 → 2.6.16

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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Interactive per-role model form for /model (PRD-076 W7).
3
+ *
4
+ * ↑↓ picks a role row, ←→ cycles through [backend default] + the curated
5
+ * platform catalog for that role, Enter applies to session overrides,
6
+ * c resets every row to default, Esc cancels. Same raw-stdin overlay
7
+ * pattern as the resume picker (repl-resume.mjs): pause readline, raw
8
+ * mode on, redraw in place with cursor-up + erase-down, restore on exit.
9
+ */
10
+
11
+ import { c } from './ansi.mjs';
12
+ import { fitAnsiLine, writeOverlayFrame, eraseOverlayFrame } from './repl-format.mjs';
13
+
14
+ const DEFAULT_SENTINEL = '__default__';
15
+
16
+ function creditBadge(row) {
17
+ const usd = Number(row?.input_cost_usd_per_m);
18
+ if (!Number.isFinite(usd) || usd <= 0) return '';
19
+ const credits = usd * 200; // credits = provider cost × 2 × 100/USD
20
+ return `~${credits < 10 ? credits.toFixed(1) : String(Math.round(credits))} cr/M`;
21
+ }
22
+
23
+ /**
24
+ * @param {object} opts
25
+ * @param {object|null} opts.rl readline instance to pause/resume
26
+ * @param {Array} opts.roles [{ role, label, current, defaultLabel }]
27
+ * @param {Array} opts.catalog raw /api/models rows (may be empty)
28
+ * @param {Array} [opts.fallbackIds] model ids to cycle when no curated catalog
29
+ * @param {string} [opts.unavailableNote] why the catalog is missing (shown in header)
30
+ * @returns {Promise<{overrides: Record<string,string>}|null>} null = cancelled
31
+ */
32
+ export async function pickModelOverridesForm({ rl, roles, catalog, fallbackIds, unavailableNote }) {
33
+ if (!process.stdin.isTTY) return null;
34
+ if (rl) rl.pause();
35
+
36
+ const curated = (catalog || []).filter(m => m?.harness_validated && m?.id);
37
+ const usingFallback = curated.length === 0;
38
+ const optionIds = usingFallback
39
+ ? [...new Set((fallbackIds || []).filter(Boolean))]
40
+ : curated.map(m => m.id);
41
+ const baseOptions = [DEFAULT_SENTINEL, ...optionIds];
42
+ const byId = new Map(curated.map(m => [m.id, m]));
43
+
44
+ // Per-row option list; a current override that isn't in the curated list
45
+ // is appended so it stays visible and selectable.
46
+ const rows = roles.map(r => {
47
+ let opts = baseOptions;
48
+ let idx = 0;
49
+ if (r.current) {
50
+ const found = baseOptions.indexOf(r.current);
51
+ if (found >= 0) {
52
+ idx = found;
53
+ } else {
54
+ opts = [...baseOptions, r.current];
55
+ idx = opts.length - 1;
56
+ }
57
+ }
58
+ return { ...r, opts, idx };
59
+ });
60
+
61
+ return await new Promise((resolve) => {
62
+ const wasRaw = process.stdin.isRaw;
63
+ let cursor = 0;
64
+ let renderedLines = 0;
65
+
66
+ const valueLabel = (row) => {
67
+ const value = row.opts[row.idx];
68
+ if (value === DEFAULT_SENTINEL) {
69
+ return c.dim(row.defaultLabel ? `default · ${row.defaultLabel}` : 'backend default');
70
+ }
71
+ const meta = byId.get(value);
72
+ const badge = meta ? creditBadge(meta) : '';
73
+ // Only flag uncurated picks when a curated catalog actually loaded —
74
+ // in fallback mode every option is a known backend model, not a stray.
75
+ const flag = meta || usingFallback ? '' : c.yellow(' (uncurated)');
76
+ return `${c.brand(value)}${badge ? ` ${c.dim(badge)}` : ''}${flag}`;
77
+ };
78
+
79
+ const render = () => {
80
+ const cols = Math.max(60, process.stderr.columns || 120);
81
+ const lines = [];
82
+ lines.push(` ${c.bold('Models')} ${c.dim('· session overrides · curated platform catalog')}`);
83
+ if (usingFallback) {
84
+ const why = unavailableNote ? ` — ${unavailableNote}` : '';
85
+ lines.push(` ${c.yellow('!')} ${c.dim(`catalog unavailable${why}; showing this session's backend models`)}`);
86
+ }
87
+ lines.push('');
88
+ rows.forEach((row, i) => {
89
+ const marker = i === cursor ? c.brand('▸') : ' ';
90
+ const rawLabel = String(row.label || row.role).padEnd(14, ' ');
91
+ const label = i === cursor ? c.brand(rawLabel) : rawLabel;
92
+ lines.push(fitAnsiLine(` ${marker} ${label} ${c.dim('‹')} ${valueLabel(row)} ${c.dim('›')}`, cols - 1));
93
+ });
94
+ lines.push('');
95
+ lines.push(fitAnsiLine(` ${c.dim('↑↓ role · ←→ model · Enter apply · c defaults · Esc cancel')}`, cols - 1));
96
+ writeOverlayFrame(renderedLines, lines);
97
+ renderedLines = lines.length;
98
+ };
99
+
100
+ const cleanup = (value) => {
101
+ process.stdin.removeListener('data', onData);
102
+ process.stdin.setRawMode(wasRaw || false);
103
+ eraseOverlayFrame(renderedLines);
104
+ if (rl) rl.resume();
105
+ resolve(value);
106
+ };
107
+
108
+ const onData = (data) => {
109
+ const key = data.toString('utf8');
110
+ if (key === '\x1b' || key === '\x03' || key === 'q') { cleanup(null); return; }
111
+ if (key === '\r' || key === '\n') {
112
+ const overrides = {};
113
+ for (const row of rows) {
114
+ const value = row.opts[row.idx];
115
+ if (value && value !== DEFAULT_SENTINEL) overrides[row.role] = value;
116
+ }
117
+ cleanup({ overrides });
118
+ return;
119
+ }
120
+ if (key === 'c' || key === 'C') { rows.forEach(r => { r.idx = 0; }); render(); return; }
121
+ if (key === '\x1b[A') { cursor = Math.max(0, cursor - 1); render(); return; }
122
+ if (key === '\x1b[B') { cursor = Math.min(rows.length - 1, cursor + 1); render(); return; }
123
+ if (key === '\x1b[D') { const r = rows[cursor]; r.idx = (r.idx - 1 + r.opts.length) % r.opts.length; render(); return; }
124
+ if (key === '\x1b[C') { const r = rows[cursor]; r.idx = (r.idx + 1) % r.opts.length; render(); return; }
125
+ };
126
+
127
+ process.stdin.setRawMode(true);
128
+ process.stdin.resume();
129
+ process.stdin.on('data', onData);
130
+ render();
131
+ });
132
+ }
@@ -27,6 +27,51 @@ import {
27
27
  isInputDockMounted,
28
28
  moveToContent,
29
29
  } from '../ui/input-dock.mjs';
30
+ import * as queue from '../ui/render-queue.mjs';
31
+
32
+ // Single seam for the transient spinner/status line. Rich mode (render
33
+ // queue active) → coalesced last-wins status that can never interleave
34
+ // with content. Legacy dock path and bare-TTY inPlace stay as fallbacks
35
+ // until their write-sites migrate onto the queue too.
36
+ // Max inner-tool lines shown under the spinner during a sub-agent run.
37
+ const SUB_AGENT_WINDOW_ROWS = 7;
38
+
39
+ function presentStatus(rendered) {
40
+ if (queue.isActive()) {
41
+ const win = runtime.subAgentWindow;
42
+ if (win?.active && win.lines.length) {
43
+ queue.statusBlock([
44
+ rendered,
45
+ ...win.lines.slice(-SUB_AGENT_WINDOW_ROWS).map(l => ` ${c.dim(l)}`),
46
+ ]);
47
+ return;
48
+ }
49
+ queue.status(rendered);
50
+ return;
51
+ }
52
+ if (isInputDockMounted()) { drawPinnedStatus(rendered); return; }
53
+ inPlace(rendered);
54
+ }
55
+
56
+ /** Push a line into the live sub-agent tool window (dedup consecutive). */
57
+ export function pushSubAgentWindowLine(line) {
58
+ const win = runtime.subAgentWindow;
59
+ if (!win?.active) return;
60
+ const text = String(line || '').trim();
61
+ if (!text || win.lines[win.lines.length - 1] === text) return;
62
+ win.lines.push(text);
63
+ if (win.lines.length > 24) win.lines.splice(0, win.lines.length - 24);
64
+ }
65
+
66
+ export function setSubAgentWindowActive(active) {
67
+ runtime.subAgentWindow = { active: Boolean(active), lines: [] };
68
+ }
69
+
70
+ function erasePresentedStatus() {
71
+ if (queue.isActive()) { queue.clearStatus(); return; }
72
+ if (isInputDockMounted()) { clearPinnedStatus(); moveToContent(); return; }
73
+ inPlace('');
74
+ }
30
75
  import {
31
76
  formatCardHead,
32
77
  formatCompactFileDiff,
@@ -157,11 +202,11 @@ export function renderExploreRun() {
157
202
  // picks up exploreSummary() text instead of any stale label.
158
203
  runtime.exploreRun.lineActive = true;
159
204
 
160
- if (isInputDockMounted()) {
161
- // Docked terminals reserve a bottom input area. Keeping explore progress
162
- // as an animated bottom overlay creates visible gaps between the sub-agent
163
- // transcript and the current tool. Emit bounded snapshots into the
164
- // transcript instead; every hidden call remains available through /expand.
205
+ if (!queue.isActive() && isInputDockMounted()) {
206
+ // Legacy docked path (queue not engaged): an animated bottom overlay
207
+ // created visible gaps, so emit bounded snapshots into the transcript
208
+ // instead. With the render queue active this branch is skipped — the
209
+ // coalesced status line can animate safely in dock mode.
165
210
  if (runtime.spinInterval) {
166
211
  clearInterval(runtime.spinInterval);
167
212
  runtime.spinInterval = null;
@@ -183,8 +228,7 @@ export function renderExploreRun() {
183
228
  const frame = SPIN_FRAMES[runtime.spinFrame % SPIN_FRAMES.length];
184
229
  runtime.spinFrame++;
185
230
  const rendered = ` ${c.brand(frame)} ${c.dim(label)}`;
186
- if (isInputDockMounted()) moveToContent();
187
- inPlace(rendered);
231
+ presentStatus(rendered);
188
232
  }, 80);
189
233
  }
190
234
  runtime.lastRenderedBlock = 'tool';
@@ -200,9 +244,10 @@ export function flushExploreRun() {
200
244
  if (wasActive) {
201
245
  if (runtime.spinInterval) { clearInterval(runtime.spinInterval); runtime.spinInterval = null; }
202
246
  runtime.spinText = '';
203
- if (!isInputDockMounted()) inPlace('');
247
+ if (queue.isActive()) queue.clearStatus();
248
+ else if (!isInputDockMounted()) inPlace('');
204
249
  }
205
- if (!isInputDockMounted() || summary !== runtime.exploreRun.lastPrintedSummary) {
250
+ if (queue.isActive() || !isInputDockMounted() || summary !== runtime.exploreRun.lastPrintedSummary) {
206
251
  writeExploreSnapshot(summary);
207
252
  }
208
253
  }
@@ -238,6 +283,17 @@ export function renderToolCall(data) {
238
283
  return;
239
284
  }
240
285
 
286
+ // Sub-agent live window (queue mode): inner tool calls stream into the
287
+ // fixed-height status block instead of appending transcript lines. The
288
+ // card is still recorded so /expand, /last, and `d` show full detail.
289
+ if (queue.isActive() && runtime.subAgentWindow?.active && inSubAgentBlock()) {
290
+ recordCard({ id: callId, tool, args, startedAt: Date.now() });
291
+ session.toolCounts[tool] = (session.toolCounts[tool] || 0) + 1;
292
+ const label = readToolLabel(tool, { args });
293
+ pushSubAgentWindowLine(label ? `→ ${tool} · ${label}` : `→ ${tool}`);
294
+ return; // the spinner tick paints the window block
295
+ }
296
+
241
297
  flushExploreRun();
242
298
  renderBlockBoundary('tool', { compactSame: tool !== 'shell' });
243
299
 
@@ -251,8 +307,12 @@ export function renderToolCall(data) {
251
307
  session.toolCounts[tool] = (session.toolCounts[tool] || 0) + 1;
252
308
  runtime.pendingHead = { callId, head, indent };
253
309
  runtime.lastRenderedBlock = 'tool';
254
- // Spinner shows what's running until the result arrives.
255
- startSpinner(`${tool}…`);
310
+ // Spinner shows what's running until the result arrives. Per-call phase
311
+ // gives each tool its own elapsed clock — long shell runs count up live.
312
+ const spinLabel = tool === 'shell' && args.command
313
+ ? `shell: ${String(args.command).split('\n')[0].slice(0, 48)}`
314
+ : `${tool}…`;
315
+ startSpinner(spinLabel, { phase: `tool:${callId}` });
256
316
  }
257
317
 
258
318
  /**
@@ -279,6 +339,7 @@ export function renderToolResult(data, eventType = 'tool_result') {
279
339
  const tool = data.tool || data._tool || '';
280
340
  const durationMs = data?.duration_ms ?? (data?.duration_s != null ? data.duration_s * 1000 : null);
281
341
  recordReadActivity(tool, data.args || {});
342
+ recordWriteActivity(tool, data.args || {}, data);
282
343
 
283
344
  // Update the card buffer so /last and `d` can find it.
284
345
  if (callId) recordCard({ id: callId, tool, args: data.args, result: data, durationMs });
@@ -315,6 +376,12 @@ export function renderToolResult(data, eventType = 'tool_result') {
315
376
  return;
316
377
  }
317
378
 
379
+ // Sub-agent live window: the call line is already streaming in the
380
+ // status block; the result stays card-only (close card summarizes).
381
+ if (queue.isActive() && runtime.subAgentWindow?.active && inSubAgentBlock()) {
382
+ return;
383
+ }
384
+
318
385
  // ── Single-line combined emit ──
319
386
  // If the head for this call is still buffered (no interleaving content
320
387
  // landed), and the combined line fits the terminal width, emit ONE line
@@ -324,7 +391,11 @@ export function renderToolResult(data, eventType = 'tool_result') {
324
391
  const combined = `${runtime.pendingHead.head} ${outcome}`;
325
392
  if (stripAnsi(combined).length <= cols) {
326
393
  process.stderr.write(`${combined}\n`);
327
- if (diffPreview) process.stderr.write(`${diffPreview}\n`);
394
+ if (diffPreview) {
395
+ process.stderr.write(`${diffPreview}\n`);
396
+ rememberFileDiffPreview(data);
397
+ }
398
+ renderPlanBody(tool, data);
328
399
  runtime.lastRenderedBlock = 'tool';
329
400
  runtime.pendingHead = null;
330
401
  return;
@@ -332,7 +403,11 @@ export function renderToolResult(data, eventType = 'tool_result') {
332
403
  if (isInlineOutcomeTool(tool)) {
333
404
  const compactHead = compactHeadForOutcome(runtime.pendingHead.head, outcome, cols);
334
405
  process.stderr.write(`${compactHead} ${outcome}\n`);
335
- if (diffPreview) process.stderr.write(`${diffPreview}\n`);
406
+ if (diffPreview) {
407
+ process.stderr.write(`${diffPreview}\n`);
408
+ rememberFileDiffPreview(data);
409
+ }
410
+ renderPlanBody(tool, data);
336
411
  runtime.lastRenderedBlock = 'tool';
337
412
  runtime.pendingHead = null;
338
413
  return;
@@ -347,7 +422,11 @@ export function renderToolResult(data, eventType = 'tool_result') {
347
422
 
348
423
  // Two-line shape: gutter under the (already-printed or just-flushed) head.
349
424
  process.stderr.write(`${gutter}${outcome}\n`);
350
- if (diffPreview) process.stderr.write(`${diffPreview}\n`);
425
+ if (diffPreview) {
426
+ process.stderr.write(`${diffPreview}\n`);
427
+ rememberFileDiffPreview(data);
428
+ }
429
+ renderPlanBody(tool, data);
351
430
  runtime.lastRenderedBlock = 'tool';
352
431
 
353
432
  // Lint warnings stay visible alongside writes.
@@ -356,6 +435,81 @@ export function renderToolResult(data, eventType = 'tool_result') {
356
435
  }
357
436
  }
358
437
 
438
+ // The plan sub-agent's output is the one peer result the USER needs to
439
+ // see, not just the model — it's the execution contract for the turn.
440
+ // Render the body as a bordered block (capped; full text stays on the
441
+ // card via /last). All other peer verbs keep the one-line summary.
442
+ const PLAN_BODY_MAX_LINES = 30;
443
+
444
+ function renderPlanBody(tool, data) {
445
+ if (String(tool || '').toLowerCase() !== 'plan') return;
446
+ const text = String(data?.output ?? data?.result ?? '').trim();
447
+ if (!text) return;
448
+ const indent = subAgentIndent();
449
+ const lines = transcriptRenderableLines(renderMarkdown(text));
450
+ if (!lines.length) return;
451
+ const shown = lines.slice(0, PLAN_BODY_MAX_LINES);
452
+ process.stderr.write(`${indent}${paint.text.dim('┌ plan')}\n`);
453
+ for (const line of shown) {
454
+ process.stderr.write(`${indent}${paint.text.dim('│')} ${line}\n`);
455
+ }
456
+ process.stderr.write(lines.length > shown.length
457
+ ? `${indent}${paint.text.dim(`└ … ${lines.length - shown.length} more lines · /last to expand`)}\n`
458
+ : `${indent}${paint.text.dim('└')}\n`);
459
+ }
460
+
461
+ function fileDiffKey(data = {}) {
462
+ return fileDiffKeys(data)[0] || '';
463
+ }
464
+
465
+ function fileDiffKeys(data = {}) {
466
+ const keys = [];
467
+ const callId = data.call_id || data._callId || data.request_id || data.id;
468
+ if (callId) keys.push(`call:${callId}`);
469
+ const diff = Array.isArray(data.file_diffs) ? data.file_diffs[0]
470
+ : data.file_diff ? data.file_diff
471
+ : data.type === 'file_diff' ? data
472
+ : null;
473
+ const file = diff?.relative_path || diff?.path || data.relative_path || data.path || '';
474
+ if (file) {
475
+ const added = diff?.lines_added ?? data.lines_added ?? '';
476
+ const removed = diff?.lines_removed ?? data.lines_removed ?? '';
477
+ keys.push(`file:${file}:${added}:${removed}`);
478
+ }
479
+ return keys;
480
+ }
481
+
482
+ function rememberFileDiffPreview(data = {}) {
483
+ for (const key of fileDiffKeys(data)) {
484
+ runtime.renderedFileDiffPreviews.add(key);
485
+ }
486
+ }
487
+
488
+ export function renderFileDiffEvent(data = {}) {
489
+ const keys = fileDiffKeys(data);
490
+ if (keys.some(key => runtime.renderedFileDiffPreviews.has(key))) return false;
491
+
492
+ const indent = subAgentIndent();
493
+ const gutter = `${indent}${paint.text.dim('⎿')} `;
494
+ const diffPreview = formatCompactFileDiff({
495
+ file_diff: data,
496
+ lines_added: data.lines_added,
497
+ lines_removed: data.lines_removed,
498
+ }, {
499
+ indent: gutter,
500
+ columns: process.stderr.columns || 120,
501
+ showFileHeader: true,
502
+ });
503
+ if (!diffPreview) return false;
504
+
505
+ renderBlockBoundary('tool', { compactSame: true });
506
+ process.stderr.write(`${diffPreview}\n`);
507
+ for (const key of keys) runtime.renderedFileDiffPreviews.add(key);
508
+ rememberChangedFile(data.relative_path || data.path);
509
+ runtime.lastRenderedBlock = 'tool';
510
+ return true;
511
+ }
512
+
359
513
  function shellResultTool(tool) {
360
514
  return [
361
515
  'shell', 'run_tests', 'validate_build', 'lint_check',
@@ -415,6 +569,11 @@ export function rememberReadFile(filePath) {
415
569
  if (file && !session.filesRead.includes(file)) session.filesRead.push(file);
416
570
  }
417
571
 
572
+ export function rememberChangedFile(filePath) {
573
+ const file = shortPath(String(filePath || '').trim());
574
+ if (file && !session.filesChanged.includes(file)) session.filesChanged.push(file);
575
+ }
576
+
418
577
  export function recordReadActivity(tool, args = {}) {
419
578
  const normalized = String(tool || '').toLowerCase();
420
579
  if (normalized === 'read_file' || normalized === 'read') {
@@ -429,6 +588,26 @@ export function recordReadActivity(tool, args = {}) {
429
588
  }
430
589
  }
431
590
 
591
+ export function recordWriteActivity(tool, args = {}, result = {}) {
592
+ const normalized = String(tool || '').toLowerCase();
593
+ if (!['write_file', 'edit_file', 'delete_file', 'write_project'].includes(normalized)) return;
594
+
595
+ if (normalized === 'write_project') {
596
+ const files = Array.isArray(args.files) ? args.files : [];
597
+ for (const file of files) {
598
+ rememberChangedFile(file?.file_path || file?.path);
599
+ }
600
+ }
601
+
602
+ rememberChangedFile(args.file_path || args.path || result.file_path || result.path);
603
+ const diffs = Array.isArray(result.file_diffs)
604
+ ? result.file_diffs
605
+ : result.file_diff ? [result.file_diff] : [];
606
+ for (const diff of diffs) {
607
+ rememberChangedFile(diff.relative_path || diff.path);
608
+ }
609
+ }
610
+
432
611
  export function thinkingKind(text) {
433
612
  return /\b(read|reading|inspect|scan|search|open|trace|look(?:ing)?\s+at)\b/i.test(text)
434
613
  ? 'Reading'
@@ -454,10 +633,37 @@ export const SPIN_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '
454
633
  // (declaration moved to repl-state.mjs runtime.*)
455
634
  // (declaration moved to repl-state.mjs runtime.*)
456
635
 
457
- export function startSpinner(text) {
636
+ // Compose the status label with live phase telemetry: elapsed seconds
637
+ // (shown once a phase runs ≥3s — quick tools stay clean) and the
638
+ // per-phase tool-call counter (sub-agent progress). The 80ms tick calls
639
+ // this every frame, so elapsed counts up without any extra timer.
640
+ function composeStatusLabel(label) {
641
+ const parts = [label];
642
+ if (runtime.spinStartedAt) {
643
+ const elapsedS = Math.floor((Date.now() - runtime.spinStartedAt) / 1000);
644
+ if (runtime.spinToolCalls > 0) {
645
+ parts.push(`${runtime.spinToolCalls} call${runtime.spinToolCalls === 1 ? '' : 's'}`);
646
+ }
647
+ if (elapsedS >= 3) parts.push(`${elapsedS}s`);
648
+ }
649
+ return parts.join(' · ');
650
+ }
651
+
652
+ /**
653
+ * Start (or re-label) the spinner. `phase` scopes the elapsed clock:
654
+ * a phase change resets it, same-phase updates keep it counting. Callers
655
+ * that don't pass a phase get a generic per-call reset (old behavior).
656
+ */
657
+ export function startSpinner(text, { phase = null } = {}) {
658
+ const nextPhase = phase || `generic:${text}`;
659
+ if (runtime.spinPhase !== nextPhase) {
660
+ runtime.spinPhase = nextPhase;
661
+ runtime.spinStartedAt = Date.now();
662
+ runtime.spinToolCalls = 0;
663
+ }
458
664
  runtime.spinText = text;
459
665
  runtime.spinFrame = 0;
460
- if (runtime.exploreRun && runtime.exploreRun.lineActive && isInputDockMounted()) return;
666
+ if (!queue.isActive() && runtime.exploreRun && runtime.exploreRun.lineActive && isInputDockMounted()) return;
461
667
  if (runtime.spinInterval) return; // already running
462
668
  runtime.spinInterval = setInterval(() => {
463
669
  // While an explore run is active, its live counts own the spinner
@@ -468,20 +674,29 @@ export function startSpinner(text) {
468
674
  if (!label) return;
469
675
  const frame = SPIN_FRAMES[runtime.spinFrame % SPIN_FRAMES.length];
470
676
  runtime.spinFrame++;
471
- const rendered = ` ${c.brand(frame)} ${c.dim(label)}`;
472
- if (isExploreActive && isInputDockMounted()) {
473
- return;
474
- }
475
- if (isInputDockMounted()) {
476
- drawPinnedStatus(rendered);
677
+ const rendered = ` ${c.brand(frame)} ${c.dim(composeStatusLabel(label))}`;
678
+ if (!queue.isActive() && isExploreActive && isInputDockMounted()) {
477
679
  return;
478
680
  }
479
- inPlace(rendered);
681
+ presentStatus(rendered);
480
682
  }, 80);
481
683
  }
482
684
 
483
685
  export function updateSpinner(text) {
484
686
  runtime.spinText = text;
687
+ // Self-healing: a content flush stops the spinner (clears the
688
+ // interval), but long-running work — sub-agent runs especially —
689
+ // keeps sending updates afterwards. Without reviving the interval
690
+ // those updates write into a dead timer and the user sees a frozen
691
+ // "▸ running" with no progress at all. Same phase → clock continues.
692
+ if (!runtime.spinInterval && text) {
693
+ startSpinner(text, { phase: runtime.spinPhase || undefined });
694
+ }
695
+ }
696
+
697
+ /** Bump the per-phase progress counter (sub-agent tool calls). */
698
+ export function bumpSpinnerProgress() {
699
+ runtime.spinToolCalls++;
485
700
  }
486
701
 
487
702
  export function stopSpinner() {
@@ -491,12 +706,10 @@ export function stopSpinner() {
491
706
  if (runtime.exploreRun && runtime.exploreRun.lineActive) return;
492
707
  if (runtime.spinInterval) { clearInterval(runtime.spinInterval); runtime.spinInterval = null; }
493
708
  runtime.spinText = '';
494
- if (isInputDockMounted()) {
495
- clearPinnedStatus();
496
- moveToContent();
497
- return;
498
- }
499
- inPlace('');
709
+ runtime.spinPhase = null;
710
+ runtime.spinStartedAt = 0;
711
+ runtime.spinToolCalls = 0;
712
+ erasePresentedStatus();
500
713
  }
501
714
 
502
715
  // ── Content Streaming Display ──
@@ -511,6 +724,7 @@ export function startContentStream() {
511
724
  runtime.streamBuffer = '';
512
725
  runtime.streamedPartialText = '';
513
726
  runtime.renderedToolResults.clear();
727
+ runtime.renderedFileDiffPreviews.clear();
514
728
  runtime.exploreRun = { counts: {}, recent: [], lineActive: false, lastPrintedSummary: '', lastPrintedTotal: 0, lastPrintedAt: 0 };
515
729
  runtime.renderedContentThisTurn = false;
516
730
  runtime.contentHeaderPrinted = false;
@@ -535,27 +749,36 @@ export function flushContent() {
535
749
  if (runtime.streamTimer) { clearTimeout(runtime.streamTimer); runtime.streamTimer = null; }
536
750
  if (!runtime.streamBuffer) return;
537
751
 
752
+ const rendered = renderMarkdown(runtime.streamBuffer);
753
+ const lines = transcriptRenderableLines(rendered);
754
+ runtime.streamBuffer = '';
755
+ if (!lines.length) return;
756
+
538
757
  if (isInputDockMounted()) moveToContent();
539
758
  stopSpinner();
540
759
  // Any buffered tool head needs to land BEFORE this content so the order
541
760
  // is preserved on screen.
542
761
  flushPendingHead();
543
762
  flushCompactReadRun();
544
- renderBlockBoundary('content');
763
+ renderBlockBoundary('content', { compactSame: true });
545
764
  if (!runtime.contentHeaderPrinted) {
546
765
  process.stdout.write(`${transcriptHeader('bahulam', { tone: 'assistant' })}\n`);
547
766
  runtime.contentHeaderPrinted = true;
548
767
  }
549
- const rendered = renderMarkdown(runtime.streamBuffer);
550
- for (const line of rendered.split('\n')) {
768
+ for (const line of lines) {
551
769
  process.stdout.write(`${transcriptLine(line, { tone: 'assistant' })}\n`);
552
770
  }
553
- runtime.streamBuffer = '';
554
771
  runtime.renderedContentThisTurn = true;
555
772
  runtime.lastRenderedBlock = 'content';
556
773
  if (typeof runtime.afterContentFlush === 'function') runtime.afterContentFlush();
557
774
  }
558
775
 
776
+ export function transcriptRenderableLines(rendered) {
777
+ const lines = String(rendered ?? '').replace(/\r\n?/g, '\n').split('\n');
778
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
779
+ return lines;
780
+ }
781
+
559
782
  export function renderStagnation(data = {}) {
560
783
  const rawMessage = data?.message || '';
561
784
  const reason = data?.reason || rawMessage.replace(/^Stagnation:\s*/i, '').trim();