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.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/bin/openreport.ts +6 -0
- package/package.json +61 -0
- package/src/agents/api-documentation.ts +66 -0
- package/src/agents/architecture-analyst.ts +46 -0
- package/src/agents/code-quality-reviewer.ts +59 -0
- package/src/agents/dependency-analyzer.ts +51 -0
- package/src/agents/onboarding-guide.ts +59 -0
- package/src/agents/orchestrator.ts +41 -0
- package/src/agents/performance-analyzer.ts +57 -0
- package/src/agents/registry.ts +50 -0
- package/src/agents/security-auditor.ts +61 -0
- package/src/agents/test-coverage-analyst.ts +58 -0
- package/src/agents/todo-generator.ts +50 -0
- package/src/app/App.tsx +151 -0
- package/src/app/theme.ts +54 -0
- package/src/cli.ts +145 -0
- package/src/commands/init.ts +81 -0
- package/src/commands/interactive.tsx +29 -0
- package/src/commands/list.ts +53 -0
- package/src/commands/run.ts +168 -0
- package/src/commands/view.tsx +52 -0
- package/src/components/generation/AgentStatusItem.tsx +125 -0
- package/src/components/generation/AgentStatusList.tsx +70 -0
- package/src/components/generation/ProgressSummary.tsx +107 -0
- package/src/components/generation/StreamingOutput.tsx +154 -0
- package/src/components/layout/Container.tsx +24 -0
- package/src/components/layout/Footer.tsx +52 -0
- package/src/components/layout/Header.tsx +50 -0
- package/src/components/report/MarkdownRenderer.tsx +50 -0
- package/src/components/report/ReportCard.tsx +31 -0
- package/src/components/report/ScrollableView.tsx +164 -0
- package/src/config/cli-detection.ts +130 -0
- package/src/config/cli-model.ts +397 -0
- package/src/config/cli-prompt-formatter.ts +129 -0
- package/src/config/defaults.ts +79 -0
- package/src/config/loader.ts +168 -0
- package/src/config/ollama.ts +48 -0
- package/src/config/providers.ts +199 -0
- package/src/config/resolve-provider.ts +62 -0
- package/src/config/saver.ts +50 -0
- package/src/config/schema.ts +51 -0
- package/src/errors.ts +34 -0
- package/src/hooks/useReportGeneration.ts +199 -0
- package/src/hooks/useTerminalSize.ts +35 -0
- package/src/ingestion/context-selector.ts +247 -0
- package/src/ingestion/file-tree.ts +227 -0
- package/src/ingestion/token-budget.ts +52 -0
- package/src/pipeline/agent-runner.ts +360 -0
- package/src/pipeline/combiner.ts +199 -0
- package/src/pipeline/context.ts +108 -0
- package/src/pipeline/extraction.ts +153 -0
- package/src/pipeline/progress.ts +192 -0
- package/src/pipeline/runner.ts +526 -0
- package/src/report/html-renderer.ts +294 -0
- package/src/report/html-script.ts +123 -0
- package/src/report/html-styles.ts +1127 -0
- package/src/report/md-to-html.ts +153 -0
- package/src/report/open-browser.ts +22 -0
- package/src/schemas/findings.ts +48 -0
- package/src/schemas/report.ts +64 -0
- package/src/screens/ConfigScreen.tsx +271 -0
- package/src/screens/GenerationScreen.tsx +278 -0
- package/src/screens/HistoryScreen.tsx +108 -0
- package/src/screens/HomeScreen.tsx +143 -0
- package/src/screens/ViewerScreen.tsx +82 -0
- package/src/storage/metadata.ts +69 -0
- package/src/storage/report-store.ts +128 -0
- package/src/tools/get-file-tree.ts +157 -0
- package/src/tools/get-git-info.ts +123 -0
- package/src/tools/glob.ts +48 -0
- package/src/tools/grep.ts +149 -0
- package/src/tools/index.ts +30 -0
- package/src/tools/list-directory.ts +57 -0
- package/src/tools/read-file.ts +52 -0
- package/src/tools/read-package-json.ts +48 -0
- package/src/tools/run-command.ts +154 -0
- package/src/tools/shared-ignore.ts +58 -0
- package/src/types/index.ts +127 -0
- package/src/types/marked-terminal.d.ts +17 -0
- package/src/utils/debug.ts +25 -0
- package/src/utils/file-utils.ts +77 -0
- package/src/utils/format.ts +56 -0
- package/src/utils/grade-colors.ts +43 -0
- 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
|
+
}
|