@mintlify/cli 4.0.1434 → 4.0.1437

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,988 @@
1
+ import {
2
+ DEFAULT_COMMAND_TIMEOUT_MS,
3
+ DEFAULT_CONCURRENCY,
4
+ clearRunSave,
5
+ runCodeTests,
6
+ type CodeTestReport,
7
+ type DocsPage,
8
+ type HarnessAgent,
9
+ type RunSave,
10
+ type TaskPhase,
11
+ type TaskResult,
12
+ type TaskUpdate,
13
+ } from '@mintlify/agent-harness';
14
+ import { Box, render, Text, useInput } from 'ink';
15
+ import path from 'node:path';
16
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
17
+
18
+ import { toggleScopeSelection, visibleScopeRows, type ScopeTreeRow } from './mintTestTree.js';
19
+
20
+ type Screen = 'resume' | 'harnesses' | 'scope' | 'running' | 'report' | 'error';
21
+
22
+ interface MintTestUiProps {
23
+ targetPath: string;
24
+ docsRoot: string;
25
+ pages: DocsPage[];
26
+ autoSelectedFiles: string[];
27
+ totalPages: number;
28
+ save: RunSave | null;
29
+ onFinish: (exitCode: number) => void;
30
+ }
31
+
32
+ interface StepDefinition {
33
+ id: 'harnesses' | 'scope' | 'check' | 'generate' | 'run' | 'report';
34
+ label: string;
35
+ }
36
+
37
+ const HARNESSES: Array<{ agent: HarnessAgent; model: string; label: string }> = [
38
+ { agent: 'claude', model: 'claude-opus-4-8', label: 'Claude Opus 4.8' },
39
+ { agent: 'claude', model: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' },
40
+ { agent: 'codex', model: 'claude-opus-4-8', label: 'Codex' },
41
+ ];
42
+
43
+ export function countReport(tasks: TaskResult[]): {
44
+ passed: number;
45
+ failed: number;
46
+ cancelled: number;
47
+ agentErrors: number;
48
+ } {
49
+ const counts = { passed: 0, failed: 0, cancelled: 0, agentErrors: 0 };
50
+ for (const task of tasks) {
51
+ if (task.status === 'agent_error') counts.agentErrors++;
52
+ else counts[task.status]++;
53
+ }
54
+ return counts;
55
+ }
56
+
57
+ const STEPS: StepDefinition[] = [
58
+ { id: 'harnesses', label: 'Harnesses' },
59
+ { id: 'scope', label: 'Scope' },
60
+ { id: 'check', label: 'Check testability' },
61
+ { id: 'generate', label: 'Generate tests' },
62
+ { id: 'run', label: 'Run tests' },
63
+ { id: 'report', label: 'Report' },
64
+ ];
65
+
66
+ function formatTokens(tokens: number): string {
67
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
68
+ if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
69
+ return String(tokens);
70
+ }
71
+
72
+ function padTokens(tokens: number): string {
73
+ return formatTokens(tokens).padStart(4);
74
+ }
75
+
76
+ export function formatDuration(durationMs: number): string {
77
+ const seconds = Math.max(0, Math.floor(durationMs / 1_000));
78
+ if (seconds < 60) return `${seconds}s`;
79
+ return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;
80
+ }
81
+
82
+ function padDuration(durationMs: number): string {
83
+ return formatDuration(durationMs).padStart(7);
84
+ }
85
+
86
+ function padCount(value: number, total: number): string {
87
+ return String(value).padStart(String(total).length);
88
+ }
89
+
90
+ function useTerminalSize(): { columns: number; rows: number } {
91
+ const [size, setSize] = useState(() => ({
92
+ columns: process.stdout.columns || 100,
93
+ rows: process.stdout.rows || 30,
94
+ }));
95
+
96
+ useEffect(() => {
97
+ const resize = () =>
98
+ setSize({ columns: process.stdout.columns || 100, rows: process.stdout.rows || 30 });
99
+ process.stdout.on('resize', resize);
100
+ return () => {
101
+ process.stdout.off('resize', resize);
102
+ };
103
+ }, []);
104
+
105
+ return size;
106
+ }
107
+
108
+ function stepState(
109
+ step: StepDefinition['id'],
110
+ screen: Screen,
111
+ activeRunStage: 'check' | 'generate' | 'run'
112
+ ): 'pending' | 'running' | 'complete' | 'error' {
113
+ const order = ['harnesses', 'scope', 'check', 'generate', 'run', 'report'];
114
+ const screenStep =
115
+ screen === 'resume'
116
+ ? 'harnesses'
117
+ : screen === 'running' || screen === 'error'
118
+ ? activeRunStage
119
+ : screen;
120
+ const current = order.indexOf(screenStep);
121
+ const index = order.indexOf(step);
122
+ if (screen === 'error' && index === current) return 'error';
123
+ if (index < current || screen === 'report') return 'complete';
124
+ if (index === current) return 'running';
125
+ return 'pending';
126
+ }
127
+
128
+ function StepGlyph({ state }: { state: ReturnType<typeof stepState> }) {
129
+ if (state === 'complete') return <Text color="green">✓</Text>;
130
+ if (state === 'running') return <Text color="cyan">●</Text>;
131
+ if (state === 'error') return <Text color="red">✗</Text>;
132
+ return <Text dimColor>○</Text>;
133
+ }
134
+
135
+ const RESUME_OPTIONS = ['Resume', 'Start fresh'] as const;
136
+ const EMPTY_SELECTION: ReadonlySet<string> = new Set();
137
+
138
+ function ResumeScreen({ save, cursor }: { save: RunSave; cursor: number }) {
139
+ return (
140
+ <Box flexDirection="column">
141
+ <Text bold>Resume previous run?</Text>
142
+ <Box flexDirection="column" marginTop={1}>
143
+ <Text>
144
+ Run <Text color="cyan">{save.runId}</Text>
145
+ </Text>
146
+ <Text dimColor>
147
+ {save.selectedFiles.length} {save.selectedFiles.length === 1 ? 'page' : 'pages'} ·{' '}
148
+ {save.agents.join(', ')}
149
+ </Text>
150
+ </Box>
151
+ <Box flexDirection="column" marginTop={1}>
152
+ {RESUME_OPTIONS.map((option, index) => (
153
+ <Text key={option} color={cursor === index ? 'cyan' : undefined}>
154
+ {cursor === index ? '›' : ' '} {option}
155
+ </Text>
156
+ ))}
157
+ </Box>
158
+ </Box>
159
+ );
160
+ }
161
+
162
+ function HarnessScreen({ cursor }: { cursor: number }) {
163
+ return (
164
+ <Box flexDirection="column">
165
+ <Text bold>Which harness should generate and run the tests?</Text>
166
+ <Text dimColor>The selected harness tests every page in the chosen scope.</Text>
167
+ <Box flexDirection="column" marginTop={1}>
168
+ {HARNESSES.map((harness, index) => (
169
+ <Text key={harness.label} color={cursor === index ? 'cyan' : undefined}>
170
+ {cursor === index ? '›' : ' '} {cursor === index ? '(•)' : '( )'} {harness.label}
171
+ </Text>
172
+ ))}
173
+ </Box>
174
+ </Box>
175
+ );
176
+ }
177
+
178
+ function ScopeScreen({
179
+ rows,
180
+ cursor,
181
+ height,
182
+ selectedCount,
183
+ selectedBlocks,
184
+ testablePages,
185
+ totalPages,
186
+ outputDirectory,
187
+ }: {
188
+ rows: ReturnType<typeof visibleScopeRows>;
189
+ cursor: number;
190
+ height: number;
191
+ selectedCount: number;
192
+ selectedBlocks: number;
193
+ testablePages: number;
194
+ totalPages: number;
195
+ outputDirectory: string;
196
+ }) {
197
+ const viewport = Math.max(4, height - 18);
198
+ const start = Math.max(0, Math.min(cursor - Math.floor(viewport / 2), rows.length - viewport));
199
+ const visible = rows.slice(start, start + viewport);
200
+
201
+ return (
202
+ <Box flexDirection="column">
203
+ <Box borderStyle="round" borderColor="gray" paddingX={1} flexDirection="column">
204
+ <Text>
205
+ <Text color="cyan" bold>
206
+ {testablePages}
207
+ </Text>{' '}
208
+ of {totalPages} total pages have untested code
209
+ </Text>
210
+ <Text>
211
+ <Text color="cyan" bold>
212
+ {selectedCount}
213
+ </Text>{' '}
214
+ {selectedCount === 1 ? 'page' : 'pages'} ·{' '}
215
+ <Text color="cyan" bold>
216
+ {selectedBlocks}
217
+ </Text>{' '}
218
+ {selectedBlocks === 1 ? 'code block' : 'code blocks'} will be tested
219
+ </Text>
220
+ <Text dimColor wrap="truncate-end">
221
+ Tests generated at {outputDirectory}
222
+ </Text>
223
+ </Box>
224
+ <Box marginTop={1} flexDirection="column">
225
+ <Text bold>Choose the docs to test</Text>
226
+ </Box>
227
+ <Text dimColor>Folders select or clear every testable page beneath them.</Text>
228
+ <Box flexDirection="column" marginTop={1}>
229
+ {visible.map((row, visibleIndex) => {
230
+ const index = start + visibleIndex;
231
+ const selection =
232
+ row.selection === 'all' ? '[x]' : row.selection === 'some' ? '[-]' : '[ ]';
233
+ const disclosure = row.kind === 'directory' ? (row.expanded ? '▾' : '▸') : ' ';
234
+ return (
235
+ <Text key={row.id} color={index === cursor ? 'cyan' : undefined}>
236
+ {' '.repeat(row.depth * 2)}
237
+ {index === cursor ? '›' : ' '} {selection} {disclosure} {row.label}{' '}
238
+ <Text dimColor>
239
+ {row.blockCount} {row.blockCount === 1 ? 'code block' : 'code blocks'}
240
+ </Text>
241
+ </Text>
242
+ );
243
+ })}
244
+ </Box>
245
+ </Box>
246
+ );
247
+ }
248
+
249
+ const PHASE_LABELS: Record<TaskPhase, string> = {
250
+ checking: 'validating code can be tested',
251
+ checked: 'code can be tested',
252
+ not_testable: 'no testable code',
253
+ generating: 'generating test code',
254
+ generated: 'test generated',
255
+ running: 'running tests',
256
+ passed: 'passed',
257
+ failed: 'tests failed',
258
+ agent_error: 'test could not be generated',
259
+ cancelled: 'cancelled',
260
+ };
261
+
262
+ const ACTIVE_PHASES = new Set<TaskPhase>(['checking', 'generating', 'running']);
263
+ const DONE_PHASES = new Set<TaskPhase>([
264
+ 'passed',
265
+ 'failed',
266
+ 'agent_error',
267
+ 'not_testable',
268
+ 'cancelled',
269
+ ]);
270
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
271
+
272
+ interface TaskRow extends TaskUpdate {
273
+ startedAt: number;
274
+ finishedAt?: number;
275
+ }
276
+
277
+ function fileStatus(
278
+ row: TaskRow | undefined,
279
+ spinner: string
280
+ ): { glyph: string; color?: string; dim?: boolean } {
281
+ if (row && ACTIVE_PHASES.has(row.phase)) return { glyph: spinner, color: 'green' };
282
+ if (row?.phase === 'not_testable') return { glyph: '✗', color: 'yellow' };
283
+ if (row?.phase === 'failed' || row?.phase === 'agent_error') return { glyph: '✗', color: 'red' };
284
+ return { glyph: '○', dim: true };
285
+ }
286
+
287
+ const SETTLED_PHASES = new Set<TaskPhase>([...DONE_PHASES, 'checked', 'generated']);
288
+
289
+ function directoryStatus(
290
+ files: string[],
291
+ phases: ReadonlyMap<string, TaskRow>,
292
+ spinner: string
293
+ ): { glyph: string; color?: string; dim?: boolean } {
294
+ const rows = files.map((file) => phases.get(file));
295
+ if (rows.some((row) => row && ACTIVE_PHASES.has(row.phase))) {
296
+ return { glyph: spinner, color: 'green' };
297
+ }
298
+ if (rows.every((row) => row && SETTLED_PHASES.has(row.phase))) {
299
+ if (rows.some((row) => row && ['failed', 'agent_error'].includes(row.phase))) {
300
+ return { glyph: '●', color: 'red' };
301
+ }
302
+ return rows.every((row) => row && DONE_PHASES.has(row.phase))
303
+ ? { glyph: '●', color: 'green' }
304
+ : { glyph: '●', dim: true };
305
+ }
306
+ return { glyph: '○', dim: true };
307
+ }
308
+
309
+ function ActivityScreen({
310
+ stage,
311
+ cancelling,
312
+ harness,
313
+ rows,
314
+ cursor,
315
+ phases,
316
+ height,
317
+ totalTasks,
318
+ spinnerFrame,
319
+ runStartedAt,
320
+ now,
321
+ }: {
322
+ stage: 'check' | 'generate' | 'run';
323
+ cancelling: boolean;
324
+ harness: string;
325
+ rows: ReturnType<typeof visibleScopeRows>;
326
+ cursor: number;
327
+ phases: ReadonlyMap<string, TaskRow>;
328
+ height: number;
329
+ totalTasks: number;
330
+ spinnerFrame: number;
331
+ runStartedAt: number;
332
+ now: number;
333
+ }) {
334
+ const spinner = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length] ?? '⠋';
335
+ const updates = [...phases.values()];
336
+ const message = cancelling
337
+ ? 'Cancelling agent and test processes'
338
+ : stage === 'check'
339
+ ? 'Checking which code blocks can be tested'
340
+ : stage === 'generate'
341
+ ? 'Agents are generating test projects'
342
+ : 'Running generated tests';
343
+ const done = updates.filter((update) => DONE_PHASES.has(update.phase)).length;
344
+ const notTestable = updates.filter((update) => update.phase === 'not_testable').length;
345
+ const created = updates.filter((update) =>
346
+ ['generated', 'running', 'passed', 'failed'].includes(update.phase)
347
+ ).length;
348
+ const tokens = updates.reduce((total, update) => total + update.tokens, 0);
349
+ const checked = updates.filter((update) => update.phase !== 'checking').length;
350
+ const progress =
351
+ stage === 'check'
352
+ ? `${padCount(checked, totalTasks)}/${totalTasks} pages checked`
353
+ : stage === 'generate'
354
+ ? `${padCount(created, totalTasks - notTestable)}/${totalTasks - notTestable} tests created`
355
+ : `${padCount(done - notTestable, totalTasks - notTestable)}/${totalTasks - notTestable} tasks finished`;
356
+ const viewport = Math.max(4, height - 13);
357
+ const start = Math.max(0, Math.min(cursor - Math.floor(viewport / 2), rows.length - viewport));
358
+ const visible = rows.slice(start, start + viewport);
359
+
360
+ return (
361
+ <Box flexDirection="column" flexGrow={1}>
362
+ <Box borderStyle="round" borderColor="gray" paddingX={1} flexDirection="column">
363
+ <Text bold>
364
+ {padDuration(now - runStartedAt)} <Text dimColor>{padTokens(tokens)} tokens</Text>
365
+ </Text>
366
+ <Text color="green">
367
+ <Text>{spinner}</Text> {message}
368
+ </Text>
369
+ <Text dimColor>Harness: {harness}</Text>
370
+ <Text dimColor>{progress}</Text>
371
+ </Box>
372
+ <Box
373
+ borderStyle="round"
374
+ borderColor="gray"
375
+ paddingX={1}
376
+ marginTop={1}
377
+ flexDirection="column"
378
+ flexGrow={1}
379
+ >
380
+ {visible.map((row, visibleIndex) => {
381
+ const index = start + visibleIndex;
382
+ const disclosure = row.kind === 'directory' ? (row.expanded ? '▾' : '▸') : ' ';
383
+ const status =
384
+ row.kind === 'directory'
385
+ ? directoryStatus(row.selectableFiles, phases, spinner)
386
+ : fileStatus(row.file ? phases.get(row.file) : undefined, spinner);
387
+ const task = row.file ? phases.get(row.file) : undefined;
388
+ const settled =
389
+ row.kind === 'directory'
390
+ ? row.selectableFiles.filter((file) => {
391
+ const phase = phases.get(file)?.phase;
392
+ return phase !== undefined && SETTLED_PHASES.has(phase);
393
+ }).length
394
+ : 0;
395
+ return (
396
+ <Text key={row.id} color={index === cursor ? 'cyan' : undefined} wrap="truncate-end">
397
+ {' '.repeat(row.depth * 2)}
398
+ {index === cursor ? '›' : ' '}{' '}
399
+ <Text color={status.color} dimColor={status.dim}>
400
+ {status.glyph}
401
+ </Text>{' '}
402
+ {disclosure} {row.label}
403
+ {row.kind === 'directory' ? (
404
+ <Text dimColor>
405
+ {' '}
406
+ {padCount(settled, row.selectableFiles.length)}/{row.selectableFiles.length}{' '}
407
+ complete
408
+ </Text>
409
+ ) : null}
410
+ {task ? (
411
+ <Text dimColor> {padDuration((task.finishedAt ?? now) - task.startedAt)}</Text>
412
+ ) : null}
413
+ </Text>
414
+ );
415
+ })}
416
+ </Box>
417
+ </Box>
418
+ );
419
+ }
420
+
421
+ function reportFileStatus(row: TaskRow | undefined): {
422
+ glyph: string;
423
+ color?: string;
424
+ dim?: boolean;
425
+ } {
426
+ if (row?.phase === 'passed') return { glyph: '✓', color: 'green' };
427
+ if (row?.phase === 'failed' || row?.phase === 'agent_error') return { glyph: '✗', color: 'red' };
428
+ if (row?.phase === 'not_testable') return { glyph: '✗', color: 'yellow' };
429
+ if (row?.phase === 'cancelled') return { glyph: '●', color: 'yellow' };
430
+ return { glyph: '○', dim: true };
431
+ }
432
+
433
+ function reportDirectoryStatus(
434
+ files: string[],
435
+ phases: ReadonlyMap<string, TaskRow>
436
+ ): { glyph: string; color?: string; dim?: boolean } {
437
+ const rows = files.map((file) => phases.get(file));
438
+ if (rows.some((row) => row?.phase === 'failed' || row?.phase === 'agent_error')) {
439
+ return { glyph: '✗', color: 'red' };
440
+ }
441
+ if (rows.length > 0 && rows.every((row) => row?.phase === 'passed')) {
442
+ return { glyph: '✓', color: 'green' };
443
+ }
444
+ return { glyph: '●', color: 'yellow' };
445
+ }
446
+
447
+ function ReportScreen({
448
+ report,
449
+ rows,
450
+ cursor,
451
+ phases,
452
+ height,
453
+ }: {
454
+ report: CodeTestReport;
455
+ rows: ReturnType<typeof visibleScopeRows>;
456
+ cursor: number;
457
+ phases: ReadonlyMap<string, TaskRow>;
458
+ height: number;
459
+ }) {
460
+ const statusColor =
461
+ report.status === 'passed'
462
+ ? 'green'
463
+ : report.status === 'completed'
464
+ ? 'cyan'
465
+ : report.status === 'cancelled'
466
+ ? 'yellow'
467
+ : 'red';
468
+ const heading =
469
+ report.status === 'passed'
470
+ ? 'All runnable tests passed'
471
+ : report.status === 'completed'
472
+ ? 'Run completed'
473
+ : `Run ${report.status}`;
474
+ const viewport = Math.max(4, height - 12);
475
+ const start = Math.max(0, Math.min(cursor - Math.floor(viewport / 2), rows.length - viewport));
476
+ const visible = rows.slice(start, start + viewport);
477
+
478
+ return (
479
+ <Box flexDirection="column" flexGrow={1}>
480
+ <Box borderStyle="round" borderColor="gray" paddingX={1} flexDirection="column">
481
+ <Text bold color={statusColor}>
482
+ {heading}
483
+ </Text>
484
+ <Text>
485
+ Finished in <Text bold>{formatDuration(report.durationMs)}</Text>
486
+ </Text>
487
+ <Text dimColor wrap="truncate-end">
488
+ Report at {report.reportPath}
489
+ </Text>
490
+ </Box>
491
+ <Box
492
+ borderStyle="round"
493
+ borderColor="gray"
494
+ paddingX={1}
495
+ marginTop={1}
496
+ flexDirection="column"
497
+ flexGrow={1}
498
+ >
499
+ {visible.map((row, visibleIndex) => {
500
+ const index = start + visibleIndex;
501
+ const disclosure = row.kind === 'directory' ? (row.expanded ? '▾' : '▸') : ' ';
502
+ const status =
503
+ row.kind === 'directory'
504
+ ? reportDirectoryStatus(row.selectableFiles, phases)
505
+ : reportFileStatus(row.file ? phases.get(row.file) : undefined);
506
+ const task = row.file ? phases.get(row.file) : undefined;
507
+ const passed =
508
+ row.kind === 'directory'
509
+ ? row.selectableFiles.filter((file) => phases.get(file)?.phase === 'passed').length
510
+ : 0;
511
+ return (
512
+ <Text key={row.id} color={index === cursor ? 'cyan' : undefined} wrap="truncate-end">
513
+ {' '.repeat(row.depth * 2)}
514
+ {index === cursor ? '›' : ' '}{' '}
515
+ <Text color={status.color} dimColor={status.dim}>
516
+ {status.glyph}
517
+ </Text>{' '}
518
+ {disclosure} {row.label}
519
+ {row.kind === 'directory' ? (
520
+ <Text dimColor>
521
+ {' '}
522
+ {padCount(passed, row.selectableFiles.length)}/{row.selectableFiles.length} passed
523
+ </Text>
524
+ ) : task ? (
525
+ <Text dimColor>
526
+ {' '}
527
+ {PHASE_LABELS[task.phase]}
528
+ {task.finishedAt ? ` · ${formatDuration(task.finishedAt - task.startedAt)}` : ''}
529
+ </Text>
530
+ ) : null}
531
+ </Text>
532
+ );
533
+ })}
534
+ </Box>
535
+ </Box>
536
+ );
537
+ }
538
+
539
+ function footerForScreen(screen: Screen, running: boolean, cancelled: boolean): string {
540
+ if (running) return '↑/↓/j/k move · ←/→/h/l collapse/expand · Ctrl-C cancel';
541
+ if (screen === 'resume') return '↑/↓/j/k move · Enter confirm · Ctrl-C cancel';
542
+ if (screen === 'harnesses') return '↑/↓/j/k move · Enter continue · Ctrl-C cancel';
543
+ if (screen === 'scope') {
544
+ return '↑/↓/j/k move · ←/→/h/l collapse/expand · Space select · a all · Enter start';
545
+ }
546
+ if (screen === 'report') {
547
+ return cancelled
548
+ ? '↑/↓/j/k move · ←/→/h/l collapse/expand · Ctrl-C exit · save kept for resume'
549
+ : '↑/↓/j/k move · ←/→/h/l collapse/expand · Enter exit';
550
+ }
551
+ return 'Enter exit';
552
+ }
553
+
554
+ function MintTestApp({
555
+ targetPath,
556
+ docsRoot,
557
+ pages,
558
+ autoSelectedFiles,
559
+ totalPages,
560
+ save,
561
+ onFinish,
562
+ }: MintTestUiProps) {
563
+ const size = useTerminalSize();
564
+ const files = useMemo(() => pages.map((page) => page.file), [pages]);
565
+ const outputDirectory = path.join(docsRoot, 'tests', 'mint-test');
566
+ const [screen, setScreen] = useState<Screen>(save ? 'resume' : 'harnesses');
567
+ const [resumeCursor, setResumeCursor] = useState(0);
568
+ const [harnessIndex, setHarnessIndex] = useState(0);
569
+ const [runHarness, setRunHarness] = useState('');
570
+ const harness = HARNESSES[harnessIndex] ?? HARNESSES[0]!;
571
+ const [selectedFiles, setSelectedFiles] = useState<Set<string>>(() => new Set(autoSelectedFiles));
572
+ const [expandedDirectories, setExpandedDirectories] = useState<Set<string>>(new Set());
573
+ const [scopeCursor, setScopeCursor] = useState(0);
574
+ const [activeRunStage, setActiveRunStage] = useState<'check' | 'generate' | 'run'>('check');
575
+ const [cancelling, setCancelling] = useState(false);
576
+ const [ctrlCArmed, setCtrlCArmed] = useState(false);
577
+ const ctrlCTimer = useRef<NodeJS.Timeout | undefined>(undefined);
578
+ const [taskPhases, setTaskPhases] = useState<Map<string, TaskRow>>(new Map());
579
+ const [totalTasks, setTotalTasks] = useState(0);
580
+ const [runCursor, setRunCursor] = useState(0);
581
+ const [runExpanded, setRunExpanded] = useState<Set<string>>(new Set());
582
+ const [runFiles, setRunFiles] = useState<string[]>([]);
583
+ const [runStartedAt, setRunStartedAt] = useState(0);
584
+ const autoCollapsed = useRef<Set<string>>(new Set());
585
+ const manualCursor = useRef(false);
586
+ const [spinnerFrame, setSpinnerFrame] = useState(0);
587
+ const [now, setNow] = useState(() => Date.now());
588
+ const [report, setReport] = useState<CodeTestReport>();
589
+ const [error, setError] = useState<string>();
590
+ const abortController = useRef<AbortController | undefined>(undefined);
591
+ const finished = useRef(false);
592
+ const rows = useMemo(
593
+ () => visibleScopeRows(pages, expandedDirectories, selectedFiles),
594
+ [pages, expandedDirectories, selectedFiles]
595
+ );
596
+ const phasesByFile = useMemo(
597
+ () => new Map([...taskPhases.values()].map((row) => [row.file, row])),
598
+ [taskPhases]
599
+ );
600
+ const visibleRunFiles = useMemo(
601
+ () =>
602
+ runFiles.filter(
603
+ (file) => activeRunStage === 'check' || phasesByFile.get(file)?.phase !== 'not_testable'
604
+ ),
605
+ [runFiles, activeRunStage, phasesByFile]
606
+ );
607
+ const runRows = useMemo(() => {
608
+ const included = new Set(visibleRunFiles);
609
+ return visibleScopeRows(
610
+ pages.filter((page) => included.has(page.file)),
611
+ runExpanded,
612
+ EMPTY_SELECTION
613
+ );
614
+ }, [pages, visibleRunFiles, runExpanded]);
615
+
616
+ useEffect(() => {
617
+ if (screen !== 'running') return;
618
+ const timer = setInterval(() => setSpinnerFrame((frame) => frame + 1), 120);
619
+ const clock = setInterval(() => setNow(Date.now()), 1_000);
620
+ return () => {
621
+ clearInterval(timer);
622
+ clearInterval(clock);
623
+ };
624
+ }, [screen]);
625
+
626
+ useEffect(() => {
627
+ if (screen !== 'running') return;
628
+ const fileDone = (file: string) => {
629
+ const phase = phasesByFile.get(file)?.phase;
630
+ return phase !== undefined && DONE_PHASES.has(phase);
631
+ };
632
+ const directoryFiles = new Map<string, string[]>();
633
+ for (const file of visibleRunFiles) {
634
+ const parts = file.split('/');
635
+ let currentPath = '';
636
+ for (const part of parts.slice(0, -1)) {
637
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
638
+ const id = `dir:${currentPath}`;
639
+ directoryFiles.set(id, [...(directoryFiles.get(id) ?? []), file]);
640
+ }
641
+ }
642
+ const toCollapse = [...directoryFiles.entries()]
643
+ .filter(([id, files]) => !autoCollapsed.current.has(id) && files.every(fileDone))
644
+ .map(([id]) => id);
645
+ if (toCollapse.length > 0) {
646
+ for (const id of toCollapse) autoCollapsed.current.add(id);
647
+ setRunExpanded((current) => {
648
+ const next = new Set(current);
649
+ for (const id of toCollapse) next.delete(id);
650
+ return next;
651
+ });
652
+ }
653
+ const rowDone = (row: ScopeTreeRow) => {
654
+ const files = row.kind === 'directory' ? row.selectableFiles : row.file ? [row.file] : [];
655
+ return files.length > 0 && files.every(fileDone);
656
+ };
657
+ if (manualCursor.current) return;
658
+ const current = runRows[runCursor];
659
+ if (runRows.length > 0 && (!current || rowDone(current))) {
660
+ const nextIndex = runRows.findIndex((row) => !rowDone(row));
661
+ if (nextIndex !== -1 && nextIndex !== runCursor) setRunCursor(nextIndex);
662
+ }
663
+ }, [screen, phasesByFile, visibleRunFiles, runRows, runCursor]);
664
+
665
+ const finish = (exitCode: number) => {
666
+ if (finished.current) return;
667
+ finished.current = true;
668
+ onFinish(exitCode);
669
+ };
670
+
671
+ const startRun = ({
672
+ runId,
673
+ runAgents,
674
+ runModel,
675
+ runFiles: runFiles_,
676
+ }: {
677
+ runId: string | null;
678
+ runAgents: HarnessAgent[];
679
+ runModel: string;
680
+ runFiles: string[];
681
+ }) => {
682
+ if (abortController.current) return;
683
+ const controller = new AbortController();
684
+ abortController.current = controller;
685
+ setTotalTasks(runAgents.length * runFiles_.length);
686
+ setRunHarness(
687
+ HARNESSES.find((entry) => entry.agent === runAgents[0] && entry.model === runModel)?.label ??
688
+ `${runAgents.join(', ')} · ${runModel}`
689
+ );
690
+ setRunCursor(0);
691
+ autoCollapsed.current = new Set();
692
+ manualCursor.current = false;
693
+ setRunStartedAt(Date.now());
694
+ setNow(Date.now());
695
+ setRunFiles(runFiles_);
696
+ setRunExpanded(
697
+ new Set(
698
+ runFiles_.flatMap((file) => {
699
+ const parts = file.split('/').slice(0, -1);
700
+ return parts.map((_, index) => `dir:${parts.slice(0, index + 1).join('/')}`);
701
+ })
702
+ )
703
+ );
704
+ setScreen('running');
705
+ setActiveRunStage('check');
706
+ void runCodeTests({
707
+ path: targetPath,
708
+ agents: runAgents,
709
+ model: runModel,
710
+ selectedFiles: runFiles_,
711
+ outputDirectory,
712
+ runId,
713
+ concurrency: DEFAULT_CONCURRENCY,
714
+ commandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS,
715
+ signal: controller.signal,
716
+ onTaskUpdate: (update) => {
717
+ const phaseStage =
718
+ update.phase === 'running'
719
+ ? 'run'
720
+ : update.phase === 'generating' || update.phase === 'generated'
721
+ ? 'generate'
722
+ : null;
723
+ if (phaseStage) {
724
+ setActiveRunStage((stage) => {
725
+ const order = ['check', 'generate', 'run'] as const;
726
+ return order.indexOf(phaseStage) > order.indexOf(stage) ? phaseStage : stage;
727
+ });
728
+ }
729
+ const now = Date.now();
730
+ setTaskPhases((current) => {
731
+ const previous = current.get(update.id);
732
+ return new Map(current).set(update.id, {
733
+ ...update,
734
+ startedAt: previous?.startedAt ?? now,
735
+ ...(ACTIVE_PHASES.has(update.phase) ? {} : { finishedAt: now }),
736
+ });
737
+ });
738
+ },
739
+ })
740
+ .then((nextReport) => {
741
+ setReport(nextReport);
742
+ const failedFiles = nextReport.tasks
743
+ .filter((task) => task.status === 'failed' || task.status === 'agent_error')
744
+ .map((task) => task.file);
745
+ setRunExpanded(
746
+ new Set(
747
+ failedFiles.flatMap((file) => {
748
+ const parts = file.split('/').slice(0, -1);
749
+ return parts.map((_, index) => `dir:${parts.slice(0, index + 1).join('/')}`);
750
+ })
751
+ )
752
+ );
753
+ setRunCursor(0);
754
+ setScreen('report');
755
+ })
756
+ .catch((runError: unknown) => {
757
+ if (controller.signal.aborted) {
758
+ finish(130);
759
+ return;
760
+ }
761
+ setError(runError instanceof Error ? runError.message : String(runError));
762
+ setScreen('error');
763
+ });
764
+ };
765
+
766
+ useInput((input, key) => {
767
+ if (key.ctrl && input === 'c') {
768
+ if (ctrlCArmed) {
769
+ if (abortController.current) {
770
+ abortController.current.abort();
771
+ setCancelling(true);
772
+ }
773
+ finish(130);
774
+ } else {
775
+ setCtrlCArmed(true);
776
+ if (ctrlCTimer.current) clearTimeout(ctrlCTimer.current);
777
+ ctrlCTimer.current = setTimeout(() => setCtrlCArmed(false), 2_000);
778
+ }
779
+ return;
780
+ }
781
+
782
+ if (screen === 'running' || screen === 'report') {
783
+ const collapse = key.leftArrow || input === 'h';
784
+ const expand = key.rightArrow || input === 'l';
785
+ if (key.upArrow || input === 'k') {
786
+ manualCursor.current = true;
787
+ setRunCursor((current) => Math.max(0, current - 1));
788
+ } else if (key.downArrow || input === 'j') {
789
+ manualCursor.current = true;
790
+ setRunCursor((current) => Math.max(0, Math.min(runRows.length - 1, current + 1)));
791
+ } else if (collapse || expand) {
792
+ manualCursor.current = true;
793
+ const row = runRows[runCursor];
794
+ if (row?.kind !== 'directory') return;
795
+ setRunExpanded((current) => {
796
+ const next = new Set(current);
797
+ if (collapse) next.delete(row.id);
798
+ else next.add(row.id);
799
+ return next;
800
+ });
801
+ } else if (screen === 'report' && key.return && report) {
802
+ finish(report.status === 'passed' ? 0 : report.status === 'cancelled' ? 130 : 1);
803
+ }
804
+ return;
805
+ }
806
+
807
+ if (screen === 'resume' && save) {
808
+ if (key.upArrow || input === 'k' || key.downArrow || input === 'j') {
809
+ setResumeCursor((current) => (current + 1) % RESUME_OPTIONS.length);
810
+ } else if (key.return) {
811
+ if (resumeCursor === 0) {
812
+ setSelectedFiles(new Set(save.selectedFiles));
813
+ startRun({
814
+ runId: save.runId,
815
+ runAgents: save.agents,
816
+ runModel: save.model,
817
+ runFiles: save.selectedFiles,
818
+ });
819
+ } else {
820
+ void clearRunSave(outputDirectory);
821
+ setScreen('harnesses');
822
+ }
823
+ }
824
+ return;
825
+ }
826
+
827
+ if (screen === 'harnesses') {
828
+ if (key.upArrow || input === 'k')
829
+ setHarnessIndex((current) => (current + HARNESSES.length - 1) % HARNESSES.length);
830
+ else if (key.downArrow || input === 'j')
831
+ setHarnessIndex((current) => (current + 1) % HARNESSES.length);
832
+ else if (key.return) setScreen('scope');
833
+ return;
834
+ }
835
+
836
+ if (screen === 'scope') {
837
+ const collapse = key.leftArrow || input === 'h';
838
+ const expand = key.rightArrow || input === 'l';
839
+ if (key.upArrow || input === 'k') setScopeCursor((current) => Math.max(0, current - 1));
840
+ else if (key.downArrow || input === 'j')
841
+ setScopeCursor((current) => Math.max(0, Math.min(rows.length - 1, current + 1)));
842
+ else if (collapse || expand) {
843
+ const row = rows[scopeCursor];
844
+ if (row?.kind !== 'directory') return;
845
+ setExpandedDirectories((current) => {
846
+ const next = new Set(current);
847
+ if (collapse) next.delete(row.id);
848
+ else next.add(row.id);
849
+ return next;
850
+ });
851
+ } else if (input === ' ') {
852
+ const row = rows[scopeCursor];
853
+ if (row) setSelectedFiles((current) => toggleScopeSelection(current, row));
854
+ } else if (input.toLowerCase() === 'a') {
855
+ setSelectedFiles((current) => (current.size === files.length ? new Set() : new Set(files)));
856
+ } else if (key.return && (selectedFiles.size > 0 || files.length === 0)) {
857
+ startRun({
858
+ runId: null,
859
+ runAgents: [harness.agent],
860
+ runModel: harness.model,
861
+ runFiles: [...selectedFiles],
862
+ });
863
+ }
864
+ return;
865
+ }
866
+
867
+ if (screen === 'error' && key.return) {
868
+ finish(1);
869
+ }
870
+ });
871
+
872
+ const leftWidth = size.columns >= 90 ? 28 : 24;
873
+ const viewHeight = size.rows - 1;
874
+ const running = screen === 'running';
875
+ const fullBleed = running || (screen === 'report' && report !== undefined);
876
+ let content: React.ReactNode;
877
+ if (screen === 'resume' && save) content = <ResumeScreen save={save} cursor={resumeCursor} />;
878
+ else if (screen === 'harnesses') {
879
+ content = <HarnessScreen cursor={harnessIndex} />;
880
+ } else if (screen === 'scope') {
881
+ content = (
882
+ <ScopeScreen
883
+ rows={rows}
884
+ cursor={scopeCursor}
885
+ height={viewHeight}
886
+ selectedCount={selectedFiles.size}
887
+ selectedBlocks={pages.reduce(
888
+ (total, page) => total + (selectedFiles.has(page.file) ? page.blockCount : 0),
889
+ 0
890
+ )}
891
+ testablePages={pages.length}
892
+ totalPages={totalPages}
893
+ outputDirectory={outputDirectory}
894
+ />
895
+ );
896
+ } else if (screen === 'report' && report) {
897
+ content = (
898
+ <ReportScreen
899
+ report={report}
900
+ rows={runRows}
901
+ cursor={runCursor}
902
+ phases={phasesByFile}
903
+ height={viewHeight}
904
+ />
905
+ );
906
+ } else if (screen === 'error') {
907
+ content = (
908
+ <Box flexDirection="column">
909
+ <Text color="red" bold>
910
+ Mint test stopped
911
+ </Text>
912
+ <Text>{error ?? 'Unknown error'}</Text>
913
+ </Box>
914
+ );
915
+ } else {
916
+ content = (
917
+ <ActivityScreen
918
+ stage={activeRunStage}
919
+ cancelling={cancelling}
920
+ harness={runHarness}
921
+ rows={runRows}
922
+ cursor={runCursor}
923
+ phases={phasesByFile}
924
+ height={viewHeight}
925
+ totalTasks={totalTasks}
926
+ spinnerFrame={spinnerFrame}
927
+ runStartedAt={runStartedAt}
928
+ now={now}
929
+ />
930
+ );
931
+ }
932
+
933
+ return (
934
+ <Box flexDirection="column" width={size.columns} height={viewHeight} overflow="hidden">
935
+ <Box flexGrow={1} gap={1} overflow="hidden">
936
+ <Box
937
+ width={leftWidth}
938
+ flexShrink={0}
939
+ borderStyle="round"
940
+ borderColor="gray"
941
+ paddingX={1}
942
+ flexDirection="column"
943
+ >
944
+ <Text bold>Steps</Text>
945
+ {STEPS.map((step) => {
946
+ const state = stepState(step.id, screen, activeRunStage);
947
+ return (
948
+ <Text key={step.id} color={state === 'running' ? 'cyan' : undefined}>
949
+ <StepGlyph state={state} /> {step.label}
950
+ </Text>
951
+ );
952
+ })}
953
+ </Box>
954
+ <Box
955
+ flexGrow={1}
956
+ borderStyle={fullBleed ? undefined : 'round'}
957
+ borderColor="gray"
958
+ paddingX={fullBleed ? 0 : 1}
959
+ flexDirection="column"
960
+ overflow="hidden"
961
+ >
962
+ {content}
963
+ </Box>
964
+ </Box>
965
+ <Text dimColor>
966
+ {ctrlCArmed
967
+ ? 'Ctrl-C again to exit'
968
+ : footerForScreen(screen, running, report?.status === 'cancelled')}
969
+ </Text>
970
+ </Box>
971
+ );
972
+ }
973
+
974
+ export async function runMintTestUi(options: Omit<MintTestUiProps, 'onFinish'>): Promise<number> {
975
+ process.stdout.write('\u001B[?1049h\u001B[?25l');
976
+ let instance: ReturnType<typeof render> | undefined;
977
+ try {
978
+ return await new Promise<number>((resolve) => {
979
+ instance = render(<MintTestApp {...options} onFinish={resolve} />, {
980
+ exitOnCtrlC: false,
981
+ patchConsole: false,
982
+ });
983
+ });
984
+ } finally {
985
+ instance?.unmount();
986
+ process.stdout.write('\u001B[?25h\u001B[?1049l');
987
+ }
988
+ }