@axplusb/kepler 1.0.9 → 2.0.0
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 -2
- package/src/context/retriever.mjs +42 -4
- package/src/context/symbol-indexer.mjs +375 -0
- package/src/core/approval.mjs +154 -95
- package/src/core/backend-url.mjs +2 -2
- package/src/core/headless.mjs +5 -0
- package/src/core/pricing.mjs +23 -1
- package/src/core/risk-tier.mjs +239 -0
- package/src/core/tool-executor.mjs +78 -5
- package/src/onboarding/preflight.mjs +274 -0
- package/src/state/orbit.mjs +263 -0
- package/src/state/verbosity.mjs +99 -0
- package/src/terminal/ansi.mjs +47 -27
- package/src/terminal/repl.mjs +407 -121
- package/src/ui/approval.mjs +167 -0
- package/src/ui/banner.mjs +133 -122
- package/src/ui/dock.mjs +88 -0
- package/src/ui/icons.mjs +164 -0
- package/src/ui/mission-report.mjs +264 -0
- package/src/ui/palette.mjs +189 -0
- package/src/ui/spinner.mjs +116 -0
- package/src/ui/status-bar.mjs +275 -0
- package/src/ui/sub-agent.mjs +152 -0
- package/src/ui/term.mjs +159 -0
- package/src/ui/tool-card.mjs +314 -0
- package/src/ui/tool-details.mjs +277 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single shared spinner — Mission Control (PRD-055 §4.4).
|
|
3
|
+
*
|
|
4
|
+
* One spinner instance per process. The repl and status bar consume frames
|
|
5
|
+
* from the same source so the eye never sees two animations out of phase.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
*
|
|
9
|
+
* const stop = startSpinner('Reading auth.py');
|
|
10
|
+
* await doWork();
|
|
11
|
+
* stop(); // clears the line and stops the timer
|
|
12
|
+
*
|
|
13
|
+
* Or, for a managed line that already exists (e.g. the status bar):
|
|
14
|
+
*
|
|
15
|
+
* const tick = spinnerFrame(); // current frame, advances on next call
|
|
16
|
+
*
|
|
17
|
+
* Behavior:
|
|
18
|
+
* - 120ms per frame.
|
|
19
|
+
* - Suppressed entirely when stdout is not a TTY or when KEPLER_PLAIN=1.
|
|
20
|
+
* The `start`/`stop` API is still safe to call (no-op).
|
|
21
|
+
* - Color follows the current orbit (orchestrated by the caller via the
|
|
22
|
+
* palette token). Default: brand.primary.
|
|
23
|
+
* - ASCII fallback (when no Unicode): 'plain dot rotation'.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { paint } from './palette.mjs';
|
|
27
|
+
import { term } from './term.mjs';
|
|
28
|
+
|
|
29
|
+
// 8-step rotation. The PRD spec calls for ◯ → ◔ → ◑ → ◕ → ● → ◕ → ◑ → ◔
|
|
30
|
+
// which yields a perceptual "breathing" cycle rather than a left-right spin.
|
|
31
|
+
const FRAMES_UTF = ['◯', '◔', '◑', '◕', '●', '◕', '◑', '◔'];
|
|
32
|
+
const FRAMES_ASCII = ['.', 'o', 'O', '@', 'O', 'o', '.', ' '];
|
|
33
|
+
|
|
34
|
+
const INTERVAL_MS = 120;
|
|
35
|
+
|
|
36
|
+
let _frame = 0;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Current spinner glyph. Advances the cursor each call.
|
|
40
|
+
* Honors capability detection automatically.
|
|
41
|
+
*/
|
|
42
|
+
export function spinnerFrame(painter = paint.brand.primary) {
|
|
43
|
+
const frames = term().unicode ? FRAMES_UTF : FRAMES_ASCII;
|
|
44
|
+
const ch = frames[_frame % frames.length];
|
|
45
|
+
_frame = (_frame + 1) % frames.length;
|
|
46
|
+
return painter ? painter(ch) : ch;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reset to frame 0 — useful at the start of a new turn so consecutive
|
|
51
|
+
* tool calls do not inherit each other's phase.
|
|
52
|
+
*/
|
|
53
|
+
export function resetSpinner() {
|
|
54
|
+
_frame = 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Start an inline spinner attached to `text`. Returns a stop function.
|
|
59
|
+
*
|
|
60
|
+
* The line is re-rendered in place using carriage return + erase, so the
|
|
61
|
+
* caller does not need to manage cursor state. If the terminal cannot
|
|
62
|
+
* render in place (non-TTY, dumb terminal, plain mode), the spinner becomes
|
|
63
|
+
* a single static line `"… text"` written once.
|
|
64
|
+
*/
|
|
65
|
+
export function startSpinner(text, { stream = process.stderr, painter, color = 'brand.primary' } = {}) {
|
|
66
|
+
const t = term();
|
|
67
|
+
if (!t.isTTY || t.plain || !t.color) {
|
|
68
|
+
try { stream.write(`… ${text}\n`); } catch {}
|
|
69
|
+
return () => {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const paintFn = painter || tokenPainter(color);
|
|
73
|
+
let stopped = false;
|
|
74
|
+
|
|
75
|
+
const render = () => {
|
|
76
|
+
if (stopped) return;
|
|
77
|
+
const glyph = spinnerFrame(paintFn);
|
|
78
|
+
try {
|
|
79
|
+
stream.write(`\r\x1b[2K${glyph} ${paint.text.dim(text)}`);
|
|
80
|
+
} catch {
|
|
81
|
+
// Stream closed mid-spin — stop quietly.
|
|
82
|
+
stop();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
render();
|
|
87
|
+
const handle = setInterval(render, INTERVAL_MS);
|
|
88
|
+
|
|
89
|
+
function stop() {
|
|
90
|
+
if (stopped) return;
|
|
91
|
+
stopped = true;
|
|
92
|
+
clearInterval(handle);
|
|
93
|
+
try {
|
|
94
|
+
stream.write('\r\x1b[2K');
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return stop;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Look up a painter function from a dotted token name (e.g. 'brand.accent').
|
|
103
|
+
* Falls back to the identity painter when the token does not exist.
|
|
104
|
+
*/
|
|
105
|
+
function tokenPainter(tokenPath) {
|
|
106
|
+
const [ns, name] = String(tokenPath || '').split('.');
|
|
107
|
+
const group = paint[ns];
|
|
108
|
+
if (group && typeof group[name] === 'function') return group[name];
|
|
109
|
+
return (s) => String(s ?? '');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Interval used by the shared spinner. Exposed for the status bar to
|
|
114
|
+
* synchronize its own re-paints with the spinner phase.
|
|
115
|
+
*/
|
|
116
|
+
export const SPINNER_INTERVAL_MS = INTERVAL_MS;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent two-line status bar — Mission Control (PRD-055 §5).
|
|
3
|
+
*
|
|
4
|
+
* Anchors itself to the bottom two rows of the terminal using a DECSTBM
|
|
5
|
+
* scroll region:
|
|
6
|
+
*
|
|
7
|
+
* ┌────────────────────────────────────────────────┐
|
|
8
|
+
* │ scroll region (rows 1..rows-2) │
|
|
9
|
+
* │ scroll region (rows 1..rows-2) │
|
|
10
|
+
* │ scroll region (rows 1..rows-2) │
|
|
11
|
+
* ├────────────────────────────────────────────────┤
|
|
12
|
+
* │ status line 1 — ORBIT | TASK | TURN | COST │
|
|
13
|
+
* │ status line 2 — keyboard dock │
|
|
14
|
+
* └────────────────────────────────────────────────┘
|
|
15
|
+
*
|
|
16
|
+
* The scroll region is set once at mount; afterwards normal stdout/stderr
|
|
17
|
+
* writes scroll within the upper region without ever touching the bar.
|
|
18
|
+
*
|
|
19
|
+
* Behavior:
|
|
20
|
+
* - No-op when stdout is not a TTY, or when KEPLER_PLAIN=1.
|
|
21
|
+
* The `mount/unmount/setOrbit` API is still safe to call.
|
|
22
|
+
* - Re-renders on state change (event-driven), not on a timer (except
|
|
23
|
+
* while in a state with a live spinner — see `_tickIfNeeded`).
|
|
24
|
+
* - SIGWINCH handler re-pads and re-sets the scroll region.
|
|
25
|
+
* - `unmount()` MUST be called before exit so the scroll region is
|
|
26
|
+
* restored and the cursor is shown.
|
|
27
|
+
*
|
|
28
|
+
* Implementation notes:
|
|
29
|
+
* - All output goes to stderr to keep stdout pipe-clean for `--print`/
|
|
30
|
+
* headless modes; if stdout is the only thing piped, the headless
|
|
31
|
+
* branch already short-circuits this module before any escape codes
|
|
32
|
+
* are emitted.
|
|
33
|
+
* - The PRD's "tput sc/rc" hint is shorthand. We use the actual VT100
|
|
34
|
+
* scroll region (`CSI top;bot r`) plus save/restore cursor.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { paint, width as visibleWidth } from './palette.mjs';
|
|
38
|
+
import { icons } from './icons.mjs';
|
|
39
|
+
import { spinnerFrame, SPINNER_INTERVAL_MS } from './spinner.mjs';
|
|
40
|
+
import { term, onResize } from './term.mjs';
|
|
41
|
+
import { ORBITS } from '../state/orbit.mjs';
|
|
42
|
+
import { dockForOrbit, renderDock } from './dock.mjs';
|
|
43
|
+
|
|
44
|
+
const ESC = '\x1b[';
|
|
45
|
+
const OUT = process.stderr;
|
|
46
|
+
|
|
47
|
+
// ── Orbit metadata: visual style + label ─────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const ORBIT_META = {
|
|
50
|
+
[ORBITS.IDLE]: { label: 'IDLE', paint: (s) => paint.text.dim(s), spinning: false },
|
|
51
|
+
[ORBITS.DISCOVERY]: { label: 'DISCOVERY', paint: (s) => paint.text.dim(s), spinning: true },
|
|
52
|
+
[ORBITS.PLANNING]: { label: 'PLANNING', paint: (s) => paint.state.warn(s), spinning: true },
|
|
53
|
+
[ORBITS.EXECUTION]: { label: 'EXECUTION', paint: (s) => paint.brand.primary(s), spinning: true },
|
|
54
|
+
[ORBITS.ALIGNMENT]: { label: 'ALIGNMENT', paint: (s) => paint.brand.data(s), spinning: true },
|
|
55
|
+
[ORBITS.AWAITING]: { label: 'AWAITING', paint: (s) => paint.brand.accent(s), spinning: false, border: true },
|
|
56
|
+
[ORBITS.PAUSED]: { label: 'PAUSED', paint: (s) => paint.state.warn(s), spinning: false },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ── State held by the singleton ──────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
let mounted = false;
|
|
62
|
+
let unsubResize = null;
|
|
63
|
+
let tickInterval = null;
|
|
64
|
+
let lastSnapshot = null;
|
|
65
|
+
let resetting = false;
|
|
66
|
+
|
|
67
|
+
// ── Low-level cursor / region helpers ────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function write(s) { try { OUT.write(s); } catch {} }
|
|
70
|
+
|
|
71
|
+
function setScrollRegion(top, bottom) { write(`${ESC}${top};${bottom}r`); }
|
|
72
|
+
function clearScrollRegion() { write(`${ESC}r`); }
|
|
73
|
+
function saveCursor() { write(`${ESC}s`); }
|
|
74
|
+
function restoreCursor() { write(`${ESC}u`); }
|
|
75
|
+
function moveTo(row, col) { write(`${ESC}${row};${col}H`); }
|
|
76
|
+
function clearLine() { write(`${ESC}2K`); }
|
|
77
|
+
function hideCursor() { write(`${ESC}?25l`); }
|
|
78
|
+
function showCursor() { write(`${ESC}?25h`); }
|
|
79
|
+
|
|
80
|
+
// ── Layout: assemble the two lines ───────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function formatCost(usd) {
|
|
83
|
+
if (typeof usd !== 'number' || !Number.isFinite(usd)) return '$0.00';
|
|
84
|
+
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
|
85
|
+
return `$${usd.toFixed(2)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildLineOne(snap, cols) {
|
|
89
|
+
const meta = ORBIT_META[snap.orbit] || ORBIT_META[ORBITS.IDLE];
|
|
90
|
+
const sep = paint.text.dim(' │ ');
|
|
91
|
+
|
|
92
|
+
const glyph = meta.spinning ? spinnerFrame(meta.paint) : meta.paint(icons.orbit);
|
|
93
|
+
const orbitLabel = `${glyph} ${meta.paint('ORBIT: ' + meta.label)}`;
|
|
94
|
+
|
|
95
|
+
const segments = [orbitLabel];
|
|
96
|
+
|
|
97
|
+
if (snap.task) {
|
|
98
|
+
segments.push(paint.text.dim('TASK: ') + paint.text.primary(snap.task));
|
|
99
|
+
}
|
|
100
|
+
if (snap.subAgents > 0) {
|
|
101
|
+
const noun = snap.subAgents === 1 ? 'sub-agent' : 'sub-agents';
|
|
102
|
+
segments.push(paint.brand.data(`${icons.subAgent} ${snap.subAgents} ${noun}`) + ' ' + paint.text.dim('active'));
|
|
103
|
+
}
|
|
104
|
+
if (snap.turn > 0) {
|
|
105
|
+
const turnText = snap.maxTurn > 0 ? `TURN ${snap.turn}/${snap.maxTurn}` : `TURN ${snap.turn}`;
|
|
106
|
+
segments.push(paint.text.dim(turnText));
|
|
107
|
+
}
|
|
108
|
+
segments.push(paint.text.dim('COST ') + paint.brand.data(formatCost(snap.cost)));
|
|
109
|
+
|
|
110
|
+
return assembleLine(segments, sep, cols);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function buildLineTwo(snap, cols) {
|
|
114
|
+
if (snap.orbit === ORBITS.AWAITING && snap.awaitingTool) {
|
|
115
|
+
const prefix = paint.brand.accent(`${icons.warn} APPROVAL `) + paint.text.primary(snap.awaitingTool);
|
|
116
|
+
const tail = renderDock(dockForOrbit(snap.orbit), Math.max(0, cols - visibleWidth(prefix) - 2));
|
|
117
|
+
return ` ${prefix} ${tail}`;
|
|
118
|
+
}
|
|
119
|
+
const hints = dockForOrbit(snap.orbit);
|
|
120
|
+
return ' ' + renderDock(hints, cols - 1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Concatenate segments with separators, dropping trailing segments when
|
|
125
|
+
* they exceed `cols` of visible width. Always keeps the first segment.
|
|
126
|
+
*/
|
|
127
|
+
function assembleLine(segments, sep, cols) {
|
|
128
|
+
if (segments.length === 0) return '';
|
|
129
|
+
let out = ' ' + segments[0];
|
|
130
|
+
let used = 1 + visibleWidth(segments[0]);
|
|
131
|
+
for (let i = 1; i < segments.length; i++) {
|
|
132
|
+
const piece = sep + segments[i];
|
|
133
|
+
const cost = visibleWidth(piece);
|
|
134
|
+
if (used + cost > cols) break;
|
|
135
|
+
out += piece;
|
|
136
|
+
used += cost;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Render ───────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
function paintLine(text, cols) {
|
|
144
|
+
const pad = Math.max(0, cols - visibleWidth(text));
|
|
145
|
+
return text + ' '.repeat(pad);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function render(snap) {
|
|
149
|
+
if (!mounted || !snap) return;
|
|
150
|
+
const t = term();
|
|
151
|
+
if (!t.isTTY || t.plain) return;
|
|
152
|
+
|
|
153
|
+
const cols = Math.max(20, t.columns);
|
|
154
|
+
const rows = Math.max(4, t.rows);
|
|
155
|
+
|
|
156
|
+
const line1 = paintLine(buildLineOne(snap, cols), cols);
|
|
157
|
+
const line2 = paintLine(buildLineTwo(snap, cols), cols);
|
|
158
|
+
|
|
159
|
+
saveCursor();
|
|
160
|
+
moveTo(rows - 1, 1);
|
|
161
|
+
clearLine();
|
|
162
|
+
write(line1);
|
|
163
|
+
moveTo(rows, 1);
|
|
164
|
+
clearLine();
|
|
165
|
+
write(line2);
|
|
166
|
+
restoreCursor();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Public API ───────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Mount the status bar. Sets up the scroll region, hides the cursor, and
|
|
173
|
+
* registers SIGWINCH. Returns `false` when the environment is not TTY
|
|
174
|
+
* (caller can short-circuit).
|
|
175
|
+
*
|
|
176
|
+
* Safe to call multiple times — re-entrant calls are no-ops.
|
|
177
|
+
*/
|
|
178
|
+
export function mount() {
|
|
179
|
+
if (mounted) return true;
|
|
180
|
+
const t = term();
|
|
181
|
+
if (!t.isTTY || t.plain) return false;
|
|
182
|
+
|
|
183
|
+
// Reserve the bottom 2 rows.
|
|
184
|
+
const rows = Math.max(4, t.rows);
|
|
185
|
+
setScrollRegion(1, rows - 2);
|
|
186
|
+
moveTo(rows - 1, 1); clearLine();
|
|
187
|
+
moveTo(rows, 1); clearLine();
|
|
188
|
+
// Restore cursor into the scroll region so subsequent writes go there.
|
|
189
|
+
moveTo(rows - 2, 1);
|
|
190
|
+
|
|
191
|
+
unsubResize = onResize(() => {
|
|
192
|
+
if (!mounted) return;
|
|
193
|
+
const r = Math.max(4, term().rows);
|
|
194
|
+
setScrollRegion(1, r - 2);
|
|
195
|
+
if (lastSnapshot) render(lastSnapshot);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Cleanup hooks — exits, signals, uncaught crash all restore the terminal.
|
|
199
|
+
process.once('exit', safeUnmount);
|
|
200
|
+
process.once('SIGINT', () => { safeUnmount(); process.exit(130); });
|
|
201
|
+
process.once('SIGTERM', () => { safeUnmount(); process.exit(143); });
|
|
202
|
+
|
|
203
|
+
mounted = true;
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Tear down: restore scroll region, show cursor. Must be called before
|
|
209
|
+
* process exit. Safe to call when not mounted.
|
|
210
|
+
*/
|
|
211
|
+
export function unmount() {
|
|
212
|
+
if (!mounted || resetting) return;
|
|
213
|
+
resetting = true;
|
|
214
|
+
try {
|
|
215
|
+
clearScrollRegion();
|
|
216
|
+
showCursor();
|
|
217
|
+
if (unsubResize) { unsubResize(); unsubResize = null; }
|
|
218
|
+
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
|
219
|
+
} finally {
|
|
220
|
+
mounted = false;
|
|
221
|
+
resetting = false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function safeUnmount() { try { unmount(); } catch {} }
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Push a new orbit snapshot. Triggers a render and starts/stops the
|
|
229
|
+
* spinner tick as needed.
|
|
230
|
+
*/
|
|
231
|
+
export function setOrbit(snap) {
|
|
232
|
+
lastSnapshot = snap;
|
|
233
|
+
if (!mounted) return;
|
|
234
|
+
const meta = ORBIT_META[snap.orbit] || ORBIT_META[ORBITS.IDLE];
|
|
235
|
+
if (meta.spinning) startTick();
|
|
236
|
+
else stopTick();
|
|
237
|
+
render(snap);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Force an immediate redraw using the last known snapshot. */
|
|
241
|
+
export function redraw() {
|
|
242
|
+
if (lastSnapshot) render(lastSnapshot);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function startTick() {
|
|
246
|
+
if (tickInterval || !mounted) return;
|
|
247
|
+
tickInterval = setInterval(() => {
|
|
248
|
+
if (!mounted || !lastSnapshot) return;
|
|
249
|
+
render(lastSnapshot);
|
|
250
|
+
}, SPINNER_INTERVAL_MS);
|
|
251
|
+
if (typeof tickInterval.unref === 'function') tickInterval.unref();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function stopTick() {
|
|
255
|
+
if (!tickInterval) return;
|
|
256
|
+
clearInterval(tickInterval);
|
|
257
|
+
tickInterval = null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Connect an `orbit` state machine instance to this status bar. Returns an
|
|
262
|
+
* unsubscribe function. The bar is mounted automatically; tear it down with
|
|
263
|
+
* `unmount()` or by calling the returned function (which also unmounts).
|
|
264
|
+
*/
|
|
265
|
+
export function attachOrbit(orbit) {
|
|
266
|
+
if (!orbit || typeof orbit.on !== 'function') return () => {};
|
|
267
|
+
if (!mount()) return () => {}; // non-TTY: silently ignore
|
|
268
|
+
const unsub = orbit.on('change', setOrbit);
|
|
269
|
+
// Initial paint
|
|
270
|
+
setOrbit(orbit.state());
|
|
271
|
+
return () => {
|
|
272
|
+
try { unsub(); } catch {}
|
|
273
|
+
unmount();
|
|
274
|
+
};
|
|
275
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent block renderer — Mission Control (PRD-055 §7).
|
|
3
|
+
*
|
|
4
|
+
* Renders the open/close pair for a sub-agent block, dimmed throughout so
|
|
5
|
+
* the primary agent reads bright by contrast. Inner tool cards are indented
|
|
6
|
+
* via the `subAgentIndent()` helper so they nest visually under the header.
|
|
7
|
+
*
|
|
8
|
+
* 🛰️ explore "JWT lifecycle" ▸ running (deepseek/deepseek-v4-flash)
|
|
9
|
+
* 🔭 Search code "expire" → 6 matches
|
|
10
|
+
* 🔭 Read file auth.py L120-180 → 60 lines
|
|
11
|
+
* └ ✅ returned 3 files identified · $0.004 · 2.1s
|
|
12
|
+
*
|
|
13
|
+
* Maintains a depth stack so concurrent / nested sub-agents indent further
|
|
14
|
+
* and so callers can ask `inSubAgent()` / `depth()` without threading state.
|
|
15
|
+
*
|
|
16
|
+
* No I/O — caller writes the returned strings to stderr. This keeps the
|
|
17
|
+
* module testable from a plain Node script.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { paint } from './palette.mjs';
|
|
21
|
+
import { icons } from './icons.mjs';
|
|
22
|
+
|
|
23
|
+
const SUB_ICONS = {
|
|
24
|
+
explore: '🔭',
|
|
25
|
+
plan: '📐',
|
|
26
|
+
verify: '✅',
|
|
27
|
+
debug: '🪲',
|
|
28
|
+
refactor:'♻️',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ── Active stack ─────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const _stack = []; // [{ id, type, startedAt }]
|
|
34
|
+
|
|
35
|
+
/** How many sub-agents are currently open. */
|
|
36
|
+
export function depth() { return _stack.length; }
|
|
37
|
+
export function inSubAgent() { return _stack.length > 0; }
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Indent string for a tool card line nested under N sub-agents.
|
|
41
|
+
* 5 cols per level matches the existing `' '` legacy indent.
|
|
42
|
+
*/
|
|
43
|
+
export function subAgentIndent(extraDepth = 0) {
|
|
44
|
+
const d = _stack.length + extraDepth;
|
|
45
|
+
if (d <= 0) return ' ';
|
|
46
|
+
return ' '.repeat(2 + d * 3);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Render ───────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Open a sub-agent block. Pushes onto the stack; returns the lines to print.
|
|
53
|
+
*
|
|
54
|
+
* @returns {string} ANSI-styled multi-line block (no trailing newline).
|
|
55
|
+
*/
|
|
56
|
+
export function renderSubAgentOpen({ id, type, model, query, parentDepth } = {}) {
|
|
57
|
+
const t = type || 'sub-agent';
|
|
58
|
+
const depthBefore = _stack.length;
|
|
59
|
+
_stack.push({ id: id || `${t}-${depthBefore}-${tag()}`, type: t, startedAt: Date.now() });
|
|
60
|
+
|
|
61
|
+
const indent = ' '.repeat(2 + depthBefore * 3);
|
|
62
|
+
const iconChar = SUB_ICONS[t] || icons.subAgent;
|
|
63
|
+
const head = `${indent}${iconChar} ${paint.brand.data(t)} ${paint.text.dim(`"${truncate(query || '', 60)}"`)}`;
|
|
64
|
+
const tag1 = paint.text.dim(`▸ running${model ? ` (${model})` : ''}`);
|
|
65
|
+
|
|
66
|
+
return query
|
|
67
|
+
? `\n${head} ${tag1}`
|
|
68
|
+
: `\n${indent}${iconChar} ${paint.brand.data(t)} ${tag1}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Close the most recent sub-agent block. Pops the stack; returns the close
|
|
73
|
+
* line with optional cost / token / duration attribution per PRD §7.3.
|
|
74
|
+
*
|
|
75
|
+
* └ ✅ returned 3 files identified · 1.2k tok · $0.004 · 2.1s
|
|
76
|
+
* └ ✗ explore agent failed
|
|
77
|
+
*
|
|
78
|
+
* Caller passes `success` (default true), `summary` ("returned N files"),
|
|
79
|
+
* and any of `{ costUsd, tokens, durationS, toolCalls, iterations }`.
|
|
80
|
+
*/
|
|
81
|
+
export function renderSubAgentClose({
|
|
82
|
+
type,
|
|
83
|
+
success = true,
|
|
84
|
+
summary = '',
|
|
85
|
+
costUsd,
|
|
86
|
+
tokens,
|
|
87
|
+
durationS,
|
|
88
|
+
toolCalls,
|
|
89
|
+
iterations,
|
|
90
|
+
error,
|
|
91
|
+
} = {}) {
|
|
92
|
+
// Match-pop: if the type doesn't match the top of stack we still pop the
|
|
93
|
+
// top entry — backends never emit interleaved open/close, so this is the
|
|
94
|
+
// safe behavior.
|
|
95
|
+
const opened = _stack.pop();
|
|
96
|
+
const t = type || opened?.type || 'sub-agent';
|
|
97
|
+
const indent = ' '.repeat(2 + _stack.length * 3);
|
|
98
|
+
|
|
99
|
+
if (!success) {
|
|
100
|
+
const line = `${indent}${paint.text.dim('└')} ${paint.state.danger('✗')} ${paint.text.dim(`${t} agent failed`)}`;
|
|
101
|
+
if (error) {
|
|
102
|
+
return `${line}\n${indent} ${paint.state.danger(truncate(error, 140))}`;
|
|
103
|
+
}
|
|
104
|
+
return line;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const parts = [];
|
|
108
|
+
if (toolCalls > 0) parts.push(`${toolCalls} tools`);
|
|
109
|
+
if (iterations > 0) parts.push(`${iterations} iter`);
|
|
110
|
+
if (tokens > 0) parts.push(`${formatTokens(tokens)} tok`);
|
|
111
|
+
if (typeof costUsd === 'number' && costUsd > 0) parts.push(formatCost(costUsd));
|
|
112
|
+
if (durationS != null) parts.push(`${Number(durationS).toFixed(1)}s`);
|
|
113
|
+
const detail = parts.length ? paint.text.dim(' · ' + parts.join(' · ')) : '';
|
|
114
|
+
|
|
115
|
+
const body = summary
|
|
116
|
+
? paint.text.dim(summary)
|
|
117
|
+
: paint.text.dim(`${t} returned`);
|
|
118
|
+
|
|
119
|
+
return `${indent}${paint.text.dim('└')} ${paint.state.success('✅')} ${body}${detail}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Force-clear the stack. Use after a `complete` event or when cancelling so
|
|
124
|
+
* a stale entry doesn't keep indenting future output.
|
|
125
|
+
*/
|
|
126
|
+
export function resetSubAgents() { _stack.length = 0; }
|
|
127
|
+
|
|
128
|
+
// ── helpers ──────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
function truncate(text, n) {
|
|
131
|
+
const s = String(text || '');
|
|
132
|
+
return s.length <= n ? s : s.slice(0, n - 1) + '…';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatTokens(n) {
|
|
136
|
+
if (!Number.isFinite(n)) return '0';
|
|
137
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
138
|
+
return String(Math.round(n));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function formatCost(usd) {
|
|
142
|
+
if (usd < 0.001) return `$${usd.toFixed(5)}`;
|
|
143
|
+
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
|
144
|
+
return `$${usd.toFixed(3)}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function tag() {
|
|
148
|
+
// Avoid Date.now()/Math.random() drift across re-renders — depth+counter is
|
|
149
|
+
// enough to keep ids unique within a process.
|
|
150
|
+
tag._n = (tag._n || 0) + 1;
|
|
151
|
+
return tag._n.toString(36);
|
|
152
|
+
}
|
package/src/ui/term.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal capability detection.
|
|
3
|
+
*
|
|
4
|
+
* Resolves once at import. Re-evaluation requires `refresh()` (used by tests
|
|
5
|
+
* and the rare command that toggles a relevant env var mid-process).
|
|
6
|
+
*
|
|
7
|
+
* Capability tiers (highest first):
|
|
8
|
+
* truecolor - 24-bit RGB (e.g. iTerm2, modern xterm, Windows Terminal)
|
|
9
|
+
* ansi256 - 256-color palette
|
|
10
|
+
* ansi16 - basic 16 colors
|
|
11
|
+
* none - no color (NO_COLOR=1, dumb terminal, non-TTY without override)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const TRUECOLOR_TERMS = new Set([
|
|
15
|
+
'truecolor',
|
|
16
|
+
'24bit',
|
|
17
|
+
'24-bit',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const ANSI256_TERMS = [
|
|
21
|
+
/-256(color)?$/i,
|
|
22
|
+
/^xterm/i,
|
|
23
|
+
/^screen/i,
|
|
24
|
+
/^tmux/i,
|
|
25
|
+
/^rxvt-unicode/i,
|
|
26
|
+
/^alacritty/i,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const DUMB_TERMS = new Set(['', 'dumb', 'unknown']);
|
|
30
|
+
|
|
31
|
+
function readEnv() {
|
|
32
|
+
const env = process.env || {};
|
|
33
|
+
return {
|
|
34
|
+
NO_COLOR: env.NO_COLOR,
|
|
35
|
+
FORCE_COLOR: env.FORCE_COLOR,
|
|
36
|
+
KEPLER_PLAIN: env.KEPLER_PLAIN,
|
|
37
|
+
COLORTERM: (env.COLORTERM || '').toLowerCase(),
|
|
38
|
+
TERM: (env.TERM || '').toLowerCase(),
|
|
39
|
+
TERM_PROGRAM: env.TERM_PROGRAM || '',
|
|
40
|
+
CI: env.CI,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function detectColorLevel(env, isTTY) {
|
|
45
|
+
// Hard opt-out (https://no-color.org). Honored even on TTYs.
|
|
46
|
+
if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return 'none';
|
|
47
|
+
if (env.KEPLER_PLAIN === '1') return 'none';
|
|
48
|
+
|
|
49
|
+
// Hard opt-in. FORCE_COLOR=1|2|3 maps to ansi16|ansi256|truecolor.
|
|
50
|
+
// FORCE_COLOR with no value or =true falls through to detection.
|
|
51
|
+
if (env.FORCE_COLOR !== undefined) {
|
|
52
|
+
const v = String(env.FORCE_COLOR).trim();
|
|
53
|
+
if (v === '0' || v === 'false') return 'none';
|
|
54
|
+
if (v === '1') return 'ansi16';
|
|
55
|
+
if (v === '2') return 'ansi256';
|
|
56
|
+
if (v === '3') return 'truecolor';
|
|
57
|
+
// Any other truthy value: continue detection but allow non-TTY.
|
|
58
|
+
isTTY = true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// No TTY and not forced: no color.
|
|
62
|
+
if (!isTTY) return 'none';
|
|
63
|
+
|
|
64
|
+
if (DUMB_TERMS.has(env.TERM)) return 'none';
|
|
65
|
+
|
|
66
|
+
if (TRUECOLOR_TERMS.has(env.COLORTERM)) return 'truecolor';
|
|
67
|
+
|
|
68
|
+
// Some terminal emulators advertise truecolor through TERM_PROGRAM.
|
|
69
|
+
if (env.TERM_PROGRAM === 'iTerm.app' || env.TERM_PROGRAM === 'WezTerm') {
|
|
70
|
+
return 'truecolor';
|
|
71
|
+
}
|
|
72
|
+
if (env.TERM_PROGRAM === 'vscode' || env.TERM_PROGRAM === 'Apple_Terminal') {
|
|
73
|
+
return 'ansi256';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (ANSI256_TERMS.some(re => re.test(env.TERM))) return 'ansi256';
|
|
77
|
+
|
|
78
|
+
return 'ansi16';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function detectUnicode(env) {
|
|
82
|
+
if (env.KEPLER_PLAIN === '1') return false;
|
|
83
|
+
// Most modern terminals on macOS/Linux handle UTF-8.
|
|
84
|
+
// Windows ConEmu / older terminals are the main holdouts; conservative
|
|
85
|
+
// fallback when LANG and LC_* are missing or explicitly POSIX.
|
|
86
|
+
const lang = (process.env.LANG || process.env.LC_ALL || process.env.LC_CTYPE || '').toLowerCase();
|
|
87
|
+
if (!lang) return process.platform !== 'win32';
|
|
88
|
+
if (lang.includes('utf')) return true;
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function compute() {
|
|
93
|
+
const env = readEnv();
|
|
94
|
+
const isTTY = !!(process.stdout && process.stdout.isTTY);
|
|
95
|
+
const level = detectColorLevel(env, isTTY);
|
|
96
|
+
return {
|
|
97
|
+
isTTY,
|
|
98
|
+
colorLevel: level, // 'none' | 'ansi16' | 'ansi256' | 'truecolor'
|
|
99
|
+
color: level !== 'none',
|
|
100
|
+
truecolor: level === 'truecolor',
|
|
101
|
+
ansi256: level === 'ansi256' || level === 'truecolor',
|
|
102
|
+
unicode: detectUnicode(env),
|
|
103
|
+
plain: env.KEPLER_PLAIN === '1',
|
|
104
|
+
columns: (process.stdout && process.stdout.columns) || 80,
|
|
105
|
+
rows: (process.stdout && process.stdout.rows) || 24,
|
|
106
|
+
ci: !!env.CI,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let _capabilities = compute();
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Current terminal capabilities. Stable until `refresh()` is called.
|
|
114
|
+
*/
|
|
115
|
+
export function term() {
|
|
116
|
+
return _capabilities;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Re-run capability detection. Tests, `/config` reloads, or runtime env changes.
|
|
121
|
+
*/
|
|
122
|
+
export function refresh() {
|
|
123
|
+
_capabilities = compute();
|
|
124
|
+
return _capabilities;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Listen for terminal resizes. Returns an unsubscribe function.
|
|
129
|
+
* Callers receive the latest capabilities object (with updated columns/rows).
|
|
130
|
+
*/
|
|
131
|
+
export function onResize(handler) {
|
|
132
|
+
if (typeof handler !== 'function') return () => {};
|
|
133
|
+
const stream = process.stdout;
|
|
134
|
+
if (!stream || typeof stream.on !== 'function') return () => {};
|
|
135
|
+
|
|
136
|
+
const onChange = () => {
|
|
137
|
+
_capabilities = {
|
|
138
|
+
..._capabilities,
|
|
139
|
+
columns: stream.columns || _capabilities.columns,
|
|
140
|
+
rows: stream.rows || _capabilities.rows,
|
|
141
|
+
};
|
|
142
|
+
handler(_capabilities);
|
|
143
|
+
};
|
|
144
|
+
stream.on('resize', onChange);
|
|
145
|
+
return () => stream.off('resize', onChange);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Force a capability level. Test-only escape hatch.
|
|
150
|
+
* Pass `null` to clear and re-detect.
|
|
151
|
+
*/
|
|
152
|
+
export function _setForTesting(overrides) {
|
|
153
|
+
if (overrides === null) {
|
|
154
|
+
_capabilities = compute();
|
|
155
|
+
return _capabilities;
|
|
156
|
+
}
|
|
157
|
+
_capabilities = { ..._capabilities, ...overrides };
|
|
158
|
+
return _capabilities;
|
|
159
|
+
}
|