openreport 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/bin/openreport.ts +6 -0
  4. package/package.json +61 -0
  5. package/src/agents/api-documentation.ts +66 -0
  6. package/src/agents/architecture-analyst.ts +46 -0
  7. package/src/agents/code-quality-reviewer.ts +59 -0
  8. package/src/agents/dependency-analyzer.ts +51 -0
  9. package/src/agents/onboarding-guide.ts +59 -0
  10. package/src/agents/orchestrator.ts +41 -0
  11. package/src/agents/performance-analyzer.ts +57 -0
  12. package/src/agents/registry.ts +50 -0
  13. package/src/agents/security-auditor.ts +61 -0
  14. package/src/agents/test-coverage-analyst.ts +58 -0
  15. package/src/agents/todo-generator.ts +50 -0
  16. package/src/app/App.tsx +151 -0
  17. package/src/app/theme.ts +54 -0
  18. package/src/cli.ts +145 -0
  19. package/src/commands/init.ts +81 -0
  20. package/src/commands/interactive.tsx +29 -0
  21. package/src/commands/list.ts +53 -0
  22. package/src/commands/run.ts +168 -0
  23. package/src/commands/view.tsx +52 -0
  24. package/src/components/generation/AgentStatusItem.tsx +125 -0
  25. package/src/components/generation/AgentStatusList.tsx +70 -0
  26. package/src/components/generation/ProgressSummary.tsx +107 -0
  27. package/src/components/generation/StreamingOutput.tsx +154 -0
  28. package/src/components/layout/Container.tsx +24 -0
  29. package/src/components/layout/Footer.tsx +52 -0
  30. package/src/components/layout/Header.tsx +50 -0
  31. package/src/components/report/MarkdownRenderer.tsx +50 -0
  32. package/src/components/report/ReportCard.tsx +31 -0
  33. package/src/components/report/ScrollableView.tsx +164 -0
  34. package/src/config/cli-detection.ts +130 -0
  35. package/src/config/cli-model.ts +397 -0
  36. package/src/config/cli-prompt-formatter.ts +129 -0
  37. package/src/config/defaults.ts +79 -0
  38. package/src/config/loader.ts +168 -0
  39. package/src/config/ollama.ts +48 -0
  40. package/src/config/providers.ts +199 -0
  41. package/src/config/resolve-provider.ts +62 -0
  42. package/src/config/saver.ts +50 -0
  43. package/src/config/schema.ts +51 -0
  44. package/src/errors.ts +34 -0
  45. package/src/hooks/useReportGeneration.ts +199 -0
  46. package/src/hooks/useTerminalSize.ts +35 -0
  47. package/src/ingestion/context-selector.ts +247 -0
  48. package/src/ingestion/file-tree.ts +227 -0
  49. package/src/ingestion/token-budget.ts +52 -0
  50. package/src/pipeline/agent-runner.ts +360 -0
  51. package/src/pipeline/combiner.ts +199 -0
  52. package/src/pipeline/context.ts +108 -0
  53. package/src/pipeline/extraction.ts +153 -0
  54. package/src/pipeline/progress.ts +192 -0
  55. package/src/pipeline/runner.ts +526 -0
  56. package/src/report/html-renderer.ts +294 -0
  57. package/src/report/html-script.ts +123 -0
  58. package/src/report/html-styles.ts +1127 -0
  59. package/src/report/md-to-html.ts +153 -0
  60. package/src/report/open-browser.ts +22 -0
  61. package/src/schemas/findings.ts +48 -0
  62. package/src/schemas/report.ts +64 -0
  63. package/src/screens/ConfigScreen.tsx +271 -0
  64. package/src/screens/GenerationScreen.tsx +278 -0
  65. package/src/screens/HistoryScreen.tsx +108 -0
  66. package/src/screens/HomeScreen.tsx +143 -0
  67. package/src/screens/ViewerScreen.tsx +82 -0
  68. package/src/storage/metadata.ts +69 -0
  69. package/src/storage/report-store.ts +128 -0
  70. package/src/tools/get-file-tree.ts +157 -0
  71. package/src/tools/get-git-info.ts +123 -0
  72. package/src/tools/glob.ts +48 -0
  73. package/src/tools/grep.ts +149 -0
  74. package/src/tools/index.ts +30 -0
  75. package/src/tools/list-directory.ts +57 -0
  76. package/src/tools/read-file.ts +52 -0
  77. package/src/tools/read-package-json.ts +48 -0
  78. package/src/tools/run-command.ts +154 -0
  79. package/src/tools/shared-ignore.ts +58 -0
  80. package/src/types/index.ts +127 -0
  81. package/src/types/marked-terminal.d.ts +17 -0
  82. package/src/utils/debug.ts +25 -0
  83. package/src/utils/file-utils.ts +77 -0
  84. package/src/utils/format.ts +56 -0
  85. package/src/utils/grade-colors.ts +43 -0
  86. package/src/utils/project-detector.ts +296 -0
@@ -0,0 +1,278 @@
1
+ import React from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { Header } from "../components/layout/Header.js";
4
+ import { Footer } from "../components/layout/Footer.js";
5
+ import { AgentStatusList } from "../components/generation/AgentStatusList.js";
6
+ import { StreamingOutput } from "../components/generation/StreamingOutput.js";
7
+ import { ProgressSummary } from "../components/generation/ProgressSummary.js";
8
+ import { useTerminalSize } from "../hooks/useTerminalSize.js";
9
+ import { useReportGeneration } from "../hooks/useReportGeneration.js";
10
+ import { getReportFilePath } from "../storage/report-store.js";
11
+ import { openInBrowser } from "../report/open-browser.js";
12
+ import { REPORT_TYPES } from "../config/defaults.js";
13
+ import { formatDuration, formatTokens } from "../utils/format.js";
14
+ import { getGradeTerminalColor } from "../utils/grade-colors.js";
15
+ import type { LanguageModelV1 } from "ai";
16
+ import type { Screen } from "../types/index.js";
17
+ import type { OpenReportConfig } from "../config/schema.js";
18
+
19
+ interface GenerationScreenProps {
20
+ projectRoot: string;
21
+ reportType: string;
22
+ model: LanguageModelV1;
23
+ modelId: string;
24
+ config: OpenReportConfig;
25
+ onNavigate: (screen: Screen, params?: Record<string, string>) => void;
26
+ generateTodoList?: boolean;
27
+ }
28
+
29
+ // Height consumed by header (2 lines: text + separator) and footer (2 lines: separator + text)
30
+ const CHROME_HEIGHT = 4;
31
+ // Height for progress summary area (3 lines: phase + bar + stats)
32
+ const PROGRESS_HEIGHT = 3;
33
+ // Height for title bar
34
+ const TITLE_HEIGHT = 1;
35
+
36
+ export function GenerationScreen({
37
+ projectRoot,
38
+ reportType,
39
+ model,
40
+ modelId,
41
+ config,
42
+ onNavigate,
43
+ generateTodoList,
44
+ }: GenerationScreenProps) {
45
+ const { columns, rows } = useTerminalSize();
46
+
47
+ const {
48
+ phase,
49
+ phaseDetail,
50
+ agents,
51
+ agentStreams,
52
+ tokens,
53
+ elapsed,
54
+ report,
55
+ error,
56
+ abort,
57
+ } = useReportGeneration({ projectRoot, reportType, model, modelId, config, generateTodoList });
58
+
59
+ const reportTypeName =
60
+ REPORT_TYPES[reportType as keyof typeof REPORT_TYPES]?.name || reportType;
61
+
62
+ useInput((input, key) => {
63
+ if (key.escape) {
64
+ abort();
65
+ onNavigate("home");
66
+ } else if (input === "v" && report) {
67
+ onNavigate("viewer", { reportId: report.metadata.id });
68
+ } else if (input === "o" && report) {
69
+ getReportFilePath(projectRoot, report.metadata.id, ".html").then((htmlPath) => {
70
+ if (htmlPath) openInBrowser(htmlPath);
71
+ });
72
+ }
73
+ });
74
+
75
+ const completedCount = agents.filter(
76
+ (a) =>
77
+ a.status === "completed" ||
78
+ a.status === "failed" ||
79
+ a.status === "skipped"
80
+ ).length;
81
+
82
+ const totalTokens = tokens.input + tokens.output;
83
+
84
+ // Build agent name lookup for streaming panels
85
+ const agentNames = React.useMemo(() => {
86
+ const names: Record<string, string> = {};
87
+ for (const agent of agents) {
88
+ names[agent.agentId] = agent.agentName;
89
+ }
90
+ return names;
91
+ }, [agents]);
92
+
93
+ // Calculate available content height:
94
+ // total rows - chrome (header/footer) - title - progress - padding
95
+ const agentListHeight = agents.length + 1; // +1 for the header row
96
+ const contentHeight = rows - CHROME_HEIGHT - TITLE_HEIGHT - 2; // 2 for padding
97
+ const streamingAvailableHeight = Math.max(
98
+ 3,
99
+ contentHeight - agentListHeight - PROGRESS_HEIGHT - 2
100
+ );
101
+
102
+ const actions = React.useMemo(
103
+ () =>
104
+ report
105
+ ? [
106
+ { key: "v", label: "View Report" },
107
+ { key: "o", label: "Open in Browser" },
108
+ { key: "esc", label: "Back" },
109
+ ]
110
+ : [{ key: "esc", label: "Cancel" }],
111
+ [report],
112
+ );
113
+
114
+ const statusText = React.useMemo(
115
+ () =>
116
+ !report && !error
117
+ ? `${formatDuration(elapsed)} | ${formatTokens(totalTokens)} tokens`
118
+ : undefined,
119
+ [report, error, elapsed, totalTokens],
120
+ );
121
+
122
+ // Phase label for header
123
+ const phaseLabel =
124
+ phase === "done"
125
+ ? "Complete"
126
+ : phase === "running-agents"
127
+ ? reportTypeName
128
+ : phase === "generating-todo"
129
+ ? "Generating Todo List"
130
+ : phase === "error"
131
+ ? "Error"
132
+ : reportTypeName;
133
+
134
+ return (
135
+ <Box flexDirection="column" width={columns} height={rows}>
136
+ {/* Header pinned at top */}
137
+ <Header modelName={modelId} phase={phaseLabel} />
138
+
139
+ {/* Main content area fills the middle */}
140
+ <Box
141
+ flexDirection="column"
142
+ flexGrow={1}
143
+ paddingX={1}
144
+ paddingY={0}
145
+ >
146
+ {error ? (
147
+ /* Error state */
148
+ <Box flexDirection="column" paddingY={1} gap={1}>
149
+ <Box gap={1}>
150
+ <Text color="red" bold>
151
+ ✗ Error
152
+ </Text>
153
+ </Box>
154
+ <Box
155
+ borderStyle="round"
156
+ borderColor="red"
157
+ paddingX={1}
158
+ paddingY={0}
159
+ >
160
+ <Text color="red" wrap="wrap">
161
+ {error}
162
+ </Text>
163
+ </Box>
164
+ <Text color="gray">
165
+ Check your provider configuration and try again.
166
+ </Text>
167
+ </Box>
168
+ ) : report ? (
169
+ /* Complete state */
170
+ <Box flexDirection="column" paddingY={1} gap={1}>
171
+ <Box gap={1}>
172
+ <Text color="green" bold>
173
+ ✓ Report generated successfully
174
+ </Text>
175
+ </Box>
176
+
177
+ <Box flexDirection="column" gap={0}>
178
+ <Box gap={1}>
179
+ <Text color="gray" dimColor>
180
+ ID:
181
+ </Text>
182
+ <Text>{report.metadata.id}</Text>
183
+ </Box>
184
+ <Box gap={1}>
185
+ <Text color="gray" dimColor>
186
+ Grade:
187
+ </Text>
188
+ <Text
189
+ bold
190
+ color={getGradeTerminalColor(report.metadata.grade || "B")}
191
+ >
192
+ {report.metadata.grade || "—"}
193
+ </Text>
194
+ </Box>
195
+ <Box gap={1}>
196
+ <Text color="gray" dimColor>
197
+ Duration:
198
+ </Text>
199
+ <Text>{formatDuration(elapsed)}</Text>
200
+ </Box>
201
+ <Box gap={1}>
202
+ <Text color="gray" dimColor>
203
+ Tokens:
204
+ </Text>
205
+ <Text>{formatTokens(totalTokens)}</Text>
206
+ </Box>
207
+ {report.findingSummary && (
208
+ <Box gap={2} marginTop={1}>
209
+ {report.findingSummary.critical > 0 && (
210
+ <Text color="red" bold>
211
+ {report.findingSummary.critical} critical
212
+ </Text>
213
+ )}
214
+ {report.findingSummary.warning > 0 && (
215
+ <Text color="yellow">
216
+ {report.findingSummary.warning} warnings
217
+ </Text>
218
+ )}
219
+ {report.findingSummary.info > 0 && (
220
+ <Text color="cyan">
221
+ {report.findingSummary.info} info
222
+ </Text>
223
+ )}
224
+ {report.findingSummary.suggestion > 0 && (
225
+ <Text color="green">
226
+ {report.findingSummary.suggestion} suggestions
227
+ </Text>
228
+ )}
229
+ </Box>
230
+ )}
231
+ </Box>
232
+
233
+ {agents.length > 0 && <AgentStatusList agents={agents} />}
234
+ </Box>
235
+ ) : (
236
+ /* In-progress state */
237
+ <Box flexDirection="column" paddingY={0} gap={0}>
238
+ {/* Agent status list */}
239
+ {agents.length > 0 && <AgentStatusList agents={agents} />}
240
+
241
+ {/* Per-agent streaming output panels */}
242
+ {Object.keys(agentStreams).length > 0 && (
243
+ <Box marginTop={1}>
244
+ <StreamingOutput
245
+ agentStreams={agentStreams}
246
+ agentNames={agentNames}
247
+ maxLinesPerAgent={Math.max(
248
+ 2,
249
+ Math.min(6, streamingAvailableHeight - 2)
250
+ )}
251
+ availableHeight={streamingAvailableHeight}
252
+ />
253
+ </Box>
254
+ )}
255
+
256
+ {/* Progress summary pinned above footer */}
257
+ <Box marginTop={1}>
258
+ <ProgressSummary
259
+ completed={completedCount}
260
+ total={agents.length || 1}
261
+ tokens={tokens}
262
+ elapsed={elapsed}
263
+ phase={phase}
264
+ phaseDetail={phaseDetail}
265
+ />
266
+ </Box>
267
+ </Box>
268
+ )}
269
+ </Box>
270
+
271
+ {/* Footer pinned at bottom */}
272
+ <Footer
273
+ actions={actions}
274
+ statusText={statusText}
275
+ />
276
+ </Box>
277
+ );
278
+ }
@@ -0,0 +1,108 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { Header } from "../components/layout/Header.js";
4
+ import { Footer } from "../components/layout/Footer.js";
5
+ import { Container } from "../components/layout/Container.js";
6
+ import { ReportCard } from "../components/report/ReportCard.js";
7
+ import { listReports, deleteReport } from "../storage/report-store.js";
8
+ import { useTerminalSize } from "../hooks/useTerminalSize.js";
9
+ import type { ReportMetadata, Screen } from "../types/index.js";
10
+
11
+ interface HistoryScreenProps {
12
+ projectRoot: string;
13
+ onNavigate: (screen: Screen, params?: Record<string, string>) => void;
14
+ }
15
+
16
+ export function HistoryScreen({
17
+ projectRoot,
18
+ onNavigate,
19
+ }: HistoryScreenProps) {
20
+ const [reports, setReports] = useState<ReportMetadata[]>([]);
21
+ const [selectedIndex, setSelectedIndex] = useState(0);
22
+ const [confirmDelete, setConfirmDelete] = useState(false);
23
+
24
+ useEffect(() => {
25
+ listReports(projectRoot, 50).then(setReports);
26
+ }, [projectRoot]);
27
+
28
+ useInput((input, key) => {
29
+ if (confirmDelete) {
30
+ if (input === "y" || input === "Y") {
31
+ const report = reports[selectedIndex];
32
+ if (report) {
33
+ deleteReport(projectRoot, report.id).then(() =>
34
+ listReports(projectRoot, 50).then((updated) => {
35
+ setReports(updated);
36
+ setSelectedIndex(Math.min(selectedIndex, updated.length - 1));
37
+ })
38
+ );
39
+ }
40
+ setConfirmDelete(false);
41
+ } else {
42
+ setConfirmDelete(false);
43
+ }
44
+ return;
45
+ }
46
+
47
+ if (key.upArrow) {
48
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
49
+ } else if (key.downArrow) {
50
+ setSelectedIndex((prev) => Math.min(reports.length - 1, prev + 1));
51
+ } else if (key.return) {
52
+ const report = reports[selectedIndex];
53
+ if (report) {
54
+ onNavigate("viewer", { reportId: report.id });
55
+ }
56
+ } else if (input === "d") {
57
+ if (reports.length > 0) {
58
+ setConfirmDelete(true);
59
+ }
60
+ } else if (key.escape || input === "q") {
61
+ onNavigate("home");
62
+ }
63
+ });
64
+
65
+ const { columns, rows } = useTerminalSize();
66
+
67
+ return (
68
+ <Box flexDirection="column" width={columns} height={rows}>
69
+ <Header />
70
+
71
+ <Container>
72
+ <Text bold>Past Reports</Text>
73
+
74
+ {reports.length === 0 ? (
75
+ <Box paddingY={1}>
76
+ <Text color="gray">No reports found. Generate your first report from the home screen.</Text>
77
+ </Box>
78
+ ) : (
79
+ <Box flexDirection="column" paddingY={1}>
80
+ {reports.map((report, i) => (
81
+ <ReportCard
82
+ key={report.id}
83
+ metadata={report}
84
+ selected={i === selectedIndex}
85
+ />
86
+ ))}
87
+ </Box>
88
+ )}
89
+
90
+ {confirmDelete && (
91
+ <Box paddingY={1}>
92
+ <Text color="red">
93
+ Delete this report? (y/n)
94
+ </Text>
95
+ </Box>
96
+ )}
97
+ </Container>
98
+
99
+ <Footer
100
+ actions={[
101
+ { key: "enter", label: "View" },
102
+ { key: "d", label: "Delete" },
103
+ { key: "esc", label: "Back" },
104
+ ]}
105
+ />
106
+ </Box>
107
+ );
108
+ }
@@ -0,0 +1,143 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { Header } from "../components/layout/Header.js";
4
+ import { Footer } from "../components/layout/Footer.js";
5
+ import { Container } from "../components/layout/Container.js";
6
+ import { REPORT_TYPES } from "../config/defaults.js";
7
+ import { detectProject } from "../utils/project-detector.js";
8
+ import { listReports } from "../storage/report-store.js";
9
+ import { useTerminalSize } from "../hooks/useTerminalSize.js";
10
+ import type { ProjectClassification, Screen } from "../types/index.js";
11
+ import type { OpenReportConfig } from "../config/schema.js";
12
+
13
+ interface HomeScreenProps {
14
+ projectRoot: string;
15
+ modelName: string;
16
+ config: OpenReportConfig;
17
+ onNavigate: (screen: Screen, params?: Record<string, string>) => void;
18
+ onQuit: () => void;
19
+ }
20
+
21
+ const menuItems = [
22
+ ...Object.values(REPORT_TYPES).map((rt) => ({
23
+ id: rt.id,
24
+ label: rt.name,
25
+ description: rt.description,
26
+ type: "report" as const,
27
+ })),
28
+ { id: "separator", label: "─────", description: "", type: "separator" as const },
29
+ { id: "history", label: "View Past Reports", description: "", type: "action" as const },
30
+ { id: "settings", label: "Settings", description: "", type: "action" as const },
31
+ ];
32
+
33
+ export function HomeScreen({
34
+ projectRoot,
35
+ modelName,
36
+ config,
37
+ onNavigate,
38
+ onQuit,
39
+ }: HomeScreenProps) {
40
+ const [selectedIndex, setSelectedIndex] = useState(0);
41
+ const [classification, setClassification] =
42
+ useState<ProjectClassification | null>(null);
43
+ const [reportCount, setReportCount] = useState(0);
44
+ const [todoListEnabled, setTodoListEnabled] = useState(config.features?.todoList ?? false);
45
+
46
+ useEffect(() => {
47
+ detectProject(projectRoot).then(setClassification).catch(() => setClassification(null));
48
+ listReports(projectRoot).then((reports) => setReportCount(reports.length));
49
+ }, [projectRoot]);
50
+
51
+ const selectableItems = menuItems.filter((item) => item.type !== "separator");
52
+
53
+ useInput((input, key) => {
54
+ if (key.upArrow) {
55
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
56
+ } else if (key.downArrow) {
57
+ setSelectedIndex((prev) =>
58
+ Math.min(selectableItems.length - 1, prev + 1)
59
+ );
60
+ } else if (input === "t") {
61
+ setTodoListEnabled((prev) => !prev);
62
+ } else if (key.return) {
63
+ const item = selectableItems[selectedIndex];
64
+ if (item.type === "report") {
65
+ onNavigate("generation", { reportType: item.id, todoList: todoListEnabled ? "true" : "false" });
66
+ } else if (item.id === "history") {
67
+ onNavigate("history");
68
+ } else if (item.id === "settings") {
69
+ onNavigate("config");
70
+ }
71
+ } else if (input === "c") {
72
+ onNavigate("config");
73
+ } else if (input === "q") {
74
+ onQuit();
75
+ }
76
+ });
77
+
78
+ const projectInfo = classification
79
+ ? `${classification.framework || classification.language}${classification.language === "typescript" ? " + TypeScript" : ""}`
80
+ : "Detecting...";
81
+
82
+ const { columns, rows } = useTerminalSize();
83
+ let selectableIdx = 0;
84
+
85
+ return (
86
+ <Box flexDirection="column" width={columns} height={rows}>
87
+ <Header modelName={modelName} />
88
+
89
+ <Container>
90
+ <Box paddingY={0}>
91
+ <Text color="gray">
92
+ {projectRoot} ({projectInfo})
93
+ </Text>
94
+ </Box>
95
+
96
+ <Box
97
+ flexDirection="column"
98
+ paddingY={1}
99
+ borderStyle="single"
100
+ borderColor="gray"
101
+ paddingX={1}
102
+ >
103
+ {menuItems.map((item) => {
104
+ if (item.type === "separator") {
105
+ return (
106
+ <Text key={item.id} color="gray">
107
+ {item.label}
108
+ </Text>
109
+ );
110
+ }
111
+
112
+ const currentIdx = selectableIdx++;
113
+ const isSelected = currentIdx === selectedIndex;
114
+ const label =
115
+ item.id === "history"
116
+ ? `${item.label} (${reportCount})`
117
+ : item.label;
118
+
119
+ return (
120
+ <Box key={item.id} flexDirection="row" gap={1}>
121
+ <Text color={isSelected ? "yellow" : "gray"}>
122
+ {isSelected ? ">" : " "}
123
+ </Text>
124
+ <Text bold={isSelected} color={isSelected ? "white" : undefined}>
125
+ {label}
126
+ </Text>
127
+ </Box>
128
+ );
129
+ })}
130
+ </Box>
131
+ </Container>
132
+
133
+ <Footer
134
+ actions={[
135
+ { key: "enter", label: "Select" },
136
+ { key: "t", label: `Todo ${todoListEnabled ? "ON" : "OFF"}` },
137
+ { key: "c", label: "Config" },
138
+ { key: "q", label: "Quit" },
139
+ ]}
140
+ />
141
+ </Box>
142
+ );
143
+ }
@@ -0,0 +1,82 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { Header } from "../components/layout/Header.js";
4
+ import { Footer } from "../components/layout/Footer.js";
5
+ import { Container } from "../components/layout/Container.js";
6
+ import { ScrollableView } from "../components/report/ScrollableView.js";
7
+ import { renderMarkdown } from "../components/report/MarkdownRenderer.js";
8
+ import { loadReport, getReportFilePath } from "../storage/report-store.js";
9
+ import { openInBrowser } from "../report/open-browser.js";
10
+ import { useTerminalSize } from "../hooks/useTerminalSize.js";
11
+ import type { Screen } from "../types/index.js";
12
+
13
+ interface ViewerScreenProps {
14
+ projectRoot: string;
15
+ reportId: string;
16
+ onNavigate: (screen: Screen, params?: Record<string, string>) => void;
17
+ }
18
+
19
+ export function ViewerScreen({
20
+ projectRoot,
21
+ reportId,
22
+ onNavigate,
23
+ }: ViewerScreenProps) {
24
+ const { columns, rows } = useTerminalSize();
25
+ const [content, setContent] = useState<string | null>(null);
26
+ const [rendered, setRendered] = useState<string>("");
27
+
28
+ useEffect(() => {
29
+ loadReport(projectRoot, reportId).then((md) => {
30
+ if (md) {
31
+ setContent(md);
32
+ setRendered(renderMarkdown(md));
33
+ }
34
+ });
35
+ }, [projectRoot, reportId, columns]);
36
+
37
+ useInput((input) => {
38
+ if (input === "o") {
39
+ getReportFilePath(projectRoot, reportId, ".html").then((htmlPath) => {
40
+ if (htmlPath) openInBrowser(htmlPath);
41
+ });
42
+ }
43
+ });
44
+
45
+ if (!content) {
46
+ return (
47
+ <Box flexDirection="column" width={columns} height={rows}>
48
+ <Header />
49
+ <Container>
50
+ <Text color="red">Report not found: {reportId}</Text>
51
+ </Container>
52
+ <Footer actions={[{ key: "esc", label: "Back" }]} />
53
+ </Box>
54
+ );
55
+ }
56
+
57
+ // Available height for scrollable content: total rows - header(2) - footer(2) - padding(2)
58
+ const viewHeight = rows - 6;
59
+
60
+ return (
61
+ <Box flexDirection="column" width={columns} height={rows}>
62
+ <Header />
63
+
64
+ <Container>
65
+ <ScrollableView
66
+ content={rendered}
67
+ height={viewHeight}
68
+ onExit={() => onNavigate("home")}
69
+ />
70
+ </Container>
71
+
72
+ <Footer
73
+ actions={[
74
+ { key: "↑↓", label: "Scroll" },
75
+ { key: "/", label: "Search" },
76
+ { key: "o", label: "Browser" },
77
+ { key: "q", label: "Back" },
78
+ ]}
79
+ />
80
+ </Box>
81
+ );
82
+ }
@@ -0,0 +1,69 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { ReportMetadataSchema } from "../schemas/report.js";
4
+ import type { ReportMetadata } from "../types/index.js";
5
+ import { debugLog } from "../utils/debug.js";
6
+
7
+ const SAFE_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
8
+
9
+ function validateReportId(id: string): void {
10
+ if (!SAFE_ID_REGEX.test(id)) {
11
+ throw new Error(`Invalid report ID: ${id}`);
12
+ }
13
+ }
14
+
15
+ export async function saveMetadata(
16
+ reportsDir: string,
17
+ metadata: ReportMetadata
18
+ ): Promise<void> {
19
+ validateReportId(metadata.id);
20
+ const metaPath = path.join(reportsDir, `${metadata.id}.meta.json`);
21
+ await fs.promises.mkdir(reportsDir, { recursive: true });
22
+ await fs.promises.writeFile(metaPath, JSON.stringify(metadata, null, 2), "utf-8");
23
+ }
24
+
25
+ export async function loadMetadata(metaPath: string): Promise<ReportMetadata | null> {
26
+ try {
27
+ const content = await fs.promises.readFile(metaPath, "utf-8");
28
+ return ReportMetadataSchema.parse(JSON.parse(content));
29
+ } catch (e) {
30
+ debugLog("metadata:read", e);
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export async function listMetadata(reportsDir: string, limit?: number): Promise<ReportMetadata[]> {
36
+ try {
37
+ try { await fs.promises.access(reportsDir); } catch { return []; }
38
+
39
+ const allFiles = await fs.promises.readdir(reportsDir);
40
+ const files = allFiles.filter((f) => f.endsWith(".meta.json"));
41
+
42
+ const results = await Promise.all(
43
+ files.map((file) => loadMetadata(path.join(reportsDir, file)))
44
+ );
45
+
46
+ const metadataList = results.filter((m): m is ReportMetadata => m !== null);
47
+
48
+ metadataList.sort(
49
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
50
+ );
51
+
52
+ return limit ? metadataList.slice(0, limit) : metadataList;
53
+ } catch (e) {
54
+ debugLog("metadata:list", e);
55
+ return [];
56
+ }
57
+ }
58
+
59
+ export async function deleteMetadata(reportsDir: string, id: string): Promise<boolean> {
60
+ validateReportId(id);
61
+ try {
62
+ const metaPath = path.join(reportsDir, `${id}.meta.json`);
63
+ await fs.promises.unlink(metaPath);
64
+ return true;
65
+ } catch (e) {
66
+ debugLog("metadata:delete", e);
67
+ return false;
68
+ }
69
+ }