@mintlify/cli 4.0.1655 → 4.0.1657

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 (54) hide show
  1. package/bin/cli.js +1 -7
  2. package/bin/tsconfig.build.tsbuildinfo +1 -1
  3. package/package.json +8 -12
  4. package/src/cli.tsx +1 -12
  5. package/__test__/mintTestFilePreview.test.ts +0 -35
  6. package/__test__/mintTestOutput.test.ts +0 -256
  7. package/__test__/runHistory.test.ts +0 -240
  8. package/bin/agent-harness/agentPreflight.js +0 -24
  9. package/bin/agent-harness/buildTaskPrompt.js +0 -86
  10. package/bin/agent-harness/countReport.js +0 -10
  11. package/bin/agent-harness/discoverCodeBlocks.js +0 -101
  12. package/bin/agent-harness/executeCodeBlocks.js +0 -263
  13. package/bin/agent-harness/filterSelectedPages.js +0 -10
  14. package/bin/agent-harness/generateTestCode.js +0 -337
  15. package/bin/agent-harness/index.js +0 -95
  16. package/bin/agent-harness/loadAgentSdk.js +0 -46
  17. package/bin/agent-harness/manifest.js +0 -113
  18. package/bin/agent-harness/mapWithConcurrency.js +0 -26
  19. package/bin/agent-harness/runHistory.js +0 -256
  20. package/bin/agent-harness/runSave.js +0 -47
  21. package/bin/agent-harness/setupFolders.js +0 -73
  22. package/bin/agent-harness/tasks/checkTestability.js +0 -228
  23. package/bin/agent-harness/types.js +0 -66
  24. package/bin/mintTest.js +0 -122
  25. package/bin/mintTestFilePreview.js +0 -39
  26. package/bin/mintTestNav.js +0 -35
  27. package/bin/mintTestTelemetry.js +0 -63
  28. package/bin/mintTestText.js +0 -31
  29. package/bin/mintTestTree.js +0 -94
  30. package/bin/mintTestUi.js +0 -818
  31. package/src/agent-harness/MINT_TEST_SYSTEM_DESIGN.md +0 -64
  32. package/src/agent-harness/agentPreflight.ts +0 -13
  33. package/src/agent-harness/buildTaskPrompt.ts +0 -93
  34. package/src/agent-harness/countReport.ts +0 -17
  35. package/src/agent-harness/discoverCodeBlocks.ts +0 -90
  36. package/src/agent-harness/executeCodeBlocks.ts +0 -351
  37. package/src/agent-harness/filterSelectedPages.ts +0 -14
  38. package/src/agent-harness/generateTestCode.ts +0 -396
  39. package/src/agent-harness/index.ts +0 -127
  40. package/src/agent-harness/loadAgentSdk.ts +0 -35
  41. package/src/agent-harness/manifest.ts +0 -116
  42. package/src/agent-harness/mapWithConcurrency.ts +0 -21
  43. package/src/agent-harness/runHistory.ts +0 -255
  44. package/src/agent-harness/runSave.ts +0 -38
  45. package/src/agent-harness/setupFolders.ts +0 -87
  46. package/src/agent-harness/tasks/checkTestability.ts +0 -256
  47. package/src/agent-harness/types.ts +0 -239
  48. package/src/mintTest.tsx +0 -138
  49. package/src/mintTestFilePreview.ts +0 -32
  50. package/src/mintTestNav.ts +0 -31
  51. package/src/mintTestTelemetry.ts +0 -101
  52. package/src/mintTestText.ts +0 -35
  53. package/src/mintTestTree.ts +0 -134
  54. package/src/mintTestUi.tsx +0 -1378
@@ -1,1378 +0,0 @@
1
- import { Box, render, Text, useInput } from 'ink';
2
- import path from 'node:path';
3
- import React, { useEffect, useMemo, useRef, useState } from 'react';
4
-
5
- import {
6
- DEFAULT_COMMAND_TIMEOUT_MS,
7
- DEFAULT_CONCURRENCY,
8
- clearRunSave,
9
- runCodeTests,
10
- type CodeTestReport,
11
- type DocsPage,
12
- type HarnessAgent,
13
- type RunSave,
14
- type TaskPhase,
15
- type TaskUpdate,
16
- } from './agent-harness/index.js';
17
- import { readFilePreview } from './mintTestFilePreview.js';
18
- import {
19
- trackMintTestGenerationResult,
20
- trackMintTestRunCompleted,
21
- trackMintTestRunStarted,
22
- } from './mintTestTelemetry.js';
23
- import { firstLine, footerSegments, taskProblemLabel } from './mintTestText.js';
24
- import { toggleScopeSelection, visibleScopeRows, type ScopeTreeRow } from './mintTestTree.js';
25
-
26
- type Screen = 'resume' | 'existing' | 'harnesses' | 'scope' | 'running' | 'report' | 'error';
27
-
28
- interface MintTestUiProps {
29
- targetPath: string;
30
- docsRoot: string;
31
- pages: DocsPage[];
32
- autoSelectedFiles: string[];
33
- totalPages: number;
34
- save: RunSave | null;
35
- lastReport: CodeTestReport | null;
36
- onFinish: (exitCode: number) => void;
37
- }
38
-
39
- interface StepDefinition {
40
- id: 'harnesses' | 'scope' | 'check' | 'generate' | 'run' | 'report';
41
- label: string;
42
- }
43
-
44
- const HARNESSES: Array<{ agent: HarnessAgent; model: string; label: string }> = [
45
- { agent: 'claude', model: 'claude-opus-4-8', label: 'Claude Opus 4.8' },
46
- { agent: 'claude', model: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' },
47
- { agent: 'codex', model: 'claude-opus-4-8', label: 'Codex' },
48
- ];
49
-
50
- const STEPS: StepDefinition[] = [
51
- { id: 'harnesses', label: 'Harnesses' },
52
- { id: 'scope', label: 'Scope' },
53
- { id: 'check', label: 'Check testability' },
54
- { id: 'generate', label: 'Generate tests' },
55
- { id: 'run', label: 'Run tests' },
56
- { id: 'report', label: 'Report' },
57
- ];
58
-
59
- function formatTokens(tokens: number): string {
60
- if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
61
- if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
62
- return String(tokens);
63
- }
64
-
65
- function padTokens(tokens: number): string {
66
- return formatTokens(tokens).padStart(4);
67
- }
68
-
69
- export function formatDuration(durationMs: number): string {
70
- const seconds = Math.max(0, Math.floor(durationMs / 1_000));
71
- if (seconds < 60) return `${seconds}s`;
72
- return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;
73
- }
74
-
75
- function padDuration(durationMs: number): string {
76
- return formatDuration(durationMs).padStart(7);
77
- }
78
-
79
- function padCount(value: number, total: number): string {
80
- return String(value).padStart(String(total).length);
81
- }
82
-
83
- function useTerminalSize(): { columns: number; rows: number } {
84
- const [size, setSize] = useState(() => ({
85
- columns: process.stdout.columns || 100,
86
- rows: process.stdout.rows || 30,
87
- }));
88
-
89
- useEffect(() => {
90
- const resize = () =>
91
- setSize({ columns: process.stdout.columns || 100, rows: process.stdout.rows || 30 });
92
- process.stdout.on('resize', resize);
93
- return () => {
94
- process.stdout.off('resize', resize);
95
- };
96
- }, []);
97
-
98
- return size;
99
- }
100
-
101
- function stepState(
102
- step: StepDefinition['id'],
103
- screen: Screen,
104
- activeRunStage: 'check' | 'generate' | 'run'
105
- ): 'pending' | 'running' | 'complete' | 'error' {
106
- const order = ['harnesses', 'scope', 'check', 'generate', 'run', 'report'];
107
- const screenStep =
108
- screen === 'resume' || screen === 'existing'
109
- ? 'harnesses'
110
- : screen === 'running' || screen === 'error'
111
- ? activeRunStage
112
- : screen;
113
- const current = order.indexOf(screenStep);
114
- const index = order.indexOf(step);
115
- if (screen === 'error' && index === current) return 'error';
116
- if (index < current || screen === 'report') return 'complete';
117
- if (index === current) return 'running';
118
- return 'pending';
119
- }
120
-
121
- function StepGlyph({ state }: { state: ReturnType<typeof stepState> }) {
122
- if (state === 'complete') return <Text color="green">✓</Text>;
123
- if (state === 'running') return <Text color="cyan">●</Text>;
124
- if (state === 'error') return <Text color="red">✗</Text>;
125
- return <Text dimColor>○</Text>;
126
- }
127
-
128
- const RESUME_OPTIONS = ['Resume', 'Start fresh'] as const;
129
- const EXISTING_RUN_OPTIONS = [
130
- 'Update tests',
131
- 'Review last test run',
132
- 'Start a brand new test',
133
- ] as const;
134
- const EMPTY_SELECTION: ReadonlySet<string> = new Set();
135
-
136
- function ResumeScreen({ save, cursor }: { save: RunSave; cursor: number }) {
137
- return (
138
- <Box flexDirection="column">
139
- <Text bold>Resume previous run?</Text>
140
- <Box flexDirection="column" marginTop={1}>
141
- <Text>
142
- Run <Text color="cyan">{save.runId}</Text>
143
- </Text>
144
- <Text dimColor>
145
- {save.selectedFiles.length} {save.selectedFiles.length === 1 ? 'page' : 'pages'} ·{' '}
146
- {save.agents.join(', ')}
147
- </Text>
148
- </Box>
149
- <Box flexDirection="column" marginTop={1}>
150
- {RESUME_OPTIONS.map((option, index) => (
151
- <Text key={option} color={cursor === index ? 'cyan' : undefined}>
152
- {cursor === index ? '›' : ' '} {option}
153
- </Text>
154
- ))}
155
- </Box>
156
- </Box>
157
- );
158
- }
159
-
160
- function ExistingRunScreen({ report, cursor }: { report: CodeTestReport; cursor: number }) {
161
- return (
162
- <Box flexDirection="column">
163
- <Text bold>Mint test has already run in this directory.</Text>
164
- <Box flexDirection="column" marginTop={1}>
165
- <Text>
166
- Last run <Text color="cyan">{report.runId}</Text> ·{' '}
167
- <Text
168
- color={
169
- report.status === 'passed'
170
- ? 'green'
171
- : report.status === 'completed'
172
- ? 'cyan'
173
- : report.status === 'cancelled'
174
- ? 'yellow'
175
- : 'red'
176
- }
177
- >
178
- {report.status}
179
- </Text>
180
- </Text>
181
- <Text dimColor>
182
- {report.selectedFiles.length} {report.selectedFiles.length === 1 ? 'page' : 'pages'} ·{' '}
183
- {report.model} · {new Date(report.completedAt).toLocaleString()}
184
- </Text>
185
- </Box>
186
- <Box flexDirection="column" marginTop={1}>
187
- {EXISTING_RUN_OPTIONS.map((option, index) => (
188
- <Text key={option} color={cursor === index ? 'cyan' : undefined}>
189
- {cursor === index ? '›' : ' '} {option}
190
- </Text>
191
- ))}
192
- </Box>
193
- </Box>
194
- );
195
- }
196
-
197
- function HarnessScreen({ cursor }: { cursor: number }) {
198
- return (
199
- <Box flexDirection="column">
200
- <Text bold>Select coding harness?</Text>
201
- <Box flexDirection="column" marginTop={1}>
202
- {HARNESSES.map((harness, index) => (
203
- <Text key={harness.label} color={cursor === index ? 'cyan' : undefined}>
204
- {cursor === index ? '›' : ' '} {cursor === index ? '(•)' : '( )'} {harness.label}
205
- </Text>
206
- ))}
207
- </Box>
208
- </Box>
209
- );
210
- }
211
-
212
- function ScopeScreen({
213
- rows,
214
- cursor,
215
- height,
216
- }: {
217
- rows: ReturnType<typeof visibleScopeRows>;
218
- cursor: number;
219
- height: number;
220
- }) {
221
- const viewport = Math.max(4, height - 18);
222
- const start = Math.max(0, Math.min(cursor - Math.floor(viewport / 2), rows.length - viewport));
223
- const visible = rows.slice(start, start + viewport);
224
-
225
- return (
226
- <Box flexDirection="column">
227
- <Box flexDirection="column">
228
- <Box marginTop={1} marginRight={2} flexDirection="column">
229
- <Text bold>Select pages to test</Text>
230
- </Box>
231
- <Text bold color="green">
232
- enter to confirm
233
- </Text>
234
- </Box>
235
- <Text dimColor>Folders select or clear every testable page beneath them.</Text>
236
- <Box flexDirection="column" marginTop={1}>
237
- {visible.map((row, visibleIndex) => {
238
- const index = start + visibleIndex;
239
- const selection =
240
- row.selection === 'all' ? '[x]' : row.selection === 'some' ? '[-]' : '[ ]';
241
- const disclosure = row.kind === 'directory' ? (row.expanded ? '▾' : '▸') : ' ';
242
- return (
243
- <Text key={row.id} color={index === cursor ? 'cyan' : undefined}>
244
- {' '.repeat(row.depth * 2)}
245
- {index === cursor ? '›' : ' '} {selection} {disclosure} {row.label}{' '}
246
- <Text dimColor>
247
- {row.blockCount} {row.blockCount === 1 ? 'code block' : 'code blocks'}
248
- </Text>
249
- </Text>
250
- );
251
- })}
252
- </Box>
253
- </Box>
254
- );
255
- }
256
-
257
- const ACTIVE_PHASES = new Set<TaskPhase>(['checking', 'generating', 'running']);
258
- const DONE_PHASES = new Set<TaskPhase>([
259
- 'passed',
260
- 'failed',
261
- 'agent_error',
262
- 'not_testable',
263
- 'cancelled',
264
- ]);
265
- const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
266
-
267
- interface TaskRow extends TaskUpdate {
268
- startedAt: number;
269
- finishedAt?: number;
270
- }
271
-
272
- interface GeneratedPathRow {
273
- id: string;
274
- path: string;
275
- previewable: boolean;
276
- task: TaskRow;
277
- }
278
-
279
- const OUTPUT_PHASES = new Set<TaskPhase>([
280
- 'generating',
281
- 'generated',
282
- 'running',
283
- 'passed',
284
- 'failed',
285
- 'agent_error',
286
- 'cancelled',
287
- ]);
288
- function generatedPathRows(tasks: Iterable<TaskRow>): GeneratedPathRow[] {
289
- const rows: GeneratedPathRow[] = [];
290
- for (const task of tasks) {
291
- if (!OUTPUT_PHASES.has(task.phase)) continue;
292
- if (task.generatedFiles.length === 0) {
293
- rows.push({
294
- id: `${task.id}:directory`,
295
- path: task.directory,
296
- previewable: false,
297
- task,
298
- });
299
- continue;
300
- }
301
- for (const file of task.generatedFiles) {
302
- rows.push({ id: `${task.id}:${file}`, path: file, previewable: true, task });
303
- }
304
- }
305
- return rows.sort((left, right) => left.path.localeCompare(right.path));
306
- }
307
-
308
- type FilePreviewState =
309
- | { status: 'empty' | 'loading' }
310
- | { status: 'loaded'; lines: string[]; truncated: boolean }
311
- | { status: 'error'; message: string };
312
-
313
- function useFilePreview(row: GeneratedPathRow | undefined): FilePreviewState {
314
- const [preview, setPreview] = useState<FilePreviewState>({ status: 'empty' });
315
-
316
- useEffect(() => {
317
- if (!row?.previewable) {
318
- setPreview({ status: 'empty' });
319
- return;
320
- }
321
- let active = true;
322
- setPreview({ status: 'loading' });
323
- void readFilePreview(row.path)
324
- .then((result) => {
325
- if (!active) return;
326
- if (result.binary) {
327
- setPreview({ status: 'error', message: 'Binary files cannot be previewed.' });
328
- return;
329
- }
330
- setPreview({
331
- status: 'loaded',
332
- lines: result.text.split(/\r?\n/),
333
- truncated: result.truncated,
334
- });
335
- })
336
- .catch((error: unknown) => {
337
- if (!active) return;
338
- setPreview({
339
- status: 'error',
340
- message: error instanceof Error ? error.message : String(error),
341
- });
342
- });
343
- return () => {
344
- active = false;
345
- };
346
- }, [row?.path, row?.previewable]);
347
-
348
- return preview;
349
- }
350
-
351
- function generatedPathStatus(
352
- phase: TaskPhase,
353
- spinner: string
354
- ): { glyph: string; color?: string; dim?: boolean } {
355
- if (phase === 'generating' || phase === 'running') return { glyph: spinner, color: 'green' };
356
- if (phase === 'generated' || phase === 'passed') return { glyph: '✓', color: 'green' };
357
- if (phase === 'failed' || phase === 'agent_error') return { glyph: '✗', color: 'red' };
358
- if (phase === 'cancelled') return { glyph: '●', color: 'yellow' };
359
- return { glyph: '○', dim: true };
360
- }
361
-
362
- function FilePreview({ row, height }: { row: GeneratedPathRow | undefined; height: number }) {
363
- const preview = useFilePreview(row);
364
- // Border (2), title, path, margin, and one row kept for the truncation notice.
365
- const lineLimit = Math.max(1, height - 6);
366
-
367
- return (
368
- <Box
369
- width="52%"
370
- height={height}
371
- borderStyle="round"
372
- borderColor="gray"
373
- paddingX={1}
374
- flexDirection="column"
375
- overflow="hidden"
376
- >
377
- <Text bold>File preview</Text>
378
- {row ? <Text dimColor>{row.path}</Text> : <Text dimColor>Select a generated file.</Text>}
379
- {!row?.previewable ? (
380
- <Text dimColor>
381
- {row?.task.phase === 'generating'
382
- ? 'Files will appear here after generation finishes.'
383
- : 'No generated file is available to preview.'}
384
- </Text>
385
- ) : preview.status === 'loading' ? (
386
- <Text dimColor>Loading preview...</Text>
387
- ) : preview.status === 'error' ? (
388
- <Text color="yellow">{preview.message}</Text>
389
- ) : preview.status === 'loaded' ? (
390
- <Box flexDirection="column" marginTop={1}>
391
- {preview.lines.slice(0, lineLimit).map((line, index) => (
392
- <Text key={`${index}:${line}`} wrap="truncate-end">
393
- <Text dimColor>{String(index + 1).padStart(3)} </Text>
394
- {line || ' '}
395
- </Text>
396
- ))}
397
- {preview.truncated || preview.lines.length > lineLimit ? (
398
- <Text dimColor>Preview truncated</Text>
399
- ) : null}
400
- </Box>
401
- ) : null}
402
- </Box>
403
- );
404
- }
405
-
406
- function GeneratedFilesView({
407
- rows,
408
- cursor,
409
- height,
410
- spinner,
411
- relativeRoot,
412
- emptyMessage = 'Waiting for generated files...',
413
- }: {
414
- rows: GeneratedPathRow[];
415
- cursor: number;
416
- height: number;
417
- spinner: string;
418
- relativeRoot: string;
419
- emptyMessage?: string;
420
- }) {
421
- const safeCursor = Math.max(0, Math.min(cursor, rows.length - 1));
422
- const viewport = Math.max(2, height - 3);
423
- const start = Math.max(
424
- 0,
425
- Math.min(safeCursor - Math.floor(viewport / 2), rows.length - viewport)
426
- );
427
- const visible = rows.slice(start, start + viewport);
428
- const selected = rows[safeCursor];
429
- const showPreview = selected?.previewable === true;
430
-
431
- return (
432
- <Box flexDirection="row" gap={1} flexGrow={1} overflow="hidden">
433
- <Box
434
- width={showPreview ? '48%' : '100%'}
435
- borderStyle="round"
436
- borderColor="gray"
437
- paddingX={1}
438
- flexDirection="column"
439
- overflow="hidden"
440
- >
441
- <Text bold>Generated files</Text>
442
- {visible.length === 0 ? <Text dimColor>{emptyMessage}</Text> : null}
443
- {visible.map((row, visibleIndex) => {
444
- const index = start + visibleIndex;
445
- const status = generatedPathStatus(row.task.phase, spinner);
446
- const relativePath = path.relative(relativeRoot, row.path) || '.';
447
- return (
448
- <Text
449
- key={row.id}
450
- color={index === safeCursor ? 'cyan' : undefined}
451
- wrap="truncate-end"
452
- >
453
- {index === safeCursor ? '›' : ' '}{' '}
454
- <Text color={status.color} dimColor={status.dim}>
455
- {status.glyph}
456
- </Text>{' '}
457
- {row.previewable
458
- ? row.path
459
- : row.task.phase === 'generating'
460
- ? `Writing under ${relativePath}`
461
- : `No generated file in ${relativePath}`}
462
- {row.task.phase === 'failed' || row.task.phase === 'agent_error' ? (
463
- <Text color="red">
464
- {' '}
465
- {taskProblemLabel(row.task.phase)}
466
- {row.task.error ? <Text dimColor> · {firstLine(row.task.error)}</Text> : null}
467
- </Text>
468
- ) : null}
469
- </Text>
470
- );
471
- })}
472
- </Box>
473
- {showPreview ? <FilePreview row={selected} height={height} /> : null}
474
- </Box>
475
- );
476
- }
477
-
478
- function fileStatus(
479
- row: TaskRow | undefined,
480
- spinner: string
481
- ): { glyph: string; color?: string; dim?: boolean } {
482
- if (row && ACTIVE_PHASES.has(row.phase)) return { glyph: spinner, color: 'green' };
483
- if (row?.phase === 'failed' || row?.phase === 'agent_error') return { glyph: '✗', color: 'red' };
484
- if (row?.phase === 'checked' || row?.phase === 'generated' || row?.phase === 'passed') {
485
- return { glyph: '✓', color: 'green' };
486
- }
487
- if (row?.phase === 'not_testable' || row?.phase === 'cancelled') {
488
- return { glyph: '●', color: 'yellow' };
489
- }
490
- return { glyph: '○', dim: true };
491
- }
492
-
493
- const SETTLED_PHASES = new Set<TaskPhase>([...DONE_PHASES, 'checked', 'generated']);
494
-
495
- function directoryStatus(
496
- files: string[],
497
- phases: ReadonlyMap<string, TaskRow>,
498
- spinner: string
499
- ): { glyph: string; color?: string; dim?: boolean } {
500
- const rows = files.map((file) => phases.get(file));
501
- if (rows.some((row) => row && ACTIVE_PHASES.has(row.phase))) {
502
- return { glyph: spinner, color: 'green' };
503
- }
504
- if (rows.some((row) => row?.phase === 'failed' || row?.phase === 'agent_error')) {
505
- return { glyph: '✗', color: 'red' };
506
- }
507
- if (rows.every((row) => row && SETTLED_PHASES.has(row.phase))) {
508
- if (rows.every((row) => row?.phase === 'not_testable' || row?.phase === 'cancelled')) {
509
- return { glyph: '●', color: 'yellow' };
510
- }
511
- return { glyph: '✓', color: 'green' };
512
- }
513
- return { glyph: '○', dim: true };
514
- }
515
-
516
- function ActivityLegend({ spinner }: { spinner: string }) {
517
- return (
518
- <Text dimColor>
519
- ○ waiting · <Text color="green">{spinner}</Text> active · <Text color="green">✓</Text>{' '}
520
- complete · <Text color="red">✗</Text> failed · <Text color="yellow">●</Text> skipped /
521
- cancelled
522
- </Text>
523
- );
524
- }
525
-
526
- function ActivityScreen({
527
- stage,
528
- cancelling,
529
- harness,
530
- docsRoot,
531
- rows,
532
- generatedRows,
533
- cursor,
534
- phases,
535
- height,
536
- totalTasks,
537
- spinnerFrame,
538
- runStartedAt,
539
- now,
540
- }: {
541
- stage: 'check' | 'generate' | 'run';
542
- cancelling: boolean;
543
- harness: string;
544
- docsRoot: string;
545
- rows: ReturnType<typeof visibleScopeRows>;
546
- generatedRows: GeneratedPathRow[];
547
- cursor: number;
548
- phases: ReadonlyMap<string, TaskRow>;
549
- height: number;
550
- totalTasks: number;
551
- spinnerFrame: number;
552
- runStartedAt: number;
553
- now: number;
554
- }) {
555
- const spinner = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length] ?? '⠋';
556
- const updates = [...phases.values()];
557
- const message = cancelling
558
- ? 'Cancelling agent and test processes'
559
- : stage === 'check'
560
- ? 'Checking which code blocks can be tested'
561
- : stage === 'generate'
562
- ? 'Agents are generating test projects'
563
- : 'Running generated tests';
564
- const done = updates.filter((update) => DONE_PHASES.has(update.phase)).length;
565
- const notTestable = updates.filter((update) => update.phase === 'not_testable').length;
566
- const created = updates.filter((update) =>
567
- ['generated', 'running', 'passed', 'failed'].includes(update.phase)
568
- ).length;
569
- const tokens = updates.reduce((total, update) => total + update.tokens, 0);
570
- const checked = updates.filter((update) => update.phase !== 'checking').length;
571
- const progress =
572
- stage === 'check'
573
- ? `${padCount(checked, totalTasks)}/${totalTasks} pages checked`
574
- : stage === 'generate'
575
- ? `${padCount(created, totalTasks - notTestable)}/${totalTasks - notTestable} tests created`
576
- : `${padCount(done - notTestable, totalTasks - notTestable)}/${totalTasks - notTestable} tasks finished`;
577
- const viewport = Math.max(4, height - 13);
578
- const start = Math.max(0, Math.min(cursor - Math.floor(viewport / 2), rows.length - viewport));
579
- const visible = rows.slice(start, start + viewport);
580
-
581
- return (
582
- <Box flexDirection="column" flexGrow={1}>
583
- <Box borderStyle="round" borderColor="gray" paddingX={1} flexDirection="column">
584
- <Text bold>
585
- {padDuration(now - runStartedAt)} <Text dimColor>{padTokens(tokens)} tokens</Text>
586
- </Text>
587
- <Text color="green">
588
- <Text>{spinner}</Text> {message}
589
- </Text>
590
- <Text dimColor>Harness: {harness}</Text>
591
- <Text dimColor>{progress}</Text>
592
- {stage === 'check' || stage === 'generate' ? <ActivityLegend spinner={spinner} /> : null}
593
- </Box>
594
- <Box marginTop={1} flexGrow={1} overflow="hidden">
595
- {stage === 'check' ? (
596
- <Box
597
- borderStyle="round"
598
- borderColor="gray"
599
- paddingX={1}
600
- flexDirection="column"
601
- flexGrow={1}
602
- >
603
- {visible.map((row, visibleIndex) => {
604
- const index = start + visibleIndex;
605
- const disclosure = row.kind === 'directory' ? (row.expanded ? '▾' : '▸') : ' ';
606
- const status =
607
- row.kind === 'directory'
608
- ? directoryStatus(row.selectableFiles, phases, spinner)
609
- : fileStatus(row.file ? phases.get(row.file) : undefined, spinner);
610
- const task = row.file ? phases.get(row.file) : undefined;
611
- const settled =
612
- row.kind === 'directory'
613
- ? row.selectableFiles.filter((file) => {
614
- const phase = phases.get(file)?.phase;
615
- return phase !== undefined && SETTLED_PHASES.has(phase);
616
- }).length
617
- : 0;
618
- return (
619
- <Text
620
- key={row.id}
621
- color={index === cursor ? 'cyan' : undefined}
622
- wrap="truncate-end"
623
- >
624
- {' '.repeat(row.depth * 2)}
625
- {index === cursor ? '›' : ' '}{' '}
626
- <Text color={status.color} dimColor={status.dim}>
627
- {status.glyph}
628
- </Text>{' '}
629
- {disclosure} {row.label}
630
- {row.kind === 'directory' ? (
631
- <Text dimColor>
632
- {' '}
633
- {padCount(settled, row.selectableFiles.length)}/{row.selectableFiles.length}{' '}
634
- complete
635
- </Text>
636
- ) : null}
637
- {task ? (
638
- <Text dimColor> {padDuration((task.finishedAt ?? now) - task.startedAt)}</Text>
639
- ) : null}
640
- {task && (task.phase === 'failed' || task.phase === 'agent_error') ? (
641
- <Text color="red">
642
- {' '}
643
- {taskProblemLabel(task.phase)}
644
- {task.error ? <Text dimColor> · {firstLine(task.error)}</Text> : null}
645
- </Text>
646
- ) : null}
647
- </Text>
648
- );
649
- })}
650
- </Box>
651
- ) : (
652
- <GeneratedFilesView
653
- rows={generatedRows}
654
- cursor={cursor}
655
- height={Math.max(4, height - 7)}
656
- spinner={spinner}
657
- relativeRoot={docsRoot}
658
- />
659
- )}
660
- </Box>
661
- </Box>
662
- );
663
- }
664
-
665
- const MAX_PROBLEM_ROWS = 6;
666
-
667
- function ProblemsView({ report }: { report: CodeTestReport }) {
668
- const problems = report.tasks.filter(
669
- (task): task is CodeTestReport['tasks'][number] & { status: 'failed' | 'agent_error' } =>
670
- task.status === 'failed' || task.status === 'agent_error'
671
- );
672
- if (problems.length === 0) return null;
673
- const hidden = problems.length - MAX_PROBLEM_ROWS;
674
- return (
675
- <Box
676
- marginTop={1}
677
- borderStyle="round"
678
- borderColor="red"
679
- paddingX={1}
680
- flexDirection="column"
681
- flexShrink={0}
682
- >
683
- <Text bold color="red">
684
- {problems.length} {problems.length === 1 ? 'page needs' : 'pages need'} attention
685
- </Text>
686
- {problems.slice(0, MAX_PROBLEM_ROWS).map((task) => (
687
- <Text key={task.id} wrap="truncate-end">
688
- <Text color="red">✗</Text> {task.file}{' '}
689
- <Text dimColor>{taskProblemLabel(task.status)}</Text>
690
- {task.error ? <Text dimColor> · {firstLine(task.error)}</Text> : null}
691
- </Text>
692
- ))}
693
- {hidden > 0 ? <Text dimColor>+{hidden} more in the report</Text> : null}
694
- </Box>
695
- );
696
- }
697
-
698
- /**
699
- * Rows the report header takes, including its border and the margin below it. The optional
700
- * lines must be counted, or the screen is one row taller than the terminal and Ink drops the
701
- * heading (it showed as "Finished in 1m 37s passed").
702
- */
703
- function reportHeaderHeight(report: CodeTestReport, rows: GeneratedPathRow[]): number {
704
- const optionalLines =
705
- (report.historyError ? 1 : 0) + (rows.some((row) => row.previewable) ? 1 : 0);
706
- return 2 + 4 + optionalLines + 1;
707
- }
708
-
709
- function problemsViewHeight(report: CodeTestReport): number {
710
- const problems = report.tasks.filter(
711
- (task) => task.status === 'failed' || task.status === 'agent_error'
712
- ).length;
713
- if (problems === 0) return 0;
714
- return Math.min(problems, MAX_PROBLEM_ROWS) + 4 + (problems > MAX_PROBLEM_ROWS ? 1 : 0);
715
- }
716
-
717
- function ReportScreen({
718
- report,
719
- rows,
720
- cursor,
721
- height,
722
- }: {
723
- report: CodeTestReport;
724
- rows: GeneratedPathRow[];
725
- cursor: number;
726
- height: number;
727
- }) {
728
- const statusColor =
729
- report.status === 'passed'
730
- ? 'green'
731
- : report.status === 'completed'
732
- ? 'cyan'
733
- : report.status === 'cancelled'
734
- ? 'yellow'
735
- : 'red';
736
- const heading =
737
- report.status === 'passed'
738
- ? 'All runnable tests passed'
739
- : report.status === 'completed'
740
- ? 'Run completed'
741
- : `Run ${report.status}`;
742
- return (
743
- <Box flexDirection="column" flexGrow={1}>
744
- <Box
745
- borderStyle="round"
746
- borderColor="gray"
747
- paddingX={1}
748
- flexDirection="column"
749
- flexShrink={0}
750
- >
751
- <Text bold color={statusColor}>
752
- {heading}
753
- </Text>
754
- <Text>
755
- Finished in <Text bold>{formatDuration(report.durationMs)}</Text>
756
- </Text>
757
- <Text dimColor wrap="truncate-end">
758
- Report at {report.reportPath}
759
- </Text>
760
- <Text dimColor wrap="truncate-end">
761
- Generated files at {report.outputDirectory}
762
- </Text>
763
- {report.historyError ? (
764
- <Text color="yellow" wrap="truncate-end">
765
- Warning: {report.historyError}
766
- </Text>
767
- ) : null}
768
- {rows.some((row) => row.previewable) ? (
769
- <Text>Select a file below to preview its contents.</Text>
770
- ) : null}
771
- </Box>
772
- <ProblemsView report={report} />
773
- <Box marginTop={1} flexGrow={1} overflow="hidden">
774
- <GeneratedFilesView
775
- rows={rows}
776
- cursor={cursor}
777
- height={Math.max(
778
- 4,
779
- height - reportHeaderHeight(report, rows) - problemsViewHeight(report)
780
- )}
781
- spinner="●"
782
- relativeRoot={report.docsRoot}
783
- emptyMessage="No generated files."
784
- />
785
- </Box>
786
- </Box>
787
- );
788
- }
789
-
790
- function FooterHints({ text }: { text: string }) {
791
- return (
792
- <>
793
- {footerSegments(text).map((segment, index) => (
794
- <Text key={`${index}:${segment.key}`}>
795
- {index > 0 ? ' · ' : ''}
796
- <Text bold>{segment.key}</Text>
797
- {segment.action ? ` ${segment.action}` : ''}
798
- </Text>
799
- ))}
800
- </>
801
- );
802
- }
803
-
804
- function footerForScreen(
805
- screen: Screen,
806
- running: boolean,
807
- cancelled: boolean,
808
- activeRunStage: 'check' | 'generate' | 'run',
809
- reviewingSavedRun: boolean
810
- ): string {
811
- if (running) {
812
- return activeRunStage === 'check'
813
- ? '↑/↓/j/k move · ←/→/h/l collapse/expand · Ctrl-C cancel'
814
- : '↑/↓/j/k preview generated files · Ctrl-C cancel';
815
- }
816
- if (screen === 'resume') return '↑/↓/j/k move · Enter confirm · Ctrl-C cancel';
817
- if (screen === 'existing') return '↑/↓/j/k move · Enter confirm · Ctrl-C cancel';
818
- if (screen === 'harnesses') return '↑/↓/j/k move · Enter continue · Ctrl-C cancel';
819
- if (screen === 'scope') {
820
- return '↑/↓/j/k move · ←/→/h/l collapse/expand · Space select · a all · Enter start';
821
- }
822
- if (screen === 'report') {
823
- if (reviewingSavedRun) return '↑/↓/j/k preview generated files · Enter exit';
824
- return cancelled
825
- ? '↑/↓/j/k preview generated files · Ctrl-C exit · save kept for resume'
826
- : '↑/↓/j/k preview generated files · Enter exit';
827
- }
828
- return 'Enter exit';
829
- }
830
-
831
- function MintTestApp({
832
- targetPath,
833
- docsRoot,
834
- pages,
835
- autoSelectedFiles,
836
- totalPages,
837
- save,
838
- lastReport,
839
- onFinish,
840
- }: MintTestUiProps) {
841
- const size = useTerminalSize();
842
- const files = useMemo(() => pages.map((page) => page.file), [pages]);
843
- const outputDirectory = path.join(docsRoot, 'tests', 'mint-test');
844
- const [screen, setScreen] = useState<Screen>(
845
- save ? 'resume' : lastReport ? 'existing' : 'harnesses'
846
- );
847
- const [resumeCursor, setResumeCursor] = useState(0);
848
- const [existingRunCursor, setExistingRunCursor] = useState(0);
849
- const [harnessIndex, setHarnessIndex] = useState(0);
850
- const [runHarness, setRunHarness] = useState('');
851
- const harness = HARNESSES[harnessIndex] ?? HARNESSES[0]!;
852
- const [selectedFiles, setSelectedFiles] = useState<Set<string>>(() => new Set(autoSelectedFiles));
853
- const [expandedDirectories, setExpandedDirectories] = useState<Set<string>>(new Set());
854
- const [scopeCursor, setScopeCursor] = useState(0);
855
- const [activeRunStage, setActiveRunStage] = useState<'check' | 'generate' | 'run'>('check');
856
- const [cancelling, setCancelling] = useState(false);
857
- const [ctrlCArmed, setCtrlCArmed] = useState(false);
858
- const ctrlCTimer = useRef<NodeJS.Timeout | undefined>(undefined);
859
- const [taskPhases, setTaskPhases] = useState<Map<string, TaskRow>>(new Map());
860
- const [totalTasks, setTotalTasks] = useState(0);
861
- const [runCursor, setRunCursor] = useState(0);
862
- const [runExpanded, setRunExpanded] = useState<Set<string>>(new Set());
863
- const [runFiles, setRunFiles] = useState<string[]>([]);
864
- const [runStartedAt, setRunStartedAt] = useState(0);
865
- const autoCollapsed = useRef<Set<string>>(new Set());
866
- const manualCursor = useRef(false);
867
- const previousRunStage = useRef(activeRunStage);
868
- const [spinnerFrame, setSpinnerFrame] = useState(0);
869
- const [now, setNow] = useState(() => Date.now());
870
- const [report, setReport] = useState<CodeTestReport>();
871
- const [reviewingSavedRun, setReviewingSavedRun] = useState(false);
872
- const [error, setError] = useState<string>();
873
- const abortController = useRef<AbortController | undefined>(undefined);
874
- const finished = useRef(false);
875
- const rows = useMemo(
876
- () => visibleScopeRows(pages, expandedDirectories, selectedFiles),
877
- [pages, expandedDirectories, selectedFiles]
878
- );
879
- const phasesByFile = useMemo(
880
- () => new Map([...taskPhases.values()].map((row) => [row.file, row])),
881
- [taskPhases]
882
- );
883
- const visibleRunFiles = useMemo(
884
- () =>
885
- runFiles.filter(
886
- (file) => activeRunStage === 'check' || phasesByFile.get(file)?.phase !== 'not_testable'
887
- ),
888
- [runFiles, activeRunStage, phasesByFile]
889
- );
890
- const runRows = useMemo(() => {
891
- const included = new Set(visibleRunFiles);
892
- return visibleScopeRows(
893
- pages.filter((page) => included.has(page.file)),
894
- runExpanded,
895
- EMPTY_SELECTION
896
- );
897
- }, [pages, visibleRunFiles, runExpanded]);
898
- const generatedRows = useMemo(() => generatedPathRows(taskPhases.values()), [taskPhases]);
899
-
900
- useEffect(() => {
901
- if (screen !== 'running') return;
902
- const timer = setInterval(() => setSpinnerFrame((frame) => frame + 1), 120);
903
- const clock = setInterval(() => setNow(Date.now()), 1_000);
904
- return () => {
905
- clearInterval(timer);
906
- clearInterval(clock);
907
- };
908
- }, [screen]);
909
-
910
- useEffect(() => {
911
- if (screen !== 'running' || activeRunStage !== 'check') return;
912
- const fileDone = (file: string) => {
913
- const phase = phasesByFile.get(file)?.phase;
914
- return phase !== undefined && DONE_PHASES.has(phase);
915
- };
916
- const directoryFiles = new Map<string, string[]>();
917
- for (const file of visibleRunFiles) {
918
- const parts = file.split('/');
919
- let currentPath = '';
920
- for (const part of parts.slice(0, -1)) {
921
- currentPath = currentPath ? `${currentPath}/${part}` : part;
922
- const id = `dir:${currentPath}`;
923
- directoryFiles.set(id, [...(directoryFiles.get(id) ?? []), file]);
924
- }
925
- }
926
- const toCollapse = [...directoryFiles.entries()]
927
- .filter(([id, files]) => !autoCollapsed.current.has(id) && files.every(fileDone))
928
- .map(([id]) => id);
929
- if (toCollapse.length > 0) {
930
- for (const id of toCollapse) autoCollapsed.current.add(id);
931
- setRunExpanded((current) => {
932
- const next = new Set(current);
933
- for (const id of toCollapse) next.delete(id);
934
- return next;
935
- });
936
- }
937
- const rowDone = (row: ScopeTreeRow) => {
938
- const files = row.kind === 'directory' ? row.selectableFiles : row.file ? [row.file] : [];
939
- return files.length > 0 && files.every(fileDone);
940
- };
941
- if (manualCursor.current) return;
942
- const current = runRows[runCursor];
943
- if (runRows.length > 0 && (!current || rowDone(current))) {
944
- const nextIndex = runRows.findIndex((row) => !rowDone(row));
945
- if (nextIndex !== -1 && nextIndex !== runCursor) setRunCursor(nextIndex);
946
- }
947
- }, [screen, activeRunStage, phasesByFile, visibleRunFiles, runRows, runCursor]);
948
-
949
- useEffect(() => {
950
- if (previousRunStage.current === activeRunStage) return;
951
- previousRunStage.current = activeRunStage;
952
- manualCursor.current = false;
953
- setRunCursor(0);
954
- }, [activeRunStage]);
955
-
956
- const finish = (exitCode: number) => {
957
- if (finished.current) return;
958
- finished.current = true;
959
- onFinish(exitCode);
960
- };
961
-
962
- const reviewRun = (savedReport: CodeTestReport) => {
963
- const startedAt = Date.parse(savedReport.startedAt);
964
- const finishedAt = Date.parse(savedReport.completedAt);
965
- setTaskPhases(
966
- new Map(
967
- savedReport.tasks.map((task) => [
968
- task.id,
969
- {
970
- id: task.id,
971
- agent: task.agent,
972
- file: task.file,
973
- directory: task.directory,
974
- generatedFiles: task.generatedFiles,
975
- phase: !task.testable && task.status === 'passed' ? 'not_testable' : task.status,
976
- tokens: task.tokens,
977
- startedAt: Number.isNaN(startedAt) ? 0 : startedAt,
978
- finishedAt: Number.isNaN(finishedAt) ? undefined : finishedAt,
979
- } satisfies TaskRow,
980
- ])
981
- )
982
- );
983
- setRunFiles(savedReport.selectedFiles);
984
- setRunHarness(
985
- HARNESSES.find(
986
- (entry) => entry.agent === savedReport.agents[0] && entry.model === savedReport.model
987
- )?.label ?? `${savedReport.agents.join(', ')} · ${savedReport.model}`
988
- );
989
- setReport(savedReport);
990
- setReviewingSavedRun(true);
991
- setActiveRunStage('run');
992
- setRunCursor(0);
993
- setScreen('report');
994
- };
995
-
996
- const startRun = ({
997
- runId,
998
- runAgents,
999
- runModel,
1000
- runFiles: runFiles_,
1001
- }: {
1002
- runId: string | null;
1003
- runAgents: HarnessAgent[];
1004
- runModel: string;
1005
- runFiles: string[];
1006
- }) => {
1007
- if (abortController.current) return;
1008
- const resumed = runId !== null;
1009
- const controller = new AbortController();
1010
- abortController.current = controller;
1011
- setTaskPhases(new Map());
1012
- setReport(undefined);
1013
- setReviewingSavedRun(false);
1014
- setError(undefined);
1015
- setTotalTasks(runAgents.length * runFiles_.length);
1016
- setRunHarness(
1017
- HARNESSES.find((entry) => entry.agent === runAgents[0] && entry.model === runModel)?.label ??
1018
- `${runAgents.join(', ')} · ${runModel}`
1019
- );
1020
- setRunCursor(0);
1021
- autoCollapsed.current = new Set();
1022
- manualCursor.current = false;
1023
- setRunStartedAt(Date.now());
1024
- setNow(Date.now());
1025
- setRunFiles(runFiles_);
1026
- setRunExpanded(
1027
- new Set(
1028
- runFiles_.flatMap((file) => {
1029
- const parts = file.split('/').slice(0, -1);
1030
- return parts.map((_, index) => `dir:${parts.slice(0, index + 1).join('/')}`);
1031
- })
1032
- )
1033
- );
1034
- setScreen('running');
1035
- setActiveRunStage('check');
1036
- void trackMintTestRunStarted({
1037
- mode: 'interactive',
1038
- resumed,
1039
- agents: runAgents,
1040
- model: runModel,
1041
- selectedPageCount: new Set(runFiles_).size,
1042
- totalPageCount: totalPages,
1043
- });
1044
- void runCodeTests({
1045
- path: targetPath,
1046
- agents: runAgents,
1047
- model: runModel,
1048
- selectedFiles: runFiles_,
1049
- outputDirectory,
1050
- runId,
1051
- concurrency: DEFAULT_CONCURRENCY,
1052
- commandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS,
1053
- signal: controller.signal,
1054
- onGenerationResult: (result) => {
1055
- void trackMintTestGenerationResult({
1056
- ...result,
1057
- mode: 'interactive',
1058
- resumed,
1059
- });
1060
- },
1061
- onTaskUpdate: (update) => {
1062
- const phaseStage =
1063
- update.phase === 'running'
1064
- ? 'run'
1065
- : update.phase === 'generating' || update.phase === 'generated'
1066
- ? 'generate'
1067
- : null;
1068
- if (phaseStage) {
1069
- setActiveRunStage((stage) => {
1070
- const order = ['check', 'generate', 'run'] as const;
1071
- return order.indexOf(phaseStage) > order.indexOf(stage) ? phaseStage : stage;
1072
- });
1073
- }
1074
- const now = Date.now();
1075
- setTaskPhases((current) => {
1076
- const previous = current.get(update.id);
1077
- return new Map(current).set(update.id, {
1078
- ...update,
1079
- startedAt: previous?.startedAt ?? now,
1080
- ...(ACTIVE_PHASES.has(update.phase) ? {} : { finishedAt: now }),
1081
- });
1082
- });
1083
- },
1084
- })
1085
- .then((nextReport) => {
1086
- void trackMintTestRunCompleted(nextReport, {
1087
- mode: 'interactive',
1088
- resumed,
1089
- totalPageCount: totalPages,
1090
- });
1091
- setReport(nextReport);
1092
- const failedFiles = nextReport.tasks
1093
- .filter((task) => task.status === 'failed' || task.status === 'agent_error')
1094
- .map((task) => task.file);
1095
- setRunExpanded(
1096
- new Set(
1097
- failedFiles.flatMap((file) => {
1098
- const parts = file.split('/').slice(0, -1);
1099
- return parts.map((_, index) => `dir:${parts.slice(0, index + 1).join('/')}`);
1100
- })
1101
- )
1102
- );
1103
- setRunCursor(0);
1104
- setScreen('report');
1105
- })
1106
- .catch((runError: unknown) => {
1107
- if (controller.signal.aborted) {
1108
- finish(130);
1109
- return;
1110
- }
1111
- setError(runError instanceof Error ? runError.message : String(runError));
1112
- setScreen('error');
1113
- });
1114
- };
1115
-
1116
- useInput((input, key) => {
1117
- if (key.ctrl && input === 'c') {
1118
- if (ctrlCArmed) {
1119
- if (abortController.current) {
1120
- abortController.current.abort();
1121
- setCancelling(true);
1122
- }
1123
- finish(130);
1124
- } else {
1125
- setCtrlCArmed(true);
1126
- if (ctrlCTimer.current) clearTimeout(ctrlCTimer.current);
1127
- ctrlCTimer.current = setTimeout(() => setCtrlCArmed(false), 2_000);
1128
- }
1129
- return;
1130
- }
1131
-
1132
- if (screen === 'running' || screen === 'report') {
1133
- const collapse = key.leftArrow || input === 'h';
1134
- const expand = key.rightArrow || input === 'l';
1135
- const showingGeneratedFiles = screen === 'report' || activeRunStage !== 'check';
1136
- const rowCount = showingGeneratedFiles ? generatedRows.length : runRows.length;
1137
- if (key.upArrow || input === 'k') {
1138
- manualCursor.current = true;
1139
- setRunCursor((current) => Math.max(0, current - 1));
1140
- } else if (key.downArrow || input === 'j') {
1141
- manualCursor.current = true;
1142
- setRunCursor((current) => Math.max(0, Math.min(rowCount - 1, current + 1)));
1143
- } else if ((collapse || expand) && !showingGeneratedFiles) {
1144
- manualCursor.current = true;
1145
- const row = runRows[runCursor];
1146
- if (row?.kind !== 'directory') return;
1147
- setRunExpanded((current) => {
1148
- const next = new Set(current);
1149
- if (collapse) next.delete(row.id);
1150
- else next.add(row.id);
1151
- return next;
1152
- });
1153
- } else if (screen === 'report' && key.return && report) {
1154
- finish(
1155
- reviewingSavedRun
1156
- ? 0
1157
- : report.status === 'passed'
1158
- ? 0
1159
- : report.status === 'cancelled'
1160
- ? 130
1161
- : 1
1162
- );
1163
- }
1164
- return;
1165
- }
1166
-
1167
- if (screen === 'existing' && lastReport) {
1168
- if (key.upArrow || input === 'k') {
1169
- setExistingRunCursor(
1170
- (current) => (current + EXISTING_RUN_OPTIONS.length - 1) % EXISTING_RUN_OPTIONS.length
1171
- );
1172
- } else if (key.downArrow || input === 'j') {
1173
- setExistingRunCursor((current) => (current + 1) % EXISTING_RUN_OPTIONS.length);
1174
- } else if (key.return) {
1175
- if (existingRunCursor === 0) {
1176
- const knownFiles = new Set(files);
1177
- const updateFiles = lastReport.selectedFiles.filter((file) => knownFiles.has(file));
1178
- setSelectedFiles(new Set(updateFiles.length > 0 ? updateFiles : autoSelectedFiles));
1179
- if (updateFiles.length === 0) {
1180
- const previousHarness = HARNESSES.findIndex(
1181
- (entry) => entry.agent === lastReport.agents[0] && entry.model === lastReport.model
1182
- );
1183
- if (previousHarness !== -1) setHarnessIndex(previousHarness);
1184
- setScreen('scope');
1185
- } else {
1186
- startRun({
1187
- runId: null,
1188
- runAgents: lastReport.agents,
1189
- runModel: lastReport.model,
1190
- runFiles: updateFiles,
1191
- });
1192
- }
1193
- } else if (existingRunCursor === 1) {
1194
- reviewRun(lastReport);
1195
- } else {
1196
- setSelectedFiles(new Set(autoSelectedFiles));
1197
- setScreen('harnesses');
1198
- }
1199
- }
1200
- return;
1201
- }
1202
-
1203
- if (screen === 'resume' && save) {
1204
- if (key.upArrow || input === 'k' || key.downArrow || input === 'j') {
1205
- setResumeCursor((current) => (current + 1) % RESUME_OPTIONS.length);
1206
- } else if (key.return) {
1207
- if (resumeCursor === 0) {
1208
- setSelectedFiles(new Set(save.selectedFiles));
1209
- startRun({
1210
- runId: save.runId,
1211
- runAgents: save.agents,
1212
- runModel: save.model,
1213
- runFiles: save.selectedFiles,
1214
- });
1215
- } else {
1216
- void clearRunSave(outputDirectory);
1217
- setScreen('harnesses');
1218
- }
1219
- }
1220
- return;
1221
- }
1222
-
1223
- if (screen === 'harnesses') {
1224
- if (key.upArrow || input === 'k')
1225
- setHarnessIndex((current) => (current + HARNESSES.length - 1) % HARNESSES.length);
1226
- else if (key.downArrow || input === 'j')
1227
- setHarnessIndex((current) => (current + 1) % HARNESSES.length);
1228
- else if (key.return) setScreen('scope');
1229
- return;
1230
- }
1231
-
1232
- if (screen === 'scope') {
1233
- const collapse = key.leftArrow || input === 'h';
1234
- const expand = key.rightArrow || input === 'l';
1235
- if (key.upArrow || input === 'k') setScopeCursor((current) => Math.max(0, current - 1));
1236
- else if (key.downArrow || input === 'j')
1237
- setScopeCursor((current) => Math.max(0, Math.min(rows.length - 1, current + 1)));
1238
- else if (collapse || expand) {
1239
- const row = rows[scopeCursor];
1240
- if (row?.kind !== 'directory') return;
1241
- setExpandedDirectories((current) => {
1242
- const next = new Set(current);
1243
- if (collapse) next.delete(row.id);
1244
- else next.add(row.id);
1245
- return next;
1246
- });
1247
- } else if (input === ' ') {
1248
- const row = rows[scopeCursor];
1249
- if (row) setSelectedFiles((current) => toggleScopeSelection(current, row));
1250
- } else if (input.toLowerCase() === 'a') {
1251
- setSelectedFiles((current) => (current.size === files.length ? new Set() : new Set(files)));
1252
- } else if (key.return && (selectedFiles.size > 0 || files.length === 0)) {
1253
- startRun({
1254
- runId: null,
1255
- runAgents: [harness.agent],
1256
- runModel: harness.model,
1257
- runFiles: [...selectedFiles],
1258
- });
1259
- }
1260
- return;
1261
- }
1262
-
1263
- if (screen === 'error' && key.return) {
1264
- finish(1);
1265
- }
1266
- });
1267
-
1268
- const leftWidth = size.columns >= 90 ? 28 : 24;
1269
- const viewHeight = size.rows - 1;
1270
- const running = screen === 'running';
1271
- const fullBleed = running || (screen === 'report' && report !== undefined);
1272
- let content: React.ReactNode;
1273
- if (screen === 'resume' && save) content = <ResumeScreen save={save} cursor={resumeCursor} />;
1274
- else if (screen === 'existing' && lastReport) {
1275
- content = <ExistingRunScreen report={lastReport} cursor={existingRunCursor} />;
1276
- } else if (screen === 'harnesses') {
1277
- content = <HarnessScreen cursor={harnessIndex} />;
1278
- } else if (screen === 'scope') {
1279
- content = <ScopeScreen rows={rows} cursor={scopeCursor} height={viewHeight} />;
1280
- } else if (screen === 'report' && report) {
1281
- content = (
1282
- <ReportScreen report={report} rows={generatedRows} cursor={runCursor} height={viewHeight} />
1283
- );
1284
- } else if (screen === 'error') {
1285
- content = (
1286
- <Box flexDirection="column">
1287
- <Text color="red" bold>
1288
- Mint test stopped
1289
- </Text>
1290
- <Text>{error ?? 'Unknown error'}</Text>
1291
- </Box>
1292
- );
1293
- } else {
1294
- content = (
1295
- <ActivityScreen
1296
- stage={activeRunStage}
1297
- cancelling={cancelling}
1298
- harness={runHarness}
1299
- docsRoot={docsRoot}
1300
- rows={runRows}
1301
- generatedRows={generatedRows}
1302
- cursor={runCursor}
1303
- phases={phasesByFile}
1304
- height={viewHeight}
1305
- totalTasks={totalTasks}
1306
- spinnerFrame={spinnerFrame}
1307
- runStartedAt={runStartedAt}
1308
- now={now}
1309
- />
1310
- );
1311
- }
1312
-
1313
- return (
1314
- <Box flexDirection="column" width={size.columns} height={viewHeight} overflow="hidden">
1315
- <Box flexGrow={1} gap={1} overflow="hidden">
1316
- <Box
1317
- width={leftWidth}
1318
- flexShrink={0}
1319
- borderStyle="round"
1320
- borderColor="gray"
1321
- paddingX={1}
1322
- flexDirection="column"
1323
- >
1324
- <Text bold>Steps</Text>
1325
- {STEPS.map((step) => {
1326
- const state = stepState(step.id, screen, activeRunStage);
1327
- return (
1328
- <Text key={step.id} color={state === 'running' ? 'cyan' : undefined}>
1329
- <StepGlyph state={state} /> {step.label}
1330
- </Text>
1331
- );
1332
- })}
1333
- </Box>
1334
- <Box
1335
- flexGrow={1}
1336
- borderStyle={fullBleed ? undefined : 'round'}
1337
- borderColor="gray"
1338
- paddingX={fullBleed ? 0 : 1}
1339
- flexDirection="column"
1340
- overflow="hidden"
1341
- >
1342
- {content}
1343
- </Box>
1344
- </Box>
1345
- <Text dimColor>
1346
- {ctrlCArmed ? (
1347
- <FooterHints text="Ctrl-C again to exit" />
1348
- ) : (
1349
- <FooterHints
1350
- text={footerForScreen(
1351
- screen,
1352
- running,
1353
- report?.status === 'cancelled',
1354
- activeRunStage,
1355
- reviewingSavedRun
1356
- )}
1357
- />
1358
- )}
1359
- </Text>
1360
- </Box>
1361
- );
1362
- }
1363
-
1364
- export async function runMintTestUi(options: Omit<MintTestUiProps, 'onFinish'>): Promise<number> {
1365
- process.stdout.write('\u001B[?1049h\u001B[?25l');
1366
- let instance: ReturnType<typeof render> | undefined;
1367
- try {
1368
- return await new Promise<number>((resolve) => {
1369
- instance = render(<MintTestApp {...options} onFinish={resolve} />, {
1370
- exitOnCtrlC: false,
1371
- patchConsole: false,
1372
- });
1373
- });
1374
- } finally {
1375
- instance?.unmount();
1376
- process.stdout.write('\u001B[?25h\u001B[?1049l');
1377
- }
1378
- }