@mintlify/cli 4.0.1465 → 4.0.1467

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.
Files changed (40) hide show
  1. package/__test__/mintTestFilePreview.test.ts +35 -0
  2. package/__test__/mintTestOutput.test.ts +194 -0
  3. package/__test__/runHistory.test.ts +164 -0
  4. package/bin/agent-harness/agentPreflight.js +24 -0
  5. package/bin/agent-harness/buildTaskPrompt.js +17 -7
  6. package/bin/agent-harness/countReport.js +10 -0
  7. package/bin/agent-harness/executeCodeBlocks.js +53 -10
  8. package/bin/agent-harness/generateTestCode.js +58 -43
  9. package/bin/agent-harness/index.js +14 -5
  10. package/bin/agent-harness/manifest.js +113 -0
  11. package/bin/agent-harness/runHistory.js +252 -0
  12. package/bin/agent-harness/setupFolders.js +20 -6
  13. package/bin/agent-harness/tasks/checkTestability.js +7 -5
  14. package/bin/agent-harness/types.js +33 -2
  15. package/bin/constants.js +3 -3
  16. package/bin/mintTest.js +31 -9
  17. package/bin/mintTestFilePreview.js +39 -0
  18. package/bin/mintTestText.js +31 -0
  19. package/bin/mintTestUi.js +295 -105
  20. package/bin/status.js +26 -0
  21. package/bin/tsconfig.build.tsbuildinfo +1 -1
  22. package/package.json +7 -7
  23. package/src/agent-harness/MINT_TEST_SYSTEM_DESIGN.md +15 -9
  24. package/src/agent-harness/agentPreflight.ts +13 -0
  25. package/src/agent-harness/buildTaskPrompt.ts +18 -7
  26. package/src/agent-harness/countReport.ts +17 -0
  27. package/src/agent-harness/executeCodeBlocks.ts +67 -1
  28. package/src/agent-harness/generateTestCode.ts +81 -51
  29. package/src/agent-harness/index.ts +20 -5
  30. package/src/agent-harness/manifest.ts +116 -0
  31. package/src/agent-harness/runHistory.ts +250 -0
  32. package/src/agent-harness/setupFolders.ts +19 -5
  33. package/src/agent-harness/tasks/checkTestability.ts +15 -6
  34. package/src/agent-harness/types.ts +81 -5
  35. package/src/constants.ts +6 -2
  36. package/src/mintTest.tsx +34 -8
  37. package/src/mintTestFilePreview.ts +32 -0
  38. package/src/mintTestText.ts +35 -0
  39. package/src/mintTestUi.tsx +586 -224
  40. package/src/status.tsx +24 -0
@@ -12,20 +12,21 @@ import {
12
12
  type HarnessAgent,
13
13
  type RunSave,
14
14
  type TaskPhase,
15
- type TaskResult,
16
15
  type TaskUpdate,
17
16
  } from './agent-harness/index.js';
17
+ import { readFilePreview } from './mintTestFilePreview.js';
18
+ import { firstLine, footerSegments, taskProblemLabel } from './mintTestText.js';
18
19
  import { toggleScopeSelection, visibleScopeRows, type ScopeTreeRow } from './mintTestTree.js';
19
20
 
20
- type Screen = 'resume' | 'harnesses' | 'scope' | 'running' | 'report' | 'error';
21
+ type Screen = 'resume' | 'existing' | 'harnesses' | 'scope' | 'running' | 'report' | 'error';
21
22
 
22
23
  interface MintTestUiProps {
23
24
  targetPath: string;
24
25
  docsRoot: string;
25
26
  pages: DocsPage[];
26
27
  autoSelectedFiles: string[];
27
- totalPages: number;
28
28
  save: RunSave | null;
29
+ lastReport: CodeTestReport | null;
29
30
  onFinish: (exitCode: number) => void;
30
31
  }
31
32
 
@@ -40,20 +41,6 @@ const HARNESSES: Array<{ agent: HarnessAgent; model: string; label: string }> =
40
41
  { agent: 'codex', model: 'claude-opus-4-8', label: 'Codex' },
41
42
  ];
42
43
 
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
44
  const STEPS: StepDefinition[] = [
58
45
  { id: 'harnesses', label: 'Harnesses' },
59
46
  { id: 'scope', label: 'Scope' },
@@ -112,7 +99,7 @@ function stepState(
112
99
  ): 'pending' | 'running' | 'complete' | 'error' {
113
100
  const order = ['harnesses', 'scope', 'check', 'generate', 'run', 'report'];
114
101
  const screenStep =
115
- screen === 'resume'
102
+ screen === 'resume' || screen === 'existing'
116
103
  ? 'harnesses'
117
104
  : screen === 'running' || screen === 'error'
118
105
  ? activeRunStage
@@ -133,6 +120,11 @@ function StepGlyph({ state }: { state: ReturnType<typeof stepState> }) {
133
120
  }
134
121
 
135
122
  const RESUME_OPTIONS = ['Resume', 'Start fresh'] as const;
123
+ const EXISTING_RUN_OPTIONS = [
124
+ 'Update tests',
125
+ 'Review last test run',
126
+ 'Start a brand new test',
127
+ ] as const;
136
128
  const EMPTY_SELECTION: ReadonlySet<string> = new Set();
137
129
 
138
130
  function ResumeScreen({ save, cursor }: { save: RunSave; cursor: number }) {
@@ -159,11 +151,47 @@ function ResumeScreen({ save, cursor }: { save: RunSave; cursor: number }) {
159
151
  );
160
152
  }
161
153
 
154
+ function ExistingRunScreen({ report, cursor }: { report: CodeTestReport; cursor: number }) {
155
+ return (
156
+ <Box flexDirection="column">
157
+ <Text bold>Mint test has already run in this directory.</Text>
158
+ <Box flexDirection="column" marginTop={1}>
159
+ <Text>
160
+ Last run <Text color="cyan">{report.runId}</Text> ·{' '}
161
+ <Text
162
+ color={
163
+ report.status === 'passed'
164
+ ? 'green'
165
+ : report.status === 'completed'
166
+ ? 'cyan'
167
+ : report.status === 'cancelled'
168
+ ? 'yellow'
169
+ : 'red'
170
+ }
171
+ >
172
+ {report.status}
173
+ </Text>
174
+ </Text>
175
+ <Text dimColor>
176
+ {report.selectedFiles.length} {report.selectedFiles.length === 1 ? 'page' : 'pages'} ·{' '}
177
+ {report.model} · {new Date(report.completedAt).toLocaleString()}
178
+ </Text>
179
+ </Box>
180
+ <Box flexDirection="column" marginTop={1}>
181
+ {EXISTING_RUN_OPTIONS.map((option, index) => (
182
+ <Text key={option} color={cursor === index ? 'cyan' : undefined}>
183
+ {cursor === index ? '›' : ' '} {option}
184
+ </Text>
185
+ ))}
186
+ </Box>
187
+ </Box>
188
+ );
189
+ }
190
+
162
191
  function HarnessScreen({ cursor }: { cursor: number }) {
163
192
  return (
164
193
  <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>
194
+ <Text bold>Select coding harness?</Text>
167
195
  <Box flexDirection="column" marginTop={1}>
168
196
  {HARNESSES.map((harness, index) => (
169
197
  <Text key={harness.label} color={cursor === index ? 'cyan' : undefined}>
@@ -179,20 +207,10 @@ function ScopeScreen({
179
207
  rows,
180
208
  cursor,
181
209
  height,
182
- selectedCount,
183
- selectedBlocks,
184
- testablePages,
185
- totalPages,
186
- outputDirectory,
187
210
  }: {
188
211
  rows: ReturnType<typeof visibleScopeRows>;
189
212
  cursor: number;
190
213
  height: number;
191
- selectedCount: number;
192
- selectedBlocks: number;
193
- testablePages: number;
194
- totalPages: number;
195
- outputDirectory: string;
196
214
  }) {
197
215
  const viewport = Math.max(4, height - 18);
198
216
  const start = Math.max(0, Math.min(cursor - Math.floor(viewport / 2), rows.length - viewport));
@@ -200,30 +218,14 @@ function ScopeScreen({
200
218
 
201
219
  return (
202
220
  <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}
221
+ <Box flexDirection="column">
222
+ <Box marginTop={1} marginRight={2} flexDirection="column">
223
+ <Text bold>Select pages to test</Text>
224
+ </Box>
225
+ <Text bold color="green">
226
+ enter to confirm
222
227
  </Text>
223
228
  </Box>
224
- <Box marginTop={1} flexDirection="column">
225
- <Text bold>Choose the docs to test</Text>
226
- </Box>
227
229
  <Text dimColor>Folders select or clear every testable page beneath them.</Text>
228
230
  <Box flexDirection="column" marginTop={1}>
229
231
  {visible.map((row, visibleIndex) => {
@@ -246,19 +248,6 @@ function ScopeScreen({
246
248
  );
247
249
  }
248
250
 
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
251
  const ACTIVE_PHASES = new Set<TaskPhase>(['checking', 'generating', 'running']);
263
252
  const DONE_PHASES = new Set<TaskPhase>([
264
253
  'passed',
@@ -274,13 +263,224 @@ interface TaskRow extends TaskUpdate {
274
263
  finishedAt?: number;
275
264
  }
276
265
 
266
+ interface GeneratedPathRow {
267
+ id: string;
268
+ path: string;
269
+ previewable: boolean;
270
+ task: TaskRow;
271
+ }
272
+
273
+ const OUTPUT_PHASES = new Set<TaskPhase>([
274
+ 'generating',
275
+ 'generated',
276
+ 'running',
277
+ 'passed',
278
+ 'failed',
279
+ 'agent_error',
280
+ 'cancelled',
281
+ ]);
282
+ function generatedPathRows(tasks: Iterable<TaskRow>): GeneratedPathRow[] {
283
+ const rows: GeneratedPathRow[] = [];
284
+ for (const task of tasks) {
285
+ if (!OUTPUT_PHASES.has(task.phase)) continue;
286
+ if (task.generatedFiles.length === 0) {
287
+ rows.push({
288
+ id: `${task.id}:directory`,
289
+ path: task.directory,
290
+ previewable: false,
291
+ task,
292
+ });
293
+ continue;
294
+ }
295
+ for (const file of task.generatedFiles) {
296
+ rows.push({ id: `${task.id}:${file}`, path: file, previewable: true, task });
297
+ }
298
+ }
299
+ return rows.sort((left, right) => left.path.localeCompare(right.path));
300
+ }
301
+
302
+ type FilePreviewState =
303
+ | { status: 'empty' | 'loading' }
304
+ | { status: 'loaded'; lines: string[]; truncated: boolean }
305
+ | { status: 'error'; message: string };
306
+
307
+ function useFilePreview(row: GeneratedPathRow | undefined): FilePreviewState {
308
+ const [preview, setPreview] = useState<FilePreviewState>({ status: 'empty' });
309
+
310
+ useEffect(() => {
311
+ if (!row?.previewable) {
312
+ setPreview({ status: 'empty' });
313
+ return;
314
+ }
315
+ let active = true;
316
+ setPreview({ status: 'loading' });
317
+ void readFilePreview(row.path)
318
+ .then((result) => {
319
+ if (!active) return;
320
+ if (result.binary) {
321
+ setPreview({ status: 'error', message: 'Binary files cannot be previewed.' });
322
+ return;
323
+ }
324
+ setPreview({
325
+ status: 'loaded',
326
+ lines: result.text.split(/\r?\n/),
327
+ truncated: result.truncated,
328
+ });
329
+ })
330
+ .catch((error: unknown) => {
331
+ if (!active) return;
332
+ setPreview({
333
+ status: 'error',
334
+ message: error instanceof Error ? error.message : String(error),
335
+ });
336
+ });
337
+ return () => {
338
+ active = false;
339
+ };
340
+ }, [row?.path, row?.previewable]);
341
+
342
+ return preview;
343
+ }
344
+
345
+ function generatedPathStatus(
346
+ phase: TaskPhase,
347
+ spinner: string
348
+ ): { glyph: string; color?: string; dim?: boolean } {
349
+ if (phase === 'generating' || phase === 'running') return { glyph: spinner, color: 'green' };
350
+ if (phase === 'generated' || phase === 'passed') return { glyph: '✓', color: 'green' };
351
+ if (phase === 'failed' || phase === 'agent_error') return { glyph: '✗', color: 'red' };
352
+ if (phase === 'cancelled') return { glyph: '●', color: 'yellow' };
353
+ return { glyph: '○', dim: true };
354
+ }
355
+
356
+ function FilePreview({ row, height }: { row: GeneratedPathRow | undefined; height: number }) {
357
+ const preview = useFilePreview(row);
358
+ // Border (2), title, path, margin, and one row kept for the truncation notice.
359
+ const lineLimit = Math.max(1, height - 6);
360
+
361
+ return (
362
+ <Box
363
+ width="52%"
364
+ height={height}
365
+ borderStyle="round"
366
+ borderColor="gray"
367
+ paddingX={1}
368
+ flexDirection="column"
369
+ overflow="hidden"
370
+ >
371
+ <Text bold>File preview</Text>
372
+ {row ? <Text dimColor>{row.path}</Text> : <Text dimColor>Select a generated file.</Text>}
373
+ {!row?.previewable ? (
374
+ <Text dimColor>
375
+ {row?.task.phase === 'generating'
376
+ ? 'Files will appear here after generation finishes.'
377
+ : 'No generated file is available to preview.'}
378
+ </Text>
379
+ ) : preview.status === 'loading' ? (
380
+ <Text dimColor>Loading preview...</Text>
381
+ ) : preview.status === 'error' ? (
382
+ <Text color="yellow">{preview.message}</Text>
383
+ ) : preview.status === 'loaded' ? (
384
+ <Box flexDirection="column" marginTop={1}>
385
+ {preview.lines.slice(0, lineLimit).map((line, index) => (
386
+ <Text key={`${index}:${line}`} wrap="truncate-end">
387
+ <Text dimColor>{String(index + 1).padStart(3)} </Text>
388
+ {line || ' '}
389
+ </Text>
390
+ ))}
391
+ {preview.truncated || preview.lines.length > lineLimit ? (
392
+ <Text dimColor>Preview truncated</Text>
393
+ ) : null}
394
+ </Box>
395
+ ) : null}
396
+ </Box>
397
+ );
398
+ }
399
+
400
+ function GeneratedFilesView({
401
+ rows,
402
+ cursor,
403
+ height,
404
+ spinner,
405
+ relativeRoot,
406
+ emptyMessage = 'Waiting for generated files...',
407
+ }: {
408
+ rows: GeneratedPathRow[];
409
+ cursor: number;
410
+ height: number;
411
+ spinner: string;
412
+ relativeRoot: string;
413
+ emptyMessage?: string;
414
+ }) {
415
+ const safeCursor = Math.max(0, Math.min(cursor, rows.length - 1));
416
+ const viewport = Math.max(2, height - 3);
417
+ const start = Math.max(
418
+ 0,
419
+ Math.min(safeCursor - Math.floor(viewport / 2), rows.length - viewport)
420
+ );
421
+ const visible = rows.slice(start, start + viewport);
422
+ const selected = rows[safeCursor];
423
+ const showPreview = selected?.previewable === true;
424
+
425
+ return (
426
+ <Box flexDirection="row" gap={1} flexGrow={1} overflow="hidden">
427
+ <Box
428
+ width={showPreview ? '48%' : '100%'}
429
+ borderStyle="round"
430
+ borderColor="gray"
431
+ paddingX={1}
432
+ flexDirection="column"
433
+ overflow="hidden"
434
+ >
435
+ <Text bold>Generated files</Text>
436
+ {visible.length === 0 ? <Text dimColor>{emptyMessage}</Text> : null}
437
+ {visible.map((row, visibleIndex) => {
438
+ const index = start + visibleIndex;
439
+ const status = generatedPathStatus(row.task.phase, spinner);
440
+ const relativePath = path.relative(relativeRoot, row.path) || '.';
441
+ return (
442
+ <Text
443
+ key={row.id}
444
+ color={index === safeCursor ? 'cyan' : undefined}
445
+ wrap="truncate-end"
446
+ >
447
+ {index === safeCursor ? '›' : ' '}{' '}
448
+ <Text color={status.color} dimColor={status.dim}>
449
+ {status.glyph}
450
+ </Text>{' '}
451
+ {row.previewable
452
+ ? row.path
453
+ : row.task.phase === 'generating'
454
+ ? `Writing under ${relativePath}`
455
+ : `No generated file in ${relativePath}`}
456
+ {row.task.phase === 'failed' || row.task.phase === 'agent_error' ? (
457
+ <Text color="red">
458
+ {' '}
459
+ {taskProblemLabel(row.task.phase)}
460
+ {row.task.error ? <Text dimColor> · {firstLine(row.task.error)}</Text> : null}
461
+ </Text>
462
+ ) : null}
463
+ </Text>
464
+ );
465
+ })}
466
+ </Box>
467
+ {showPreview ? <FilePreview row={selected} height={height} /> : null}
468
+ </Box>
469
+ );
470
+ }
471
+
277
472
  function fileStatus(
278
473
  row: TaskRow | undefined,
279
474
  spinner: string
280
475
  ): { glyph: string; color?: string; dim?: boolean } {
281
476
  if (row && ACTIVE_PHASES.has(row.phase)) return { glyph: spinner, color: 'green' };
282
- if (row?.phase === 'not_testable') return { glyph: '✗', color: 'yellow' };
283
477
  if (row?.phase === 'failed' || row?.phase === 'agent_error') return { glyph: '✗', color: 'red' };
478
+ if (row?.phase === 'checked' || row?.phase === 'generated' || row?.phase === 'passed') {
479
+ return { glyph: '✓', color: 'green' };
480
+ }
481
+ if (row?.phase === 'not_testable' || row?.phase === 'cancelled') {
482
+ return { glyph: '●', color: 'yellow' };
483
+ }
284
484
  return { glyph: '○', dim: true };
285
485
  }
286
486
 
@@ -295,22 +495,35 @@ function directoryStatus(
295
495
  if (rows.some((row) => row && ACTIVE_PHASES.has(row.phase))) {
296
496
  return { glyph: spinner, color: 'green' };
297
497
  }
498
+ if (rows.some((row) => row?.phase === 'failed' || row?.phase === 'agent_error')) {
499
+ return { glyph: '✗', color: 'red' };
500
+ }
298
501
  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' };
502
+ if (rows.every((row) => row?.phase === 'not_testable' || row?.phase === 'cancelled')) {
503
+ return { glyph: '●', color: 'yellow' };
301
504
  }
302
- return rows.every((row) => row && DONE_PHASES.has(row.phase))
303
- ? { glyph: '●', color: 'green' }
304
- : { glyph: '●', dim: true };
505
+ return { glyph: '✓', color: 'green' };
305
506
  }
306
507
  return { glyph: '○', dim: true };
307
508
  }
308
509
 
510
+ function ActivityLegend({ spinner }: { spinner: string }) {
511
+ return (
512
+ <Text dimColor>
513
+ ○ waiting · <Text color="green">{spinner}</Text> active · <Text color="green">✓</Text>{' '}
514
+ complete · <Text color="red">✗</Text> failed · <Text color="yellow">●</Text> skipped /
515
+ cancelled
516
+ </Text>
517
+ );
518
+ }
519
+
309
520
  function ActivityScreen({
310
521
  stage,
311
522
  cancelling,
312
523
  harness,
524
+ docsRoot,
313
525
  rows,
526
+ generatedRows,
314
527
  cursor,
315
528
  phases,
316
529
  height,
@@ -322,7 +535,9 @@ function ActivityScreen({
322
535
  stage: 'check' | 'generate' | 'run';
323
536
  cancelling: boolean;
324
537
  harness: string;
538
+ docsRoot: string;
325
539
  rows: ReturnType<typeof visibleScopeRows>;
540
+ generatedRows: GeneratedPathRow[];
326
541
  cursor: number;
327
542
  phases: ReadonlyMap<string, TaskRow>;
328
543
  height: number;
@@ -368,93 +583,140 @@ function ActivityScreen({
368
583
  </Text>
369
584
  <Text dimColor>Harness: {harness}</Text>
370
585
  <Text dimColor>{progress}</Text>
586
+ {stage === 'check' || stage === 'generate' ? <ActivityLegend spinner={spinner} /> : null}
371
587
  </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
588
+ <Box marginTop={1} flexGrow={1} overflow="hidden">
589
+ {stage === 'check' ? (
590
+ <Box
591
+ borderStyle="round"
592
+ borderColor="gray"
593
+ paddingX={1}
594
+ flexDirection="column"
595
+ flexGrow={1}
596
+ >
597
+ {visible.map((row, visibleIndex) => {
598
+ const index = start + visibleIndex;
599
+ const disclosure = row.kind === 'directory' ? (row.expanded ? '▾' : '▸') : ' ';
600
+ const status =
601
+ row.kind === 'directory'
602
+ ? directoryStatus(row.selectableFiles, phases, spinner)
603
+ : fileStatus(row.file ? phases.get(row.file) : undefined, spinner);
604
+ const task = row.file ? phases.get(row.file) : undefined;
605
+ const settled =
606
+ row.kind === 'directory'
607
+ ? row.selectableFiles.filter((file) => {
608
+ const phase = phases.get(file)?.phase;
609
+ return phase !== undefined && SETTLED_PHASES.has(phase);
610
+ }).length
611
+ : 0;
612
+ return (
613
+ <Text
614
+ key={row.id}
615
+ color={index === cursor ? 'cyan' : undefined}
616
+ wrap="truncate-end"
617
+ >
618
+ {' '.repeat(row.depth * 2)}
619
+ {index === cursor ? '' : ' '}{' '}
620
+ <Text color={status.color} dimColor={status.dim}>
621
+ {status.glyph}
622
+ </Text>{' '}
623
+ {disclosure} {row.label}
624
+ {row.kind === 'directory' ? (
625
+ <Text dimColor>
626
+ {' '}
627
+ {padCount(settled, row.selectableFiles.length)}/{row.selectableFiles.length}{' '}
628
+ complete
629
+ </Text>
630
+ ) : null}
631
+ {task ? (
632
+ <Text dimColor> {padDuration((task.finishedAt ?? now) - task.startedAt)}</Text>
633
+ ) : null}
634
+ {task && (task.phase === 'failed' || task.phase === 'agent_error') ? (
635
+ <Text color="red">
636
+ {' '}
637
+ {taskProblemLabel(task.phase)}
638
+ {task.error ? <Text dimColor> · {firstLine(task.error)}</Text> : null}
639
+ </Text>
640
+ ) : null}
408
641
  </Text>
409
- ) : null}
410
- {task ? (
411
- <Text dimColor> {padDuration((task.finishedAt ?? now) - task.startedAt)}</Text>
412
- ) : null}
413
- </Text>
414
- );
415
- })}
642
+ );
643
+ })}
644
+ </Box>
645
+ ) : (
646
+ <GeneratedFilesView
647
+ rows={generatedRows}
648
+ cursor={cursor}
649
+ height={Math.max(4, height - 7)}
650
+ spinner={spinner}
651
+ relativeRoot={docsRoot}
652
+ />
653
+ )}
416
654
  </Box>
417
655
  </Box>
418
656
  );
419
657
  }
420
658
 
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 };
659
+ const MAX_PROBLEM_ROWS = 6;
660
+
661
+ function ProblemsView({ report }: { report: CodeTestReport }) {
662
+ const problems = report.tasks.filter(
663
+ (task): task is CodeTestReport['tasks'][number] & { status: 'failed' | 'agent_error' } =>
664
+ task.status === 'failed' || task.status === 'agent_error'
665
+ );
666
+ if (problems.length === 0) return null;
667
+ const hidden = problems.length - MAX_PROBLEM_ROWS;
668
+ return (
669
+ <Box
670
+ marginTop={1}
671
+ borderStyle="round"
672
+ borderColor="red"
673
+ paddingX={1}
674
+ flexDirection="column"
675
+ flexShrink={0}
676
+ >
677
+ <Text bold color="red">
678
+ {problems.length} {problems.length === 1 ? 'page needs' : 'pages need'} attention
679
+ </Text>
680
+ {problems.slice(0, MAX_PROBLEM_ROWS).map((task) => (
681
+ <Text key={task.id} wrap="truncate-end">
682
+ <Text color="red">✗</Text> {task.file}{' '}
683
+ <Text dimColor>{taskProblemLabel(task.status)}</Text>
684
+ {task.error ? <Text dimColor> · {firstLine(task.error)}</Text> : null}
685
+ </Text>
686
+ ))}
687
+ {hidden > 0 ? <Text dimColor>+{hidden} more in the report</Text> : null}
688
+ </Box>
689
+ );
431
690
  }
432
691
 
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' };
692
+ /**
693
+ * Rows the report header takes, including its border and the margin below it. The optional
694
+ * lines must be counted, or the screen is one row taller than the terminal and Ink drops the
695
+ * heading (it showed as "Finished in 1m 37s passed").
696
+ */
697
+ function reportHeaderHeight(report: CodeTestReport, rows: GeneratedPathRow[]): number {
698
+ const optionalLines =
699
+ (report.historyError ? 1 : 0) + (rows.some((row) => row.previewable) ? 1 : 0);
700
+ return 2 + 4 + optionalLines + 1;
701
+ }
702
+
703
+ function problemsViewHeight(report: CodeTestReport): number {
704
+ const problems = report.tasks.filter(
705
+ (task) => task.status === 'failed' || task.status === 'agent_error'
706
+ ).length;
707
+ if (problems === 0) return 0;
708
+ return Math.min(problems, MAX_PROBLEM_ROWS) + 4 + (problems > MAX_PROBLEM_ROWS ? 1 : 0);
445
709
  }
446
710
 
447
711
  function ReportScreen({
448
712
  report,
449
713
  rows,
450
714
  cursor,
451
- phases,
452
715
  height,
453
716
  }: {
454
717
  report: CodeTestReport;
455
- rows: ReturnType<typeof visibleScopeRows>;
718
+ rows: GeneratedPathRow[];
456
719
  cursor: number;
457
- phases: ReadonlyMap<string, TaskRow>;
458
720
  height: number;
459
721
  }) {
460
722
  const statusColor =
@@ -471,13 +733,15 @@ function ReportScreen({
471
733
  : report.status === 'completed'
472
734
  ? 'Run completed'
473
735
  : `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
736
  return (
479
737
  <Box flexDirection="column" flexGrow={1}>
480
- <Box borderStyle="round" borderColor="gray" paddingX={1} flexDirection="column">
738
+ <Box
739
+ borderStyle="round"
740
+ borderColor="gray"
741
+ paddingX={1}
742
+ flexDirection="column"
743
+ flexShrink={0}
744
+ >
481
745
  <Text bold color={statusColor}>
482
746
  {heading}
483
747
  </Text>
@@ -487,66 +751,73 @@ function ReportScreen({
487
751
  <Text dimColor wrap="truncate-end">
488
752
  Report at {report.reportPath}
489
753
  </Text>
754
+ <Text dimColor wrap="truncate-end">
755
+ Generated files at {report.outputDirectory}
756
+ </Text>
757
+ {report.historyError ? (
758
+ <Text color="yellow" wrap="truncate-end">
759
+ Warning: {report.historyError}
760
+ </Text>
761
+ ) : null}
762
+ {rows.some((row) => row.previewable) ? (
763
+ <Text>Select a file below to preview its contents.</Text>
764
+ ) : null}
490
765
  </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
- })}
766
+ <ProblemsView report={report} />
767
+ <Box marginTop={1} flexGrow={1} overflow="hidden">
768
+ <GeneratedFilesView
769
+ rows={rows}
770
+ cursor={cursor}
771
+ height={Math.max(
772
+ 4,
773
+ height - reportHeaderHeight(report, rows) - problemsViewHeight(report)
774
+ )}
775
+ spinner="●"
776
+ relativeRoot={report.docsRoot}
777
+ emptyMessage="No generated files."
778
+ />
534
779
  </Box>
535
780
  </Box>
536
781
  );
537
782
  }
538
783
 
539
- function footerForScreen(screen: Screen, running: boolean, cancelled: boolean): string {
540
- if (running) return '↑/↓/j/k move · ←/→/h/l collapse/expand · Ctrl-C cancel';
784
+ function FooterHints({ text }: { text: string }) {
785
+ return (
786
+ <>
787
+ {footerSegments(text).map((segment, index) => (
788
+ <Text key={`${index}:${segment.key}`}>
789
+ {index > 0 ? ' · ' : ''}
790
+ <Text bold>{segment.key}</Text>
791
+ {segment.action ? ` ${segment.action}` : ''}
792
+ </Text>
793
+ ))}
794
+ </>
795
+ );
796
+ }
797
+
798
+ function footerForScreen(
799
+ screen: Screen,
800
+ running: boolean,
801
+ cancelled: boolean,
802
+ activeRunStage: 'check' | 'generate' | 'run',
803
+ reviewingSavedRun: boolean
804
+ ): string {
805
+ if (running) {
806
+ return activeRunStage === 'check'
807
+ ? '↑/↓/j/k move · ←/→/h/l collapse/expand · Ctrl-C cancel'
808
+ : '↑/↓/j/k preview generated files · Ctrl-C cancel';
809
+ }
541
810
  if (screen === 'resume') return '↑/↓/j/k move · Enter confirm · Ctrl-C cancel';
811
+ if (screen === 'existing') return '↑/↓/j/k move · Enter confirm · Ctrl-C cancel';
542
812
  if (screen === 'harnesses') return '↑/↓/j/k move · Enter continue · Ctrl-C cancel';
543
813
  if (screen === 'scope') {
544
814
  return '↑/↓/j/k move · ←/→/h/l collapse/expand · Space select · a all · Enter start';
545
815
  }
546
816
  if (screen === 'report') {
817
+ if (reviewingSavedRun) return '↑/↓/j/k preview generated files · Enter exit';
547
818
  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';
819
+ ? '↑/↓/j/k preview generated files · Ctrl-C exit · save kept for resume'
820
+ : '↑/↓/j/k preview generated files · Enter exit';
550
821
  }
551
822
  return 'Enter exit';
552
823
  }
@@ -556,15 +827,18 @@ function MintTestApp({
556
827
  docsRoot,
557
828
  pages,
558
829
  autoSelectedFiles,
559
- totalPages,
560
830
  save,
831
+ lastReport,
561
832
  onFinish,
562
833
  }: MintTestUiProps) {
563
834
  const size = useTerminalSize();
564
835
  const files = useMemo(() => pages.map((page) => page.file), [pages]);
565
836
  const outputDirectory = path.join(docsRoot, 'tests', 'mint-test');
566
- const [screen, setScreen] = useState<Screen>(save ? 'resume' : 'harnesses');
837
+ const [screen, setScreen] = useState<Screen>(
838
+ save ? 'resume' : lastReport ? 'existing' : 'harnesses'
839
+ );
567
840
  const [resumeCursor, setResumeCursor] = useState(0);
841
+ const [existingRunCursor, setExistingRunCursor] = useState(0);
568
842
  const [harnessIndex, setHarnessIndex] = useState(0);
569
843
  const [runHarness, setRunHarness] = useState('');
570
844
  const harness = HARNESSES[harnessIndex] ?? HARNESSES[0]!;
@@ -583,9 +857,11 @@ function MintTestApp({
583
857
  const [runStartedAt, setRunStartedAt] = useState(0);
584
858
  const autoCollapsed = useRef<Set<string>>(new Set());
585
859
  const manualCursor = useRef(false);
860
+ const previousRunStage = useRef(activeRunStage);
586
861
  const [spinnerFrame, setSpinnerFrame] = useState(0);
587
862
  const [now, setNow] = useState(() => Date.now());
588
863
  const [report, setReport] = useState<CodeTestReport>();
864
+ const [reviewingSavedRun, setReviewingSavedRun] = useState(false);
589
865
  const [error, setError] = useState<string>();
590
866
  const abortController = useRef<AbortController | undefined>(undefined);
591
867
  const finished = useRef(false);
@@ -612,6 +888,7 @@ function MintTestApp({
612
888
  EMPTY_SELECTION
613
889
  );
614
890
  }, [pages, visibleRunFiles, runExpanded]);
891
+ const generatedRows = useMemo(() => generatedPathRows(taskPhases.values()), [taskPhases]);
615
892
 
616
893
  useEffect(() => {
617
894
  if (screen !== 'running') return;
@@ -624,7 +901,7 @@ function MintTestApp({
624
901
  }, [screen]);
625
902
 
626
903
  useEffect(() => {
627
- if (screen !== 'running') return;
904
+ if (screen !== 'running' || activeRunStage !== 'check') return;
628
905
  const fileDone = (file: string) => {
629
906
  const phase = phasesByFile.get(file)?.phase;
630
907
  return phase !== undefined && DONE_PHASES.has(phase);
@@ -660,7 +937,14 @@ function MintTestApp({
660
937
  const nextIndex = runRows.findIndex((row) => !rowDone(row));
661
938
  if (nextIndex !== -1 && nextIndex !== runCursor) setRunCursor(nextIndex);
662
939
  }
663
- }, [screen, phasesByFile, visibleRunFiles, runRows, runCursor]);
940
+ }, [screen, activeRunStage, phasesByFile, visibleRunFiles, runRows, runCursor]);
941
+
942
+ useEffect(() => {
943
+ if (previousRunStage.current === activeRunStage) return;
944
+ previousRunStage.current = activeRunStage;
945
+ manualCursor.current = false;
946
+ setRunCursor(0);
947
+ }, [activeRunStage]);
664
948
 
665
949
  const finish = (exitCode: number) => {
666
950
  if (finished.current) return;
@@ -668,6 +952,40 @@ function MintTestApp({
668
952
  onFinish(exitCode);
669
953
  };
670
954
 
955
+ const reviewRun = (savedReport: CodeTestReport) => {
956
+ const startedAt = Date.parse(savedReport.startedAt);
957
+ const finishedAt = Date.parse(savedReport.completedAt);
958
+ setTaskPhases(
959
+ new Map(
960
+ savedReport.tasks.map((task) => [
961
+ task.id,
962
+ {
963
+ id: task.id,
964
+ agent: task.agent,
965
+ file: task.file,
966
+ directory: task.directory,
967
+ generatedFiles: task.generatedFiles,
968
+ phase: !task.testable && task.status === 'passed' ? 'not_testable' : task.status,
969
+ tokens: task.tokens,
970
+ startedAt: Number.isNaN(startedAt) ? 0 : startedAt,
971
+ finishedAt: Number.isNaN(finishedAt) ? undefined : finishedAt,
972
+ } satisfies TaskRow,
973
+ ])
974
+ )
975
+ );
976
+ setRunFiles(savedReport.selectedFiles);
977
+ setRunHarness(
978
+ HARNESSES.find(
979
+ (entry) => entry.agent === savedReport.agents[0] && entry.model === savedReport.model
980
+ )?.label ?? `${savedReport.agents.join(', ')} · ${savedReport.model}`
981
+ );
982
+ setReport(savedReport);
983
+ setReviewingSavedRun(true);
984
+ setActiveRunStage('run');
985
+ setRunCursor(0);
986
+ setScreen('report');
987
+ };
988
+
671
989
  const startRun = ({
672
990
  runId,
673
991
  runAgents,
@@ -682,6 +1000,10 @@ function MintTestApp({
682
1000
  if (abortController.current) return;
683
1001
  const controller = new AbortController();
684
1002
  abortController.current = controller;
1003
+ setTaskPhases(new Map());
1004
+ setReport(undefined);
1005
+ setReviewingSavedRun(false);
1006
+ setError(undefined);
685
1007
  setTotalTasks(runAgents.length * runFiles_.length);
686
1008
  setRunHarness(
687
1009
  HARNESSES.find((entry) => entry.agent === runAgents[0] && entry.model === runModel)?.label ??
@@ -782,13 +1104,15 @@ function MintTestApp({
782
1104
  if (screen === 'running' || screen === 'report') {
783
1105
  const collapse = key.leftArrow || input === 'h';
784
1106
  const expand = key.rightArrow || input === 'l';
1107
+ const showingGeneratedFiles = screen === 'report' || activeRunStage !== 'check';
1108
+ const rowCount = showingGeneratedFiles ? generatedRows.length : runRows.length;
785
1109
  if (key.upArrow || input === 'k') {
786
1110
  manualCursor.current = true;
787
1111
  setRunCursor((current) => Math.max(0, current - 1));
788
1112
  } else if (key.downArrow || input === 'j') {
789
1113
  manualCursor.current = true;
790
- setRunCursor((current) => Math.max(0, Math.min(runRows.length - 1, current + 1)));
791
- } else if (collapse || expand) {
1114
+ setRunCursor((current) => Math.max(0, Math.min(rowCount - 1, current + 1)));
1115
+ } else if ((collapse || expand) && !showingGeneratedFiles) {
792
1116
  manualCursor.current = true;
793
1117
  const row = runRows[runCursor];
794
1118
  if (row?.kind !== 'directory') return;
@@ -799,7 +1123,51 @@ function MintTestApp({
799
1123
  return next;
800
1124
  });
801
1125
  } else if (screen === 'report' && key.return && report) {
802
- finish(report.status === 'passed' ? 0 : report.status === 'cancelled' ? 130 : 1);
1126
+ finish(
1127
+ reviewingSavedRun
1128
+ ? 0
1129
+ : report.status === 'passed'
1130
+ ? 0
1131
+ : report.status === 'cancelled'
1132
+ ? 130
1133
+ : 1
1134
+ );
1135
+ }
1136
+ return;
1137
+ }
1138
+
1139
+ if (screen === 'existing' && lastReport) {
1140
+ if (key.upArrow || input === 'k') {
1141
+ setExistingRunCursor(
1142
+ (current) => (current + EXISTING_RUN_OPTIONS.length - 1) % EXISTING_RUN_OPTIONS.length
1143
+ );
1144
+ } else if (key.downArrow || input === 'j') {
1145
+ setExistingRunCursor((current) => (current + 1) % EXISTING_RUN_OPTIONS.length);
1146
+ } else if (key.return) {
1147
+ if (existingRunCursor === 0) {
1148
+ const knownFiles = new Set(files);
1149
+ const updateFiles = lastReport.selectedFiles.filter((file) => knownFiles.has(file));
1150
+ setSelectedFiles(new Set(updateFiles.length > 0 ? updateFiles : autoSelectedFiles));
1151
+ if (updateFiles.length === 0) {
1152
+ const previousHarness = HARNESSES.findIndex(
1153
+ (entry) => entry.agent === lastReport.agents[0] && entry.model === lastReport.model
1154
+ );
1155
+ if (previousHarness !== -1) setHarnessIndex(previousHarness);
1156
+ setScreen('scope');
1157
+ } else {
1158
+ startRun({
1159
+ runId: null,
1160
+ runAgents: lastReport.agents,
1161
+ runModel: lastReport.model,
1162
+ runFiles: updateFiles,
1163
+ });
1164
+ }
1165
+ } else if (existingRunCursor === 1) {
1166
+ reviewRun(lastReport);
1167
+ } else {
1168
+ setSelectedFiles(new Set(autoSelectedFiles));
1169
+ setScreen('harnesses');
1170
+ }
803
1171
  }
804
1172
  return;
805
1173
  }
@@ -875,33 +1243,15 @@ function MintTestApp({
875
1243
  const fullBleed = running || (screen === 'report' && report !== undefined);
876
1244
  let content: React.ReactNode;
877
1245
  if (screen === 'resume' && save) content = <ResumeScreen save={save} cursor={resumeCursor} />;
878
- else if (screen === 'harnesses') {
1246
+ else if (screen === 'existing' && lastReport) {
1247
+ content = <ExistingRunScreen report={lastReport} cursor={existingRunCursor} />;
1248
+ } else if (screen === 'harnesses') {
879
1249
  content = <HarnessScreen cursor={harnessIndex} />;
880
1250
  } 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
- );
1251
+ content = <ScopeScreen rows={rows} cursor={scopeCursor} height={viewHeight} />;
896
1252
  } else if (screen === 'report' && report) {
897
1253
  content = (
898
- <ReportScreen
899
- report={report}
900
- rows={runRows}
901
- cursor={runCursor}
902
- phases={phasesByFile}
903
- height={viewHeight}
904
- />
1254
+ <ReportScreen report={report} rows={generatedRows} cursor={runCursor} height={viewHeight} />
905
1255
  );
906
1256
  } else if (screen === 'error') {
907
1257
  content = (
@@ -918,7 +1268,9 @@ function MintTestApp({
918
1268
  stage={activeRunStage}
919
1269
  cancelling={cancelling}
920
1270
  harness={runHarness}
1271
+ docsRoot={docsRoot}
921
1272
  rows={runRows}
1273
+ generatedRows={generatedRows}
922
1274
  cursor={runCursor}
923
1275
  phases={phasesByFile}
924
1276
  height={viewHeight}
@@ -963,9 +1315,19 @@ function MintTestApp({
963
1315
  </Box>
964
1316
  </Box>
965
1317
  <Text dimColor>
966
- {ctrlCArmed
967
- ? 'Ctrl-C again to exit'
968
- : footerForScreen(screen, running, report?.status === 'cancelled')}
1318
+ {ctrlCArmed ? (
1319
+ <FooterHints text="Ctrl-C again to exit" />
1320
+ ) : (
1321
+ <FooterHints
1322
+ text={footerForScreen(
1323
+ screen,
1324
+ running,
1325
+ report?.status === 'cancelled',
1326
+ activeRunStage,
1327
+ reviewingSavedRun
1328
+ )}
1329
+ />
1330
+ )}
969
1331
  </Text>
970
1332
  </Box>
971
1333
  );