@axplusb/kepler 1.0.10 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Tool cards — Mission Control (PRD-055 §6).
3
+ *
4
+ * One-line summary per tool: icon + label + args + outcome.
5
+ *
6
+ * 🔭 search_code "JWT validation" → 4 matches in 2 files
7
+ * 🔭 read_file auth.py L42-L88 → 47 lines
8
+ * 🛠️ edit_file auth.py → +12 −4
9
+ * ⚙️ shell "npm test" → passed in 1.2s
10
+ *
11
+ * Two render points:
12
+ *
13
+ * formatCardHead(tool, args) — at tool invocation (no outcome)
14
+ * formatCard({ tool, args, result, … }) — once the result arrives
15
+ *
16
+ * Cards are recorded in a small ring buffer (`recordCard`, `lastCard`,
17
+ * `getCard`) so the expand handler in repl.mjs can re-render details on `d`
18
+ * / `/last` / `/expand <n>` without holding state in the REPL itself.
19
+ *
20
+ * No I/O — callers (repl, demo, headless adapter) are responsible for
21
+ * `process.stderr.write(...)`. This keeps the module pure and testable.
22
+ */
23
+
24
+ import { paint, width as visibleWidth } from './palette.mjs';
25
+ import { icon, toolFamily } from './icons.mjs';
26
+ import { term } from './term.mjs';
27
+ import {
28
+ toolDisplayLabel,
29
+ toolDisplaySummary,
30
+ formatShellCommand,
31
+ } from '../terminal/tool-display.mjs';
32
+
33
+ // ── Family → label colorizer ─────────────────────────────────────────────
34
+
35
+ function paintLabel(tool, label) {
36
+ switch (toolFamily(tool)) {
37
+ case 'subAgent': return paint.brand.data(label);
38
+ case 'search': return paint.text.primary(label);
39
+ case 'write': return paint.brand.primary(label);
40
+ case 'shell': return paint.state.warn(label);
41
+ case 'network': return paint.brand.accent(label);
42
+ default: return paint.text.primary(label);
43
+ }
44
+ }
45
+
46
+ // ── Args summary ─────────────────────────────────────────────────────────
47
+
48
+ function formatArgs(tool, args, cwd) {
49
+ const summary = toolDisplaySummary(tool, args || {}, { cwd });
50
+ if (!summary) return '';
51
+ if (tool === 'shell') {
52
+ return formatShellCommand(summary, paintShellAdapter);
53
+ }
54
+ return paint.text.muted(summary);
55
+ }
56
+
57
+ // Adapter so formatShellCommand (from legacy tool-display.mjs) keeps working
58
+ // against the new palette. It expects an object with .red/.blue/.yellow/.white.
59
+ const paintShellAdapter = {
60
+ red: (s) => paint.state.danger(s),
61
+ blue: (s) => paint.brand.data(s),
62
+ yellow: (s) => paint.state.warn(s),
63
+ white: (s) => paint.text.primary(s),
64
+ };
65
+
66
+ // ── Result → outcome summary ─────────────────────────────────────────────
67
+
68
+ /**
69
+ * Summarize a tool result into a compact outcome label.
70
+ *
71
+ * @returns {{ text: string, tone: 'success'|'warn'|'danger'|'dim' }}
72
+ */
73
+ export function summarizeResult(tool, data) {
74
+ if (!data) return { text: '', tone: 'dim' };
75
+
76
+ if (data._blocked) {
77
+ return { text: firstOutputLine(data) || 'blocked', tone: 'danger' };
78
+ }
79
+ if (data.success === false) {
80
+ const msg = String(data.error || firstOutputLine(data) || 'failed').slice(0, 140);
81
+ return { text: msg, tone: 'danger' };
82
+ }
83
+
84
+ switch (tool) {
85
+ case 'read_file': {
86
+ const lines = data._total_lines || lineCount(data.output || data.output_preview);
87
+ return { text: `${lines} line${lines === 1 ? '' : 's'}`, tone: 'success' };
88
+ }
89
+ case 'read_files':
90
+ return { text: 'files read', tone: 'success' };
91
+
92
+ case 'search_code':
93
+ case 'search_files':
94
+ case 'grep': {
95
+ const matches = countMatches(data);
96
+ const files = countMatchFiles(data);
97
+ if (matches === 0) return { text: 'no matches', tone: 'warn' };
98
+ const filesPart = files > 0 ? ` in ${files} file${files === 1 ? '' : 's'}` : '';
99
+ return { text: `${matches} match${matches === 1 ? '' : 'es'}${filesPart}`, tone: 'success' };
100
+ }
101
+
102
+ case 'list_files': {
103
+ const n = lineCount(data.output);
104
+ return { text: n > 0 ? `${n} item${n === 1 ? '' : 's'}` : 'empty', tone: 'success' };
105
+ }
106
+
107
+ case 'edit_file':
108
+ case 'write_file':
109
+ case 'write_project': {
110
+ const delta = diffDelta(data);
111
+ if (delta) return { text: delta, tone: 'success' };
112
+ return { text: 'updated', tone: 'success' };
113
+ }
114
+
115
+ case 'delete_file':
116
+ return { text: 'deleted', tone: 'warn' };
117
+
118
+ case 'shell':
119
+ case 'run_tests':
120
+ case 'validate_build':
121
+ case 'lint_check':
122
+ case 'validate_file':
123
+ case 'validate_structure': {
124
+ const exit = data.exit_code ?? data.exitCode;
125
+ if (exit != null && exit !== 0) {
126
+ return { text: `exit ${exit}`, tone: 'danger' };
127
+ }
128
+ const head = firstOutputLine(data).slice(0, 100);
129
+ return { text: head || 'ok', tone: 'success' };
130
+ }
131
+
132
+ case 'analyze_code': {
133
+ // Backend returns "filename (N lines, ext)" — the filename already
134
+ // appears in the card head, so strip it and keep just the metadata.
135
+ const head = firstOutputLine(data);
136
+ const m = head.match(/\((\d+)\s+lines?,?\s+([^)]+)\)/);
137
+ if (m) return { text: `${m[1]} lines · ${m[2].trim()}`, tone: 'success' };
138
+ return { text: head.slice(0, 80) || 'done', tone: 'success' };
139
+ }
140
+
141
+ case 'plan':
142
+ case 'explore':
143
+ case 'verify':
144
+ case 'debug':
145
+ case 'refactor': {
146
+ const head = firstOutputLine(data).slice(0, 100);
147
+ return { text: head || 'done', tone: 'success' };
148
+ }
149
+
150
+ default: {
151
+ const head = firstOutputLine(data).slice(0, 100);
152
+ return { text: head || 'done', tone: 'success' };
153
+ }
154
+ }
155
+ }
156
+
157
+ function firstOutputLine(data) {
158
+ const o = data?.output_preview || data?.output || data?.message || '';
159
+ return String(o).split('\n').map(l => l.trim()).find(Boolean) || '';
160
+ }
161
+
162
+ function lineCount(s) {
163
+ if (!s) return 0;
164
+ return String(s).split('\n').filter(Boolean).length;
165
+ }
166
+
167
+ function countMatches(data) {
168
+ if (typeof data?.match_count === 'number') return data.match_count;
169
+ return lineCount(data?.output);
170
+ }
171
+
172
+ function countMatchFiles(data) {
173
+ if (typeof data?.file_count === 'number') return data.file_count;
174
+ const out = String(data?.output || '');
175
+ if (!out) return 0;
176
+ const files = new Set();
177
+ for (const line of out.split('\n')) {
178
+ const m = line.match(/^([^:]+):/);
179
+ if (m) files.add(m[1]);
180
+ }
181
+ return files.size;
182
+ }
183
+
184
+ function diffDelta(data) {
185
+ const add = data?.lines_added ?? data?.additions;
186
+ const rem = data?.lines_removed ?? data?.deletions;
187
+ if (add == null && rem == null) return '';
188
+ const a = add ?? 0;
189
+ const r = rem ?? 0;
190
+ return `+${a} −${r}`;
191
+ }
192
+
193
+ function tone(text, t) {
194
+ switch (t) {
195
+ case 'success': return paint.state.success(text);
196
+ case 'warn': return paint.state.warn(text);
197
+ case 'danger': return paint.state.danger(text);
198
+ case 'dim':
199
+ default: return paint.text.dim(text);
200
+ }
201
+ }
202
+
203
+ // ── Card head (printed at invocation) ────────────────────────────────────
204
+
205
+ /**
206
+ * Render the leading half of a card — icon + colored label + args.
207
+ * Width-aware: truncates args from the left when the line would overflow.
208
+ */
209
+ export function formatCardHead(tool, args, opts = {}) {
210
+ const cwd = opts.cwd || safeCwd();
211
+ const cols = opts.columns || term().columns || 120;
212
+ const indent = opts.indent || ' ';
213
+
214
+ const iconText = icon(tool);
215
+ const label = toolDisplayLabel(tool);
216
+ const argsText = formatArgs(tool, args, cwd);
217
+
218
+ const leadVisible = visibleWidth(`${indent}${iconText} ${label}`);
219
+ const budget = Math.max(20, cols - leadVisible - 4);
220
+ const argsTruncated = truncateMiddle(argsText, budget);
221
+
222
+ const head = `${indent}${iconText} ${paintLabel(tool, label)}`;
223
+ return argsTruncated ? `${head} ${argsTruncated}` : head;
224
+ }
225
+
226
+ /**
227
+ * Render a full card with outcome.
228
+ *
229
+ * 🔭 search_code "JWT" → 4 matches in 2 files · 120ms
230
+ *
231
+ * `result` is the tool_result data from the SSE stream (same shape as
232
+ * `renderToolResult` consumed). `durationMs` overrides what's on the result.
233
+ */
234
+ export function formatCard({ tool, args, result, durationMs, indent, columns, cwd } = {}) {
235
+ const cols = columns || term().columns || 120;
236
+ const head = formatCardHead(tool, args, { indent, columns: cols, cwd });
237
+
238
+ const summary = summarizeResult(tool, result);
239
+ const duration = formatDuration(durationMs ?? result?.duration_ms ?? (result?.duration_s != null ? result.duration_s * 1000 : null));
240
+
241
+ if (!summary.text && !duration) return head;
242
+
243
+ const arrow = paint.text.dim('→');
244
+ const body = summary.text ? tone(summary.text, summary.tone) : '';
245
+ const tail = duration ? paint.text.dim(` · ${duration}`) : '';
246
+
247
+ const candidate = `${head} ${arrow} ${body}${tail}`;
248
+ if (visibleWidth(candidate) <= cols) return candidate;
249
+
250
+ // Doesn't fit on one line → push outcome to a separate gutter line.
251
+ const gutterIndent = (indent || ' ') + paint.text.dim('⎿ ');
252
+ return `${head}\n${gutterIndent}${arrow} ${body}${tail}`;
253
+ }
254
+
255
+ function truncateMiddle(text, max) {
256
+ if (!text) return '';
257
+ if (visibleWidth(text) <= max) return text;
258
+ // Truncate the plain text and re-trust palette helpers to skip codes.
259
+ const plain = text.replace(/\x1b\[[0-9;]*m/g, '');
260
+ if (plain.length <= max) return text;
261
+ const keep = Math.max(8, max - 3);
262
+ const head = plain.slice(0, Math.floor(keep / 2));
263
+ const tail = plain.slice(plain.length - Math.ceil(keep / 2));
264
+ return paint.text.muted(`${head}…${tail}`);
265
+ }
266
+
267
+ function formatDuration(ms) {
268
+ if (ms == null || !Number.isFinite(ms)) return '';
269
+ if (ms < 1000) return `${Math.round(ms)}ms`;
270
+ return `${(ms / 1000).toFixed(1)}s`;
271
+ }
272
+
273
+ function safeCwd() {
274
+ try { return process.cwd(); } catch { return ''; }
275
+ }
276
+
277
+ // ── Ring buffer of recent cards (for expand / /last) ─────────────────────
278
+
279
+ const MAX_CARDS = 50;
280
+ const _cards = [];
281
+
282
+ /**
283
+ * Record a card by its call_id (or generated id). Returns the stored entry.
284
+ * The entry is updated in place when the matching result arrives.
285
+ */
286
+ export function recordCard({ id, tool, args, head, result, durationMs, startedAt }) {
287
+ const entry = { id, tool, args, head, result: result || null, durationMs: durationMs ?? null, startedAt: startedAt ?? null };
288
+ // Replace if same id already exists (e.g. tool_call followed by tool_result)
289
+ const existing = _cards.findIndex(c => c.id != null && c.id === id);
290
+ if (existing >= 0) {
291
+ _cards[existing] = { ..._cards[existing], ...entry };
292
+ return _cards[existing];
293
+ }
294
+ _cards.push(entry);
295
+ if (_cards.length > MAX_CARDS) _cards.shift();
296
+ return entry;
297
+ }
298
+
299
+ /** Most recently recorded card (the one `d` / `/last` should expand). */
300
+ export function lastCard() {
301
+ return _cards[_cards.length - 1] || null;
302
+ }
303
+
304
+ /** Look up a card by id, or 1-based index from the tail (-1 == lastCard). */
305
+ export function getCard(idOrIndex) {
306
+ if (idOrIndex == null) return lastCard();
307
+ if (typeof idOrIndex === 'number') {
308
+ if (idOrIndex < 0) return _cards[_cards.length + idOrIndex] || null;
309
+ return _cards[idOrIndex] || null;
310
+ }
311
+ return _cards.find(c => c.id === idOrIndex) || null;
312
+ }
313
+
314
+ /** All recorded cards in order. */
315
+ export function allCards() {
316
+ return _cards.slice();
317
+ }
318
+
319
+ /** Drop all recorded cards (used by tests and `/clear`). */
320
+ export function clearCards() {
321
+ _cards.length = 0;
322
+ }