@axplusb/kepler 1.0.10 → 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.
@@ -0,0 +1,314 @@
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 'plan':
133
+ case 'explore':
134
+ case 'verify':
135
+ case 'debug':
136
+ case 'refactor':
137
+ case 'analyze_code': {
138
+ const head = firstOutputLine(data).slice(0, 100);
139
+ return { text: head || 'done', tone: 'success' };
140
+ }
141
+
142
+ default: {
143
+ const head = firstOutputLine(data).slice(0, 100);
144
+ return { text: head || 'done', tone: 'success' };
145
+ }
146
+ }
147
+ }
148
+
149
+ function firstOutputLine(data) {
150
+ const o = data?.output_preview || data?.output || data?.message || '';
151
+ return String(o).split('\n').map(l => l.trim()).find(Boolean) || '';
152
+ }
153
+
154
+ function lineCount(s) {
155
+ if (!s) return 0;
156
+ return String(s).split('\n').filter(Boolean).length;
157
+ }
158
+
159
+ function countMatches(data) {
160
+ if (typeof data?.match_count === 'number') return data.match_count;
161
+ return lineCount(data?.output);
162
+ }
163
+
164
+ function countMatchFiles(data) {
165
+ if (typeof data?.file_count === 'number') return data.file_count;
166
+ const out = String(data?.output || '');
167
+ if (!out) return 0;
168
+ const files = new Set();
169
+ for (const line of out.split('\n')) {
170
+ const m = line.match(/^([^:]+):/);
171
+ if (m) files.add(m[1]);
172
+ }
173
+ return files.size;
174
+ }
175
+
176
+ function diffDelta(data) {
177
+ const add = data?.lines_added ?? data?.additions;
178
+ const rem = data?.lines_removed ?? data?.deletions;
179
+ if (add == null && rem == null) return '';
180
+ const a = add ?? 0;
181
+ const r = rem ?? 0;
182
+ return `+${a} −${r}`;
183
+ }
184
+
185
+ function tone(text, t) {
186
+ switch (t) {
187
+ case 'success': return paint.state.success(text);
188
+ case 'warn': return paint.state.warn(text);
189
+ case 'danger': return paint.state.danger(text);
190
+ case 'dim':
191
+ default: return paint.text.dim(text);
192
+ }
193
+ }
194
+
195
+ // ── Card head (printed at invocation) ────────────────────────────────────
196
+
197
+ /**
198
+ * Render the leading half of a card — icon + colored label + args.
199
+ * Width-aware: truncates args from the left when the line would overflow.
200
+ */
201
+ export function formatCardHead(tool, args, opts = {}) {
202
+ const cwd = opts.cwd || safeCwd();
203
+ const cols = opts.columns || term().columns || 120;
204
+ const indent = opts.indent || ' ';
205
+
206
+ const iconText = icon(tool);
207
+ const label = toolDisplayLabel(tool);
208
+ const argsText = formatArgs(tool, args, cwd);
209
+
210
+ const leadVisible = visibleWidth(`${indent}${iconText} ${label}`);
211
+ const budget = Math.max(20, cols - leadVisible - 4);
212
+ const argsTruncated = truncateMiddle(argsText, budget);
213
+
214
+ const head = `${indent}${iconText} ${paintLabel(tool, label)}`;
215
+ return argsTruncated ? `${head} ${argsTruncated}` : head;
216
+ }
217
+
218
+ /**
219
+ * Render a full card with outcome.
220
+ *
221
+ * 🔭 search_code "JWT" → 4 matches in 2 files · 120ms
222
+ *
223
+ * `result` is the tool_result data from the SSE stream (same shape as
224
+ * `renderToolResult` consumed). `durationMs` overrides what's on the result.
225
+ */
226
+ export function formatCard({ tool, args, result, durationMs, indent, columns, cwd } = {}) {
227
+ const cols = columns || term().columns || 120;
228
+ const head = formatCardHead(tool, args, { indent, columns: cols, cwd });
229
+
230
+ const summary = summarizeResult(tool, result);
231
+ const duration = formatDuration(durationMs ?? result?.duration_ms ?? (result?.duration_s != null ? result.duration_s * 1000 : null));
232
+
233
+ if (!summary.text && !duration) return head;
234
+
235
+ const arrow = paint.text.dim('→');
236
+ const body = summary.text ? tone(summary.text, summary.tone) : '';
237
+ const tail = duration ? paint.text.dim(` · ${duration}`) : '';
238
+
239
+ const candidate = `${head} ${arrow} ${body}${tail}`;
240
+ if (visibleWidth(candidate) <= cols) return candidate;
241
+
242
+ // Doesn't fit on one line → push outcome to a separate gutter line.
243
+ const gutterIndent = (indent || ' ') + paint.text.dim('⎿ ');
244
+ return `${head}\n${gutterIndent}${arrow} ${body}${tail}`;
245
+ }
246
+
247
+ function truncateMiddle(text, max) {
248
+ if (!text) return '';
249
+ if (visibleWidth(text) <= max) return text;
250
+ // Truncate the plain text and re-trust palette helpers to skip codes.
251
+ const plain = text.replace(/\x1b\[[0-9;]*m/g, '');
252
+ if (plain.length <= max) return text;
253
+ const keep = Math.max(8, max - 3);
254
+ const head = plain.slice(0, Math.floor(keep / 2));
255
+ const tail = plain.slice(plain.length - Math.ceil(keep / 2));
256
+ return paint.text.muted(`${head}…${tail}`);
257
+ }
258
+
259
+ function formatDuration(ms) {
260
+ if (ms == null || !Number.isFinite(ms)) return '';
261
+ if (ms < 1000) return `${Math.round(ms)}ms`;
262
+ return `${(ms / 1000).toFixed(1)}s`;
263
+ }
264
+
265
+ function safeCwd() {
266
+ try { return process.cwd(); } catch { return ''; }
267
+ }
268
+
269
+ // ── Ring buffer of recent cards (for expand / /last) ─────────────────────
270
+
271
+ const MAX_CARDS = 50;
272
+ const _cards = [];
273
+
274
+ /**
275
+ * Record a card by its call_id (or generated id). Returns the stored entry.
276
+ * The entry is updated in place when the matching result arrives.
277
+ */
278
+ export function recordCard({ id, tool, args, head, result, durationMs, startedAt }) {
279
+ const entry = { id, tool, args, head, result: result || null, durationMs: durationMs ?? null, startedAt: startedAt ?? null };
280
+ // Replace if same id already exists (e.g. tool_call followed by tool_result)
281
+ const existing = _cards.findIndex(c => c.id != null && c.id === id);
282
+ if (existing >= 0) {
283
+ _cards[existing] = { ..._cards[existing], ...entry };
284
+ return _cards[existing];
285
+ }
286
+ _cards.push(entry);
287
+ if (_cards.length > MAX_CARDS) _cards.shift();
288
+ return entry;
289
+ }
290
+
291
+ /** Most recently recorded card (the one `d` / `/last` should expand). */
292
+ export function lastCard() {
293
+ return _cards[_cards.length - 1] || null;
294
+ }
295
+
296
+ /** Look up a card by id, or 1-based index from the tail (-1 == lastCard). */
297
+ export function getCard(idOrIndex) {
298
+ if (idOrIndex == null) return lastCard();
299
+ if (typeof idOrIndex === 'number') {
300
+ if (idOrIndex < 0) return _cards[_cards.length + idOrIndex] || null;
301
+ return _cards[idOrIndex] || null;
302
+ }
303
+ return _cards.find(c => c.id === idOrIndex) || null;
304
+ }
305
+
306
+ /** All recorded cards in order. */
307
+ export function allCards() {
308
+ return _cards.slice();
309
+ }
310
+
311
+ /** Drop all recorded cards (used by tests and `/clear`). */
312
+ export function clearCards() {
313
+ _cards.length = 0;
314
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Tool detail formatters — Mission Control (PRD-055 §6.3).
3
+ *
4
+ * One function per high-value tool, all sharing the same signature so the
5
+ * expand handler can dispatch by `tool`:
6
+ *
7
+ * detailFor(card) → string // multi-line, ANSI-styled, no trailing \n
8
+ *
9
+ * Falls back to a generic dump when there's no dedicated formatter.
10
+ *
11
+ * Pure: no I/O. The REPL writes the returned string to stderr.
12
+ */
13
+
14
+ import { paint } from './palette.mjs';
15
+ import { icon, toolFamily } from './icons.mjs';
16
+ import { toolDisplayLabel } from '../terminal/tool-display.mjs';
17
+
18
+ const MAX_DETAIL_LINES = 60;
19
+ const MAX_LINE_WIDTH = 220;
20
+
21
+ // ── Dispatch ─────────────────────────────────────────────────────────────
22
+
23
+ export function detailFor(card) {
24
+ if (!card) return paint.text.dim(' (no card to expand)');
25
+ const { tool } = card;
26
+
27
+ const header = renderHeader(card);
28
+ const body = renderBody(card);
29
+ return body ? `${header}\n${body}` : header;
30
+ }
31
+
32
+ function renderBody(card) {
33
+ const { tool } = card;
34
+ switch (tool) {
35
+ case 'read_file': return detailReadFile(card);
36
+ case 'read_files': return detailReadFiles(card);
37
+ case 'search_code':
38
+ case 'search_files':
39
+ case 'grep': return detailSearch(card);
40
+ case 'list_files': return detailListFiles(card);
41
+ case 'edit_file': return detailEditFile(card);
42
+ case 'write_file': return detailWriteFile(card);
43
+ case 'write_project': return detailWriteProject(card);
44
+ case 'delete_file': return detailDeleteFile(card);
45
+ case 'shell': return detailShell(card);
46
+ case 'run_tests':
47
+ case 'validate_build':
48
+ case 'lint_check':
49
+ case 'validate_file':
50
+ case 'validate_structure': return detailValidator(card);
51
+ case 'plan': return detailPlan(card);
52
+ case 'explore':
53
+ case 'verify':
54
+ case 'debug':
55
+ case 'refactor':
56
+ case 'analyze_code': return detailGenericOutput(card);
57
+ default: return detailGenericOutput(card);
58
+ }
59
+ }
60
+
61
+ // ── Header / framing ─────────────────────────────────────────────────────
62
+
63
+ function renderHeader(card) {
64
+ const { tool, args, durationMs, result } = card;
65
+ const label = toolDisplayLabel(tool);
66
+ const fam = toolFamily(tool);
67
+ const accent = fam === 'write' ? paint.brand.primary
68
+ : fam === 'shell' ? paint.state.warn
69
+ : fam === 'subAgent' ? paint.brand.data
70
+ : paint.text.primary;
71
+
72
+ const lines = [
73
+ ` ${paint.text.dim('━━')} ${icon(tool)} ${accent(label)} ${paint.text.dim('━━')}`,
74
+ ];
75
+
76
+ const args1 = oneLineArgs(tool, args);
77
+ if (args1) lines.push(` ${paint.text.dim('args ')} ${args1}`);
78
+ if (durationMs != null) {
79
+ lines.push(` ${paint.text.dim('time ')} ${paint.text.muted(formatDuration(durationMs))}`);
80
+ }
81
+ if (result?.success === false) {
82
+ lines.push(` ${paint.state.danger('error ')} ${result?.error || 'failed'}`);
83
+ }
84
+ return lines.join('\n');
85
+ }
86
+
87
+ function oneLineArgs(tool, args) {
88
+ if (!args) return '';
89
+ try {
90
+ const compact = JSON.stringify(args);
91
+ if (compact.length <= 140) return paint.text.muted(compact);
92
+ return paint.text.muted(compact.slice(0, 137) + '…');
93
+ } catch {
94
+ return paint.text.muted(String(args));
95
+ }
96
+ }
97
+
98
+ // ── Read ────────────────────────────────────────────────────────────────
99
+
100
+ function detailReadFile(card) {
101
+ const output = String(card.result?.output ?? card.result?.output_preview ?? '');
102
+ if (!output) return paint.text.dim(' (empty file)');
103
+ const startLine = Number(card.args?.start_line) || 1;
104
+ return numbered(output, startLine);
105
+ }
106
+
107
+ function detailReadFiles(card) {
108
+ const output = String(card.result?.output ?? '');
109
+ return clip(output);
110
+ }
111
+
112
+ // ── Search / list ───────────────────────────────────────────────────────
113
+
114
+ function detailSearch(card) {
115
+ const output = String(card.result?.output ?? '');
116
+ if (!output.trim()) return paint.text.dim(' (no matches)');
117
+
118
+ const grouped = groupSearchByFile(output);
119
+ if (!grouped) return clip(output);
120
+
121
+ const out = [];
122
+ let totalLines = 0;
123
+ for (const [file, hits] of grouped) {
124
+ if (totalLines > MAX_DETAIL_LINES) {
125
+ out.push(paint.text.dim(` … ${grouped.size - out.length / 2} more file(s)`));
126
+ break;
127
+ }
128
+ out.push(` ${paint.brand.data(file)}`);
129
+ for (const hit of hits.slice(0, 8)) {
130
+ out.push(` ${paint.text.dim(hit.line + ':')} ${paint.text.primary(hit.text)}`);
131
+ totalLines++;
132
+ }
133
+ if (hits.length > 8) {
134
+ out.push(paint.text.dim(` … ${hits.length - 8} more match(es)`));
135
+ }
136
+ totalLines += 1;
137
+ }
138
+ return out.join('\n');
139
+ }
140
+
141
+ function groupSearchByFile(output) {
142
+ const groups = new Map();
143
+ let any = false;
144
+ for (const line of output.split('\n')) {
145
+ const m = line.match(/^([^:]+):(\d+):(.*)$/);
146
+ if (!m) continue;
147
+ any = true;
148
+ const [, file, ln, text] = m;
149
+ if (!groups.has(file)) groups.set(file, []);
150
+ groups.get(file).push({ line: ln, text: text.trim().slice(0, MAX_LINE_WIDTH) });
151
+ }
152
+ return any ? groups : null;
153
+ }
154
+
155
+ function detailListFiles(card) {
156
+ const output = String(card.result?.output ?? '');
157
+ if (!output.trim()) return paint.text.dim(' (empty)');
158
+ const lines = output.split('\n').filter(Boolean).slice(0, MAX_DETAIL_LINES);
159
+ return lines.map(l => ` ${paint.text.primary(l)}`).join('\n');
160
+ }
161
+
162
+ // ── Write / edit ────────────────────────────────────────────────────────
163
+
164
+ function detailEditFile(card) {
165
+ const diff = card.result?.diff || card.result?.patch || card.result?.output;
166
+ if (diff) return renderDiff(String(diff));
167
+
168
+ const before = card.args?.search;
169
+ const after = card.args?.replace;
170
+ if (before != null && after != null) {
171
+ return [
172
+ ` ${paint.state.danger('- ' + String(before).split('\n')[0].slice(0, 160))}`,
173
+ ` ${paint.state.success('+ ' + String(after).split('\n')[0].slice(0, 160))}`,
174
+ ].join('\n');
175
+ }
176
+ return paint.text.dim(' (edit applied, no diff returned)');
177
+ }
178
+
179
+ function detailWriteFile(card) {
180
+ const content = card.args?.content;
181
+ if (!content) return paint.text.dim(' (no content)');
182
+ return numbered(String(content), 1);
183
+ }
184
+
185
+ function detailWriteProject(card) {
186
+ const files = card.args?.files || [];
187
+ if (!files.length) return paint.text.dim(' (no files)');
188
+ return files.slice(0, 30).map(f => {
189
+ const p = f.path || f.file_path || '';
190
+ const lines = typeof f.content === 'string' ? f.content.split('\n').length : '?';
191
+ return ` ${paint.brand.primary(p)} ${paint.text.dim(`(${lines} lines)`)}`;
192
+ }).join('\n');
193
+ }
194
+
195
+ function detailDeleteFile(card) {
196
+ const p = card.args?.file_path || card.args?.path || '';
197
+ return ` ${paint.state.danger('✗')} ${paint.text.primary(p)}`;
198
+ }
199
+
200
+ // ── Shell / validators ──────────────────────────────────────────────────
201
+
202
+ function detailShell(card) {
203
+ const stdout = String(card.result?.stdout ?? card.result?.output ?? '');
204
+ const stderr = String(card.result?.stderr ?? '');
205
+ const out = [];
206
+ if (stdout) {
207
+ out.push(paint.text.dim(' stdout'));
208
+ out.push(clip(stdout));
209
+ }
210
+ if (stderr) {
211
+ if (out.length) out.push('');
212
+ out.push(paint.state.warn(' stderr'));
213
+ out.push(clip(stderr, paint.state.danger));
214
+ }
215
+ return out.length ? out.join('\n') : paint.text.dim(' (no output)');
216
+ }
217
+
218
+ function detailValidator(card) {
219
+ return detailShell(card);
220
+ }
221
+
222
+ // ── Sub-agent output ────────────────────────────────────────────────────
223
+
224
+ function detailPlan(card) {
225
+ const text = String(card.result?.output ?? card.result?.plan ?? '');
226
+ if (!text.trim()) return paint.text.dim(' (no plan)');
227
+ // Number list items, highlight headers.
228
+ return text.split('\n').slice(0, MAX_DETAIL_LINES).map(line => {
229
+ if (/^\s*\d+\./.test(line)) return ` ${paint.brand.accent(line.trim())}`;
230
+ if (/^#+\s/.test(line)) return ` ${paint.bold(paint.brand.primary(line.trim()))}`;
231
+ return ` ${paint.text.primary(line)}`;
232
+ }).join('\n');
233
+ }
234
+
235
+ function detailGenericOutput(card) {
236
+ const out = String(card.result?.output ?? card.result?.output_preview ?? '');
237
+ return clip(out);
238
+ }
239
+
240
+ // ── Helpers ─────────────────────────────────────────────────────────────
241
+
242
+ function numbered(text, start) {
243
+ const lines = String(text).split('\n');
244
+ const total = lines.length;
245
+ const width = String(start + total - 1).length;
246
+ return lines.slice(0, MAX_DETAIL_LINES).map((line, i) => {
247
+ const n = String(start + i).padStart(width);
248
+ return ` ${paint.text.dim(n)} ${paint.text.primary(line.slice(0, MAX_LINE_WIDTH))}`;
249
+ }).join('\n') + (total > MAX_DETAIL_LINES
250
+ ? `\n ${paint.text.dim(`… ${total - MAX_DETAIL_LINES} more line(s)`)}`
251
+ : '');
252
+ }
253
+
254
+ function clip(text, painter = paint.text.primary) {
255
+ if (!text) return paint.text.dim(' (empty)');
256
+ const lines = String(text).split('\n');
257
+ const head = lines.slice(0, MAX_DETAIL_LINES).map(l => ` ${painter(l.slice(0, MAX_LINE_WIDTH))}`);
258
+ if (lines.length > MAX_DETAIL_LINES) {
259
+ head.push(` ${paint.text.dim(`… ${lines.length - MAX_DETAIL_LINES} more line(s)`)}`);
260
+ }
261
+ return head.join('\n');
262
+ }
263
+
264
+ function renderDiff(text) {
265
+ return text.split('\n').slice(0, MAX_DETAIL_LINES).map(line => {
266
+ if (line.startsWith('+++') || line.startsWith('---')) return ` ${paint.bold(paint.text.muted(line))}`;
267
+ if (line.startsWith('@@')) return ` ${paint.brand.data(line)}`;
268
+ if (line.startsWith('+')) return ` ${paint.state.success(line)}`;
269
+ if (line.startsWith('-')) return ` ${paint.state.danger(line)}`;
270
+ return ` ${paint.text.dim(line)}`;
271
+ }).join('\n');
272
+ }
273
+
274
+ function formatDuration(ms) {
275
+ if (ms < 1000) return `${Math.round(ms)}ms`;
276
+ return `${(ms / 1000).toFixed(1)}s`;
277
+ }