@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.
- package/package.json +5 -5
- package/src/auth/tarang-auth.mjs +6 -0
- package/src/config/cli-args.mjs +7 -1
- package/src/config/model-catalog.mjs +57 -0
- package/src/core/bundled-runtime.mjs +12 -0
- package/src/core/error-guidance.mjs +8 -4
- package/src/core/local-agent.mjs +3 -3
- package/src/index.mjs +7 -3
- package/src/permissions/command-classifier.mjs +50 -2
- package/src/telemetry/index.mjs +97 -71
- package/src/terminal/main.mjs +40 -0
- package/src/terminal/repl-format.mjs +64 -4
- package/src/terminal/repl-model-form.mjs +132 -0
- package/src/terminal/repl-render.mjs +152 -26
- package/src/terminal/repl-resume.mjs +27 -12
- package/src/terminal/repl-state.mjs +15 -0
- package/src/terminal/repl.mjs +489 -21
- package/src/ui/approval.mjs +22 -5
- package/src/ui/input-dock.mjs +67 -5
- package/src/ui/render-queue.mjs +500 -0
- package/src/ui/slash-commands.mjs +2 -0
- package/src/ui/sub-agent.mjs +17 -2
|
@@ -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
|
-
|
|
237
|
+
transientLine(` ${c.brand(glyph)} ${c.dim(label)} ${resumeProgressBar(percent)} ${c.dim(formatElapsed(started))}`);
|
|
186
238
|
};
|
|
187
239
|
|
|
188
240
|
render();
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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) ──
|