@bahulam/code 2.6.15 → 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.
@@ -9,6 +9,58 @@
9
9
 
10
10
  import * as path from 'node:path';
11
11
  import { c, stripAnsi, formatElapsed, inPlace } from './ansi.mjs';
12
+ import * as rqueue from '../ui/render-queue.mjs';
13
+
14
+ // Transient one-line status writer that is safe under the render queue.
15
+ // Raw inPlace() writes get REDIRECTED into transcript content when the
16
+ // queue is active (cursor codes stripped) — that leaked one line per
17
+ // spinner frame during resume summarization. queue.status() coalesces
18
+ // and overwrites in place; inPlace stays as the no-queue fallback.
19
+ function transientLine(text) {
20
+ if (rqueue.isActive()) {
21
+ if (text) rqueue.status(text);
22
+ else rqueue.clearStatus();
23
+ return;
24
+ }
25
+ inPlace(text);
26
+ }
27
+
28
+ /**
29
+ * Atomic repaint writer for raw-stdin overlays (resume picker, /model form).
30
+ *
31
+ * While the render queue is active, plain process.stderr.write is REDIRECTED
32
+ * into transcript content with cursor codes stripped — an overlay's
33
+ * "cursor-up N + erase" repaint becomes "append another copy" (the form-
34
+ * replication bug). Each repaint therefore goes through rqueue.raw() as one
35
+ * frame. On the first paint the frame's rows are reserved with newlines so
36
+ * painting near the bottom (input dock) scrolls once up-front and the
37
+ * cursor-relative repaint math stays stable afterwards.
38
+ */
39
+ export function writeOverlayFrame(erasePrev, lines) {
40
+ const body = lines.join('\n') + '\n';
41
+ if (erasePrev > 0) {
42
+ rqueue.raw(`\x1b[${erasePrev}F\r\x1b[J` + body);
43
+ } else {
44
+ rqueue.raw('\n'.repeat(lines.length) + `\x1b[${lines.length}A` + body);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Erase the previously drawn overlay frame (rows lines tall) so the next
50
+ * prompt / transcript output starts on a clean line. Call from every
51
+ * overlay's cleanup path — without this, picker frames stack on screen
52
+ * when a follow-up prompt (cwd confirm, next overlay) writes below them.
53
+ */
54
+ export function eraseOverlayFrame(rows, summaryLine = '') {
55
+ if (!rows || rows <= 0) {
56
+ if (summaryLine) rqueue.raw(summaryLine + '\n');
57
+ return;
58
+ }
59
+ // Collapse the frame to a compact one-line summary (or nothing). A pure
60
+ // erase leaves a blank void mid-screen because the input dock parks the
61
+ // cursor at the bottom before the next plain write lands.
62
+ rqueue.raw(`\x1b[${rows}F\r\x1b[J` + (summaryLine ? summaryLine + '\n' : ''));
63
+ }
12
64
 
13
65
  // ── One-liners ───────────────────────────────────────────────────────
14
66
 
@@ -182,23 +234,31 @@ export function startResumeProgress(mode = 'full') {
182
234
  if (!active) return;
183
235
  const glyph = frames[frame % frames.length];
184
236
  frame++;
185
- inPlace(` ${c.brand(glyph)} ${c.dim(label)} ${resumeProgressBar(percent)} ${c.dim(formatElapsed(started))}`);
237
+ transientLine(` ${c.brand(glyph)} ${c.dim(label)} ${resumeProgressBar(percent)} ${c.dim(formatElapsed(started))}`);
186
238
  };
187
239
 
188
240
  render();
189
- const timer = setInterval(render, 100);
241
+ let timer = setInterval(render, 100);
190
242
  return {
243
+ // Self-healing: the resume flow stops the progress line to show the
244
+ // cwd-confirm overlay, then keeps reporting phases ('rebuilding local
245
+ // session state', …). A dead update() silently dropped every cue after
246
+ // that prompt — revive the ticker instead (same pattern as
247
+ // updateSpinner in repl-render.mjs).
191
248
  update(nextLabel, nextPercent) {
192
- if (!active) return;
193
249
  if (nextLabel) label = nextLabel;
194
250
  if (Number.isFinite(nextPercent)) percent = Math.max(percent, Math.min(98, nextPercent));
251
+ if (!active) {
252
+ active = true;
253
+ timer = setInterval(render, 100);
254
+ }
195
255
  render();
196
256
  },
197
257
  stop() {
198
258
  if (!active) return;
199
259
  active = false;
200
260
  clearInterval(timer);
201
- inPlace('');
261
+ transientLine('');
202
262
  },
203
263
  };
204
264
  }
@@ -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
  /**
@@ -316,6 +376,12 @@ export function renderToolResult(data, eventType = 'tool_result') {
316
376
  return;
317
377
  }
318
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
+
319
385
  // ── Single-line combined emit ──
320
386
  // If the head for this call is still buffered (no interleaving content
321
387
  // landed), and the combined line fits the terminal width, emit ONE line
@@ -329,6 +395,7 @@ export function renderToolResult(data, eventType = 'tool_result') {
329
395
  process.stderr.write(`${diffPreview}\n`);
330
396
  rememberFileDiffPreview(data);
331
397
  }
398
+ renderPlanBody(tool, data);
332
399
  runtime.lastRenderedBlock = 'tool';
333
400
  runtime.pendingHead = null;
334
401
  return;
@@ -340,6 +407,7 @@ export function renderToolResult(data, eventType = 'tool_result') {
340
407
  process.stderr.write(`${diffPreview}\n`);
341
408
  rememberFileDiffPreview(data);
342
409
  }
410
+ renderPlanBody(tool, data);
343
411
  runtime.lastRenderedBlock = 'tool';
344
412
  runtime.pendingHead = null;
345
413
  return;
@@ -358,6 +426,7 @@ export function renderToolResult(data, eventType = 'tool_result') {
358
426
  process.stderr.write(`${diffPreview}\n`);
359
427
  rememberFileDiffPreview(data);
360
428
  }
429
+ renderPlanBody(tool, data);
361
430
  runtime.lastRenderedBlock = 'tool';
362
431
 
363
432
  // Lint warnings stay visible alongside writes.
@@ -366,6 +435,29 @@ export function renderToolResult(data, eventType = 'tool_result') {
366
435
  }
367
436
  }
368
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
+
369
461
  function fileDiffKey(data = {}) {
370
462
  return fileDiffKeys(data)[0] || '';
371
463
  }
@@ -541,10 +633,37 @@ export const SPIN_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '
541
633
  // (declaration moved to repl-state.mjs runtime.*)
542
634
  // (declaration moved to repl-state.mjs runtime.*)
543
635
 
544
- 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
+ }
545
664
  runtime.spinText = text;
546
665
  runtime.spinFrame = 0;
547
- if (runtime.exploreRun && runtime.exploreRun.lineActive && isInputDockMounted()) return;
666
+ if (!queue.isActive() && runtime.exploreRun && runtime.exploreRun.lineActive && isInputDockMounted()) return;
548
667
  if (runtime.spinInterval) return; // already running
549
668
  runtime.spinInterval = setInterval(() => {
550
669
  // While an explore run is active, its live counts own the spinner
@@ -555,20 +674,29 @@ export function startSpinner(text) {
555
674
  if (!label) return;
556
675
  const frame = SPIN_FRAMES[runtime.spinFrame % SPIN_FRAMES.length];
557
676
  runtime.spinFrame++;
558
- const rendered = ` ${c.brand(frame)} ${c.dim(label)}`;
559
- if (isExploreActive && isInputDockMounted()) {
677
+ const rendered = ` ${c.brand(frame)} ${c.dim(composeStatusLabel(label))}`;
678
+ if (!queue.isActive() && isExploreActive && isInputDockMounted()) {
560
679
  return;
561
680
  }
562
- if (isInputDockMounted()) {
563
- drawPinnedStatus(rendered);
564
- return;
565
- }
566
- inPlace(rendered);
681
+ presentStatus(rendered);
567
682
  }, 80);
568
683
  }
569
684
 
570
685
  export function updateSpinner(text) {
571
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++;
572
700
  }
573
701
 
574
702
  export function stopSpinner() {
@@ -578,12 +706,10 @@ export function stopSpinner() {
578
706
  if (runtime.exploreRun && runtime.exploreRun.lineActive) return;
579
707
  if (runtime.spinInterval) { clearInterval(runtime.spinInterval); runtime.spinInterval = null; }
580
708
  runtime.spinText = '';
581
- if (isInputDockMounted()) {
582
- clearPinnedStatus();
583
- moveToContent();
584
- return;
585
- }
586
- inPlace('');
709
+ runtime.spinPhase = null;
710
+ runtime.spinStartedAt = 0;
711
+ runtime.spinToolCalls = 0;
712
+ erasePresentedStatus();
587
713
  }
588
714
 
589
715
  // ── Content Streaming Display ──
@@ -30,6 +30,8 @@ import {
30
30
  resumeModeLabel,
31
31
  resumeTailTurnCount,
32
32
  startResumeProgress,
33
+ writeOverlayFrame,
34
+ eraseOverlayFrame,
33
35
  } from './repl-format.mjs';
34
36
  import { TarangStreamClient } from '../core/stream-client.mjs';
35
37
  import { getRecentSessions, getSessionDetail, buildResumeHistory, combineResumeSummaries } from '../core/local-store.mjs';
@@ -86,9 +88,6 @@ export async function pickResumableSession(resumable, ctx) {
86
88
  let renderedLines = 0;
87
89
 
88
90
  const renderMenu = () => {
89
- if (renderedLines > 0) {
90
- process.stderr.write(`\x1b[${renderedLines}F\r\x1b[J`);
91
- }
92
91
  if (selected < offset) offset = selected;
93
92
  if (selected >= offset + pageSize) offset = selected - pageSize + 1;
94
93
 
@@ -119,13 +118,19 @@ export async function pickResumableSession(resumable, ctx) {
119
118
  ` ${c.dim(`↑↓ move · Enter resume · P preview · Esc cancel · ${selected + 1}/${resumable.length}`)}`,
120
119
  cols - 1
121
120
  ));
122
- process.stderr.write(lines.join('\n') + '\n');
121
+ writeOverlayFrame(renderedLines, lines);
123
122
  renderedLines = lines.length;
124
123
  };
125
124
 
126
125
  const cleanup = (value) => {
127
126
  process.stdin.removeListener('data', onData);
128
127
  process.stdin.setRawMode(wasRaw || false);
128
+ // Collapse the list to one line: picked → which session; preview →
129
+ // erase only (preview overlay paints next); cancel → caller reports.
130
+ const picked = value?.action === 'resume' ? value.session : null;
131
+ eraseOverlayFrame(renderedLines, picked
132
+ ? ` ${c.green('↺')} ${c.brand(picked.project || '(unknown)')} ${c.dim(`· ${picked.messageCount} msgs · resuming…`)}`
133
+ : '');
129
134
  if (rl) rl.resume();
130
135
  resolve(value);
131
136
  };
@@ -183,7 +188,6 @@ export async function chooseThresholdMode(ctx, decision) {
183
188
  let renderedLines = 0;
184
189
 
185
190
  const render = () => {
186
- if (renderedLines > 0) process.stderr.write(`\x1b[${renderedLines}F\r\x1b[J`);
187
191
  const cols = Math.max(60, process.stderr.columns || 120);
188
192
  const pct = Math.round(decision.usageRatio * 100);
189
193
  const projected = formatCtxTokens(decision.projected);
@@ -221,13 +225,16 @@ export async function chooseThresholdMode(ctx, decision) {
221
225
  }
222
226
  lines.push('');
223
227
  lines.push(fitAnsiLine(` ${c.dim('↑↓ move · Enter pick · f/s/1/2 shortcut · Esc cancel')}`, cols - 1));
224
- process.stderr.write(lines.join('\n') + '\n');
228
+ writeOverlayFrame(renderedLines, lines);
225
229
  renderedLines = lines.length;
226
230
  };
227
231
 
228
232
  const cleanup = (value) => {
229
233
  process.stdin.removeListener('data', onData);
230
234
  process.stdin.setRawMode(wasRaw || false);
235
+ eraseOverlayFrame(renderedLines, value
236
+ ? ` ${c.dim('mode:')} ${c.brand(resumeModeLabel(value))}`
237
+ : '');
231
238
  if (rl) rl.resume();
232
239
  resolve(value);
233
240
  };
@@ -281,7 +288,6 @@ export async function previewResumeSession(session, ctx) {
281
288
  let scrollOffset = 0;
282
289
 
283
290
  const render = () => {
284
- if (renderedLines > 0) process.stderr.write(`\x1b[${renderedLines}F\r\x1b[J`);
285
291
  const cols = Math.max(60, process.stderr.columns || 120);
286
292
  const rows = Math.max(10, Math.min((process.stderr.rows || 30) - 6, 20));
287
293
  const contentLines = (history.summary || '').split('\n');
@@ -305,13 +311,14 @@ export async function previewResumeSession(session, ctx) {
305
311
  }
306
312
  lines.push('');
307
313
  lines.push(fitAnsiLine(` ${c.dim(`↑↓/PgUp/PgDn scroll · f/s/1/2 switch mode · Enter resume this · q back · ${scrollOffset + 1}-${Math.min(scrollOffset + rows, totalLines)}/${totalLines}`)}`, cols - 1));
308
- process.stderr.write(lines.join('\n') + '\n');
314
+ writeOverlayFrame(renderedLines, lines);
309
315
  renderedLines = lines.length;
310
316
  };
311
317
 
312
318
  const cleanup = (value) => {
313
319
  process.stdin.removeListener('data', onData);
314
320
  process.stdin.setRawMode(wasRaw || false);
321
+ eraseOverlayFrame(renderedLines);
315
322
  if (rl) rl.resume();
316
323
  resolve(value);
317
324
  };
@@ -343,9 +350,12 @@ export async function previewResumeSession(session, ctx) {
343
350
  */
344
351
  export async function confirmCwdSwitch(ctx, savedPath, currentPath) {
345
352
  if (!process.stdin.isTTY) return 'switch';
346
- process.stderr.write(`\n ${c.dim('This session lives in another repo:')}\n`);
347
- process.stderr.write(` ${c.dim('→')} ${c.brand(savedPath)} ${c.dim(`(current cwd: ${currentPath})`)}\n`);
348
- process.stderr.write(` ${c.dim('[Enter]')} switch cwd and resume · ${c.dim('[s]')} stay here and resume anyway · ${c.dim('[n]')} cancel `);
353
+ const frame = [
354
+ ` ${c.dim('This session lives in another repo:')}`,
355
+ ` ${c.dim('')} ${c.brand(savedPath)} ${c.dim(`(current cwd: ${currentPath})`)}`,
356
+ ` ${c.dim('[Enter]')} switch cwd and resume · ${c.dim('[s]')} stay here and resume anyway · ${c.dim('[n]')} cancel`,
357
+ ];
358
+ writeOverlayFrame(0, frame);
349
359
  const rl = ctx._rl || null;
350
360
  if (rl) rl.pause();
351
361
  return await new Promise((resolve) => {
@@ -353,8 +363,13 @@ export async function confirmCwdSwitch(ctx, savedPath, currentPath) {
353
363
  const cleanup = (value) => {
354
364
  process.stdin.removeListener('data', onData);
355
365
  process.stdin.setRawMode(wasRaw || false);
366
+ const summary = value === 'switch'
367
+ ? ` ${c.dim('cwd →')} ${c.brand(savedPath)}`
368
+ : value === 'stay'
369
+ ? ` ${c.dim(`staying in ${currentPath}`)}`
370
+ : '';
371
+ eraseOverlayFrame(frame.length, summary);
356
372
  if (rl) rl.resume();
357
- process.stderr.write('\n');
358
373
  resolve(value);
359
374
  };
360
375
  const onData = (data) => {
@@ -49,6 +49,19 @@ export const runtime = {
49
49
  spinInterval: null,
50
50
  spinText: '',
51
51
  spinFrame: 0,
52
+ // Activity phase tracking — elapsed time + progress counters on the
53
+ // status line. spinPhase changes reset spinStartedAt; text updates
54
+ // within the same phase keep the clock running so "thinking · 14s"
55
+ // and "explore agent · 32s · 12 calls" count up live.
56
+ spinPhase: null, // 'thinking' | 'status' | `tool:<id>` | `sub:<type>` | null
57
+ spinStartedAt: 0, // Date.now() when the current phase began
58
+ spinToolCalls: 0, // per-phase tool-call counter (sub-agents)
59
+
60
+ // Sub-agent live tool window: while a sub-agent runs (queue active),
61
+ // its inner tool calls stream into a fixed-height status block (last
62
+ // N lines under the spinner) instead of appending to the transcript.
63
+ // Full detail stays on the recorded cards (/expand, /last, `d`).
64
+ subAgentWindow: { active: false, lines: [] },
52
65
  };
53
66
 
54
67
  // Full session state for the current CLI process. Set by the REPL loop
@@ -90,6 +103,8 @@ export const session = {
90
103
  totalCost: 0, // accumulated session cost (USD)
91
104
  costAccurate: false, // true if backend provides per-model breakdown
92
105
  modelOverrides: {}, // session-local role -> model overrides sent to backend
106
+ modelMode: null, // named mode (fast|thinking|extra|max) sent as model_mode
107
+ routePreference: null, // --route platform|byok; controls catalog validation only
93
108
  isByok: false, // set from session_info; hides cost + credits when true
94
109
  // ── Subscription / credit state (server-authoritative; set from
95
110
  // session_info + complete events) ──