@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.
- 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/approval-log.mjs +23 -0
- package/src/core/approval.mjs +93 -17
- package/src/core/bundled-runtime.mjs +12 -0
- package/src/core/error-guidance.mjs +8 -4
- package/src/core/file-diff.mjs +1 -1
- package/src/core/local-agent.mjs +3 -3
- package/src/core/risk-tier.mjs +53 -2
- package/src/core/safety.mjs +61 -4
- package/src/core/tool-executor.mjs +25 -13
- package/src/core/trust.mjs +5 -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 +256 -33
- package/src/terminal/repl-resume.mjs +27 -12
- package/src/terminal/repl-state.mjs +16 -0
- package/src/terminal/repl.mjs +644 -85
- package/src/terminal/tool-display.mjs +20 -1
- package/src/ui/approval.mjs +33 -5
- package/src/ui/input-dock.mjs +192 -16
- package/src/ui/render-queue.mjs +500 -0
- package/src/ui/slash-commands.mjs +2 -0
- package/src/ui/sub-agent.mjs +17 -2
- package/src/ui/tool-card.mjs +156 -13
- package/src/ui/tool-details.mjs +96 -3
|
@@ -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
|
/**
|
|
@@ -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)
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
|
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();
|