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,168 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { loadConfig } from "../config/loader.js";
|
|
5
|
+
import { createProviderRegistry } from "../config/providers.js";
|
|
6
|
+
import { resolveProvider } from "../config/resolve-provider.js";
|
|
7
|
+
import { ProgressTracker } from "../pipeline/progress.js";
|
|
8
|
+
import { runPipeline } from "../pipeline/runner.js";
|
|
9
|
+
import { saveReport } from "../storage/report-store.js";
|
|
10
|
+
import { REPORT_TYPES } from "../config/defaults.js";
|
|
11
|
+
import { formatDuration, formatTokens } from "../utils/format.js";
|
|
12
|
+
import type { AgentStatus, PipelinePhase } from "../types/index.js";
|
|
13
|
+
|
|
14
|
+
function logPhase(msg: string) {
|
|
15
|
+
process.stderr.write(`\r${chalk.cyan("\u25cf")} ${msg} `);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function logDone(msg: string) {
|
|
19
|
+
process.stderr.write(`\r${chalk.green("\u2714")} ${msg}\n`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RunOptions {
|
|
23
|
+
model?: string;
|
|
24
|
+
provider?: string;
|
|
25
|
+
output?: string;
|
|
26
|
+
format?: "markdown" | "json";
|
|
27
|
+
quiet?: boolean;
|
|
28
|
+
todo?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runHeadless(
|
|
32
|
+
projectRoot: string,
|
|
33
|
+
reportType: string,
|
|
34
|
+
options: RunOptions = {}
|
|
35
|
+
) {
|
|
36
|
+
// Validate report type
|
|
37
|
+
const validTypes = Object.keys(REPORT_TYPES);
|
|
38
|
+
if (!validTypes.includes(reportType)) {
|
|
39
|
+
console.error(
|
|
40
|
+
chalk.red(`Unknown report type: ${reportType}`)
|
|
41
|
+
);
|
|
42
|
+
console.log(
|
|
43
|
+
chalk.gray(`Valid types: ${validTypes.join(", ")}`)
|
|
44
|
+
);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const config = await loadConfig({
|
|
49
|
+
projectRoot,
|
|
50
|
+
cliFlags: {
|
|
51
|
+
...(options.model ? { defaultModel: options.model } : {}),
|
|
52
|
+
...(options.provider ? { defaultProvider: options.provider } : {}),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const registry = createProviderRegistry(config);
|
|
57
|
+
let model;
|
|
58
|
+
let modelId;
|
|
59
|
+
if (options.provider) {
|
|
60
|
+
model = registry.getModel(config);
|
|
61
|
+
modelId = config.defaultModel;
|
|
62
|
+
} else {
|
|
63
|
+
const resolved = await resolveProvider(config, registry);
|
|
64
|
+
model = resolved.model;
|
|
65
|
+
modelId = resolved.effectiveModel;
|
|
66
|
+
config.defaultProvider = resolved.effectiveProvider;
|
|
67
|
+
config.defaultModel = resolved.effectiveModel;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const progress = new ProgressTracker();
|
|
71
|
+
logPhase("Starting analysis...");
|
|
72
|
+
|
|
73
|
+
progress.on("phaseChange", (phase: PipelinePhase) => {
|
|
74
|
+
if (!options.quiet) {
|
|
75
|
+
logPhase(`Phase: ${phase}`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
progress.on("agentStatusChange", (status: AgentStatus) => {
|
|
80
|
+
if (!options.quiet) {
|
|
81
|
+
const icon =
|
|
82
|
+
status.status === "completed"
|
|
83
|
+
? chalk.green("\u2713")
|
|
84
|
+
: status.status === "failed"
|
|
85
|
+
? chalk.red("\u2717")
|
|
86
|
+
: status.status === "running"
|
|
87
|
+
? chalk.cyan("\u25c9")
|
|
88
|
+
: chalk.gray("\u25cb");
|
|
89
|
+
logPhase(`${icon} ${status.agentName}: ${status.statusMessage || status.status}`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const report = await runPipeline({
|
|
95
|
+
projectRoot,
|
|
96
|
+
reportType,
|
|
97
|
+
model,
|
|
98
|
+
modelId,
|
|
99
|
+
config,
|
|
100
|
+
progress,
|
|
101
|
+
generateTodoList: options.todo ?? config.features?.todoList ?? false,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
logDone("Analysis complete");
|
|
105
|
+
|
|
106
|
+
// Save report
|
|
107
|
+
const savedPath = await saveReport(projectRoot, report);
|
|
108
|
+
|
|
109
|
+
// Optionally save to custom output path
|
|
110
|
+
if (options.output) {
|
|
111
|
+
const outputPath = path.resolve(projectRoot, options.output);
|
|
112
|
+
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
|
113
|
+
|
|
114
|
+
if (options.format === "json") {
|
|
115
|
+
await fs.promises.writeFile(
|
|
116
|
+
outputPath,
|
|
117
|
+
JSON.stringify(report, null, 2),
|
|
118
|
+
"utf-8"
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
await fs.promises.writeFile(outputPath, report.markdownContent, "utf-8");
|
|
122
|
+
}
|
|
123
|
+
console.log(chalk.gray(`Output: ${outputPath}`));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!options.quiet) {
|
|
127
|
+
const totalTokens =
|
|
128
|
+
report.metadata.tokens.input + report.metadata.tokens.output;
|
|
129
|
+
|
|
130
|
+
console.log();
|
|
131
|
+
console.log(chalk.green.bold("Report generated successfully!"));
|
|
132
|
+
console.log(chalk.gray(`ID: ${report.metadata.id}`));
|
|
133
|
+
console.log(chalk.gray(`Grade: ${report.metadata.grade}`));
|
|
134
|
+
console.log(
|
|
135
|
+
chalk.gray(`Duration: ${formatDuration(report.metadata.duration)}`)
|
|
136
|
+
);
|
|
137
|
+
console.log(chalk.gray(`Tokens: ${formatTokens(totalTokens)}`));
|
|
138
|
+
console.log(chalk.gray(`Saved: ${savedPath}`));
|
|
139
|
+
console.log();
|
|
140
|
+
|
|
141
|
+
// Print finding summary
|
|
142
|
+
const { findingSummary } = report;
|
|
143
|
+
if (findingSummary.critical > 0) {
|
|
144
|
+
console.log(
|
|
145
|
+
chalk.red(` ${findingSummary.critical} critical findings`)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (findingSummary.warning > 0) {
|
|
149
|
+
console.log(
|
|
150
|
+
chalk.yellow(` ${findingSummary.warning} warnings`)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (findingSummary.info > 0) {
|
|
154
|
+
console.log(chalk.blue(` ${findingSummary.info} info`));
|
|
155
|
+
}
|
|
156
|
+
if (findingSummary.suggestion > 0) {
|
|
157
|
+
console.log(
|
|
158
|
+
chalk.magenta(` ${findingSummary.suggestion} suggestions`)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch (err: unknown) {
|
|
163
|
+
process.stderr.write(`\r${chalk.red("\u2718")} Report generation failed\n`);
|
|
164
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
165
|
+
console.error(chalk.red(msg));
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render } from "ink";
|
|
3
|
+
import { loadReport, listReports } from "../storage/report-store.js";
|
|
4
|
+
import { ViewerScreen } from "../screens/ViewerScreen.js";
|
|
5
|
+
import type { Screen } from "../types/index.js";
|
|
6
|
+
|
|
7
|
+
export interface ViewOptions {
|
|
8
|
+
raw?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function runView(
|
|
12
|
+
projectRoot: string,
|
|
13
|
+
reportId: string,
|
|
14
|
+
options: ViewOptions = {}
|
|
15
|
+
) {
|
|
16
|
+
// If no exact ID, try to find a match
|
|
17
|
+
const reports = await listReports(projectRoot);
|
|
18
|
+
const match = reports.find(
|
|
19
|
+
(r) => r.id === reportId || r.id.startsWith(reportId)
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
if (!match) {
|
|
23
|
+
const available = reports.length > 0
|
|
24
|
+
? `\nAvailable reports:\n${reports.slice(0, 5).map((r) => ` ${r.id} - ${r.type}`).join("\n")}`
|
|
25
|
+
: "";
|
|
26
|
+
throw new Error(`Report not found: ${reportId}${available}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const content = await loadReport(projectRoot, match.id);
|
|
30
|
+
if (!content) {
|
|
31
|
+
throw new Error(`Could not load report: ${match.id}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (options.raw) {
|
|
35
|
+
console.log(content);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Render TUI viewer
|
|
40
|
+
const app = render(
|
|
41
|
+
<ViewerScreen
|
|
42
|
+
projectRoot={projectRoot}
|
|
43
|
+
reportId={match.id}
|
|
44
|
+
onNavigate={(_screen: Screen) => {
|
|
45
|
+
// In standalone view mode, just exit
|
|
46
|
+
app.unmount();
|
|
47
|
+
}}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
await app.waitUntilExit();
|
|
52
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import Spinner from "ink-spinner";
|
|
4
|
+
import type { AgentStatus } from "../../types/index.js";
|
|
5
|
+
import { truncate, formatDuration } from "../../utils/format.js";
|
|
6
|
+
|
|
7
|
+
interface AgentStatusItemProps {
|
|
8
|
+
agent: AgentStatus;
|
|
9
|
+
/** Available width for the row, used to compute responsive column widths */
|
|
10
|
+
terminalWidth?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const AgentStatusItem = React.memo(function AgentStatusItem({
|
|
14
|
+
agent,
|
|
15
|
+
terminalWidth = 80,
|
|
16
|
+
}: AgentStatusItemProps) {
|
|
17
|
+
const icon = getStatusIcon(agent.status);
|
|
18
|
+
const color = getStatusColor(agent.status);
|
|
19
|
+
|
|
20
|
+
// Calculate agent duration
|
|
21
|
+
const duration = agent.startedAt
|
|
22
|
+
? (agent.completedAt || new Date()).getTime() - agent.startedAt.getTime()
|
|
23
|
+
: 0;
|
|
24
|
+
|
|
25
|
+
// Token display
|
|
26
|
+
const tokenStr = agent.tokensUsed
|
|
27
|
+
? `${((agent.tokensUsed.input + agent.tokensUsed.output) / 1000).toFixed(1)}K`
|
|
28
|
+
: "";
|
|
29
|
+
|
|
30
|
+
// Responsive column widths
|
|
31
|
+
const isCompact = terminalWidth < 60;
|
|
32
|
+
const isNarrow = terminalWidth < 100;
|
|
33
|
+
const nameWidth = isCompact ? 16 : isNarrow ? 20 : 26;
|
|
34
|
+
const timeWidth = isCompact ? 6 : 8;
|
|
35
|
+
const tokenWidth = isCompact ? 0 : 8; // Hide tokens in compact mode
|
|
36
|
+
const statusMaxLen = isCompact
|
|
37
|
+
? 20
|
|
38
|
+
: isNarrow
|
|
39
|
+
? 35
|
|
40
|
+
: Math.max(20, terminalWidth - nameWidth - timeWidth - tokenWidth - 10);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Box flexDirection="row" gap={1}>
|
|
44
|
+
{/* Status icon */}
|
|
45
|
+
<Box width={2}>
|
|
46
|
+
{agent.status === "running" ? (
|
|
47
|
+
<Text color="cyan">
|
|
48
|
+
<Spinner type="dots" />
|
|
49
|
+
</Text>
|
|
50
|
+
) : (
|
|
51
|
+
<Text color={color}>{icon}</Text>
|
|
52
|
+
)}
|
|
53
|
+
</Box>
|
|
54
|
+
|
|
55
|
+
{/* Agent name */}
|
|
56
|
+
<Box width={nameWidth}>
|
|
57
|
+
<Text color={color} bold={agent.status === "running"}>
|
|
58
|
+
{truncate(agent.agentName, nameWidth)}
|
|
59
|
+
</Text>
|
|
60
|
+
</Box>
|
|
61
|
+
|
|
62
|
+
{/* Status message */}
|
|
63
|
+
<Box flexGrow={1}>
|
|
64
|
+
<Text
|
|
65
|
+
color={agent.status === "running" ? "white" : "gray"}
|
|
66
|
+
dimColor={agent.status !== "running"}
|
|
67
|
+
>
|
|
68
|
+
{agent.error
|
|
69
|
+
? truncate(agent.error, statusMaxLen)
|
|
70
|
+
: agent.statusMessage
|
|
71
|
+
? truncate(agent.statusMessage, statusMaxLen)
|
|
72
|
+
: ""}
|
|
73
|
+
</Text>
|
|
74
|
+
</Box>
|
|
75
|
+
|
|
76
|
+
{/* Duration */}
|
|
77
|
+
{duration > 0 && (
|
|
78
|
+
<Box width={timeWidth} justifyContent="flex-end">
|
|
79
|
+
<Text color="gray" dimColor>
|
|
80
|
+
{formatDuration(duration)}
|
|
81
|
+
</Text>
|
|
82
|
+
</Box>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{/* Tokens (hidden in compact mode) */}
|
|
86
|
+
{tokenStr && !isCompact && (
|
|
87
|
+
<Box width={tokenWidth} justifyContent="flex-end">
|
|
88
|
+
<Text color="gray" dimColor>
|
|
89
|
+
{tokenStr}
|
|
90
|
+
</Text>
|
|
91
|
+
</Box>
|
|
92
|
+
)}
|
|
93
|
+
</Box>
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
function getStatusIcon(status: AgentStatus["status"]): string {
|
|
98
|
+
switch (status) {
|
|
99
|
+
case "pending":
|
|
100
|
+
return "○";
|
|
101
|
+
case "running":
|
|
102
|
+
return "●";
|
|
103
|
+
case "completed":
|
|
104
|
+
return "✓";
|
|
105
|
+
case "failed":
|
|
106
|
+
return "✗";
|
|
107
|
+
case "skipped":
|
|
108
|
+
return "−";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getStatusColor(status: AgentStatus["status"]): string {
|
|
113
|
+
switch (status) {
|
|
114
|
+
case "pending":
|
|
115
|
+
return "gray";
|
|
116
|
+
case "running":
|
|
117
|
+
return "cyan";
|
|
118
|
+
case "completed":
|
|
119
|
+
return "green";
|
|
120
|
+
case "failed":
|
|
121
|
+
return "red";
|
|
122
|
+
case "skipped":
|
|
123
|
+
return "gray";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { AgentStatusItem } from "./AgentStatusItem.js";
|
|
4
|
+
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
5
|
+
import type { AgentStatus } from "../../types/index.js";
|
|
6
|
+
|
|
7
|
+
interface AgentStatusListProps {
|
|
8
|
+
agents: AgentStatus[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const AgentStatusList = React.memo(function AgentStatusList({ agents }: AgentStatusListProps) {
|
|
12
|
+
const { columns } = useTerminalSize();
|
|
13
|
+
|
|
14
|
+
if (agents.length === 0) return null;
|
|
15
|
+
|
|
16
|
+
const isCompact = columns < 60;
|
|
17
|
+
const isNarrow = columns < 100;
|
|
18
|
+
const nameWidth = isCompact ? 16 : isNarrow ? 20 : 26;
|
|
19
|
+
const timeWidth = isCompact ? 6 : 8;
|
|
20
|
+
const tokenWidth = isCompact ? 0 : 8;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Box flexDirection="column" gap={0}>
|
|
24
|
+
{/* Column headers */}
|
|
25
|
+
<Box flexDirection="row" gap={1} marginBottom={0}>
|
|
26
|
+
<Box width={2} />
|
|
27
|
+
<Box width={nameWidth}>
|
|
28
|
+
<Text color="gray" dimColor bold>
|
|
29
|
+
AGENT
|
|
30
|
+
</Text>
|
|
31
|
+
</Box>
|
|
32
|
+
<Box flexGrow={1}>
|
|
33
|
+
<Text color="gray" dimColor bold>
|
|
34
|
+
STATUS
|
|
35
|
+
</Text>
|
|
36
|
+
</Box>
|
|
37
|
+
<Box width={timeWidth} justifyContent="flex-end">
|
|
38
|
+
<Text color="gray" dimColor bold>
|
|
39
|
+
TIME
|
|
40
|
+
</Text>
|
|
41
|
+
</Box>
|
|
42
|
+
{!isCompact && (
|
|
43
|
+
<Box width={tokenWidth} justifyContent="flex-end">
|
|
44
|
+
<Text color="gray" dimColor bold>
|
|
45
|
+
TOKENS
|
|
46
|
+
</Text>
|
|
47
|
+
</Box>
|
|
48
|
+
)}
|
|
49
|
+
</Box>
|
|
50
|
+
|
|
51
|
+
{/* Agent rows */}
|
|
52
|
+
{agents.map((agent) => (
|
|
53
|
+
<AgentStatusItem
|
|
54
|
+
key={agent.agentId}
|
|
55
|
+
agent={agent}
|
|
56
|
+
terminalWidth={columns}
|
|
57
|
+
/>
|
|
58
|
+
))}
|
|
59
|
+
</Box>
|
|
60
|
+
);
|
|
61
|
+
}, (prev, next) => {
|
|
62
|
+
if (prev.agents.length !== next.agents.length) return false;
|
|
63
|
+
return prev.agents.every((a, i) => {
|
|
64
|
+
const b = next.agents[i];
|
|
65
|
+
return a.agentId === b.agentId
|
|
66
|
+
&& a.status === b.status
|
|
67
|
+
&& a.statusMessage === b.statusMessage
|
|
68
|
+
&& a.tokensUsed === b.tokensUsed;
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { formatTokens, formatDuration } from "../../utils/format.js";
|
|
4
|
+
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
5
|
+
|
|
6
|
+
interface ProgressSummaryProps {
|
|
7
|
+
completed: number;
|
|
8
|
+
total: number;
|
|
9
|
+
tokens: { input: number; output: number };
|
|
10
|
+
elapsed: number;
|
|
11
|
+
estimatedTimeRemaining?: number;
|
|
12
|
+
phase: string;
|
|
13
|
+
phaseDetail?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const PHASE_LABELS: Record<string, string> = {
|
|
17
|
+
scanning: "Scanning project files...",
|
|
18
|
+
classifying: "Detecting project type...",
|
|
19
|
+
"pre-reading": "Reading project files...",
|
|
20
|
+
analyzing: "Orchestrator analyzing project...",
|
|
21
|
+
"running-agents": "Running analysis agents",
|
|
22
|
+
combining: "Generating executive summary...",
|
|
23
|
+
saving: "Saving report...",
|
|
24
|
+
done: "Complete!",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function ProgressSummary({
|
|
28
|
+
completed,
|
|
29
|
+
total,
|
|
30
|
+
tokens,
|
|
31
|
+
elapsed,
|
|
32
|
+
estimatedTimeRemaining,
|
|
33
|
+
phase,
|
|
34
|
+
phaseDetail,
|
|
35
|
+
}: ProgressSummaryProps) {
|
|
36
|
+
const { columns } = useTerminalSize();
|
|
37
|
+
|
|
38
|
+
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
39
|
+
|
|
40
|
+
// Responsive bar width: use ~40% of terminal width, clamped between 15 and 60
|
|
41
|
+
const barWidth = Math.max(15, Math.min(60, Math.floor((columns - 4) * 0.4)));
|
|
42
|
+
const filled = Math.round((percentage / 100) * barWidth);
|
|
43
|
+
const empty = barWidth - filled;
|
|
44
|
+
|
|
45
|
+
const bar = "━".repeat(filled) + "╌".repeat(empty);
|
|
46
|
+
const barColor = percentage === 100 ? "green" : "cyan";
|
|
47
|
+
|
|
48
|
+
const phaseLabel = PHASE_LABELS[phase] || phase;
|
|
49
|
+
const totalTokens = tokens.input + tokens.output;
|
|
50
|
+
|
|
51
|
+
const isCompact = columns < 60;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Box flexDirection="column" gap={0}>
|
|
55
|
+
{/* Phase indicator */}
|
|
56
|
+
<Box flexDirection="row" gap={1}>
|
|
57
|
+
<Text color={phase === "done" ? "green" : "cyan"} bold>
|
|
58
|
+
{phase === "done" ? "✓" : "●"}
|
|
59
|
+
</Text>
|
|
60
|
+
<Text color={phase === "done" ? "green" : "white"}>{phaseLabel}</Text>
|
|
61
|
+
{phaseDetail && (
|
|
62
|
+
<Text color="gray" dimColor> {phaseDetail}</Text>
|
|
63
|
+
)}
|
|
64
|
+
</Box>
|
|
65
|
+
|
|
66
|
+
{/* Progress bar */}
|
|
67
|
+
<Box flexDirection="row" gap={1} marginTop={0}>
|
|
68
|
+
<Text color={barColor}>{bar}</Text>
|
|
69
|
+
<Text bold color={barColor}>
|
|
70
|
+
{percentage}%
|
|
71
|
+
</Text>
|
|
72
|
+
<Text color="gray" dimColor>
|
|
73
|
+
({completed}/{total} agents)
|
|
74
|
+
</Text>
|
|
75
|
+
</Box>
|
|
76
|
+
|
|
77
|
+
{/* Stats row */}
|
|
78
|
+
<Box flexDirection="row" gap={isCompact ? 1 : 3} marginTop={0}>
|
|
79
|
+
<Box gap={1}>
|
|
80
|
+
<Text color="gray" dimColor>
|
|
81
|
+
Tokens
|
|
82
|
+
</Text>
|
|
83
|
+
<Text color={totalTokens > 0 ? "yellow" : "gray"}>
|
|
84
|
+
{formatTokens(totalTokens)}
|
|
85
|
+
</Text>
|
|
86
|
+
</Box>
|
|
87
|
+
<Box gap={1}>
|
|
88
|
+
<Text color="gray" dimColor>
|
|
89
|
+
Elapsed
|
|
90
|
+
</Text>
|
|
91
|
+
<Text color="white">{formatDuration(elapsed)}</Text>
|
|
92
|
+
</Box>
|
|
93
|
+
{estimatedTimeRemaining !== undefined &&
|
|
94
|
+
estimatedTimeRemaining > 0 && (
|
|
95
|
+
<Box gap={1}>
|
|
96
|
+
<Text color="gray" dimColor>
|
|
97
|
+
ETA
|
|
98
|
+
</Text>
|
|
99
|
+
<Text color="white">
|
|
100
|
+
~{formatDuration(estimatedTimeRemaining * 1000)}
|
|
101
|
+
</Text>
|
|
102
|
+
</Box>
|
|
103
|
+
)}
|
|
104
|
+
</Box>
|
|
105
|
+
</Box>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import Spinner from "ink-spinner";
|
|
4
|
+
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
5
|
+
|
|
6
|
+
interface AgentStreamInfo {
|
|
7
|
+
agentId: string;
|
|
8
|
+
agentName: string;
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface StreamingOutputProps {
|
|
13
|
+
/** Per-agent stream data: Record<agentId, streamText> */
|
|
14
|
+
agentStreams: Record<string, string>;
|
|
15
|
+
/** Map of agentId to display name */
|
|
16
|
+
agentNames: Record<string, string>;
|
|
17
|
+
/** Maximum lines to show per agent panel */
|
|
18
|
+
maxLinesPerAgent?: number;
|
|
19
|
+
/** Available height for the streaming output area */
|
|
20
|
+
availableHeight?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Displays live streaming output from multiple agents in separate panels.
|
|
25
|
+
* When terminal is wide enough (>= 80 cols), panels are side-by-side.
|
|
26
|
+
* When narrow, panels stack vertically.
|
|
27
|
+
* Only shows panels for agents that have active stream text.
|
|
28
|
+
*/
|
|
29
|
+
export const StreamingOutput = React.memo(function StreamingOutput({
|
|
30
|
+
agentStreams,
|
|
31
|
+
agentNames,
|
|
32
|
+
maxLinesPerAgent = 5,
|
|
33
|
+
availableHeight,
|
|
34
|
+
}: StreamingOutputProps) {
|
|
35
|
+
const { columns } = useTerminalSize();
|
|
36
|
+
|
|
37
|
+
// Build the list of active agent streams
|
|
38
|
+
const activeStreams: AgentStreamInfo[] = Object.entries(agentStreams)
|
|
39
|
+
.filter(([_, text]) => text && text.trim().length > 0)
|
|
40
|
+
.map(([agentId, text]) => ({
|
|
41
|
+
agentId,
|
|
42
|
+
agentName: agentNames[agentId] || agentId,
|
|
43
|
+
text,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
if (activeStreams.length === 0) return null;
|
|
47
|
+
|
|
48
|
+
// Determine layout: side-by-side if wide enough, otherwise stacked
|
|
49
|
+
const MIN_PANEL_WIDTH = 38;
|
|
50
|
+
const usableWidth = columns - 2; // Account for container padding
|
|
51
|
+
const canFitSideBySide =
|
|
52
|
+
activeStreams.length > 1 && usableWidth >= MIN_PANEL_WIDTH * 2 + 1;
|
|
53
|
+
|
|
54
|
+
// If side-by-side, limit to max 3 panels across
|
|
55
|
+
const maxPanelsAcross = canFitSideBySide
|
|
56
|
+
? Math.min(activeStreams.length, Math.floor(usableWidth / MIN_PANEL_WIDTH), 3)
|
|
57
|
+
: 1;
|
|
58
|
+
|
|
59
|
+
const panelWidth = canFitSideBySide
|
|
60
|
+
? Math.floor(usableWidth / maxPanelsAcross)
|
|
61
|
+
: usableWidth;
|
|
62
|
+
|
|
63
|
+
// Determine lines per panel based on available height
|
|
64
|
+
const effectiveMaxLines = availableHeight
|
|
65
|
+
? Math.max(2, Math.min(maxLinesPerAgent, availableHeight - 3))
|
|
66
|
+
: maxLinesPerAgent;
|
|
67
|
+
|
|
68
|
+
// Group streams into rows for side-by-side rendering
|
|
69
|
+
const rows: AgentStreamInfo[][] = [];
|
|
70
|
+
for (let i = 0; i < activeStreams.length; i += maxPanelsAcross) {
|
|
71
|
+
rows.push(activeStreams.slice(i, i + maxPanelsAcross));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Box flexDirection="column" gap={0}>
|
|
76
|
+
{rows.map((row, rowIndex) => (
|
|
77
|
+
<Box key={rowIndex} flexDirection="row" gap={0}>
|
|
78
|
+
{row.map((stream) => (
|
|
79
|
+
<AgentPanel
|
|
80
|
+
key={stream.agentId}
|
|
81
|
+
agentName={stream.agentName}
|
|
82
|
+
text={stream.text}
|
|
83
|
+
width={panelWidth}
|
|
84
|
+
maxLines={effectiveMaxLines}
|
|
85
|
+
/>
|
|
86
|
+
))}
|
|
87
|
+
</Box>
|
|
88
|
+
))}
|
|
89
|
+
</Box>
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
interface AgentPanelProps {
|
|
94
|
+
agentName: string;
|
|
95
|
+
text: string;
|
|
96
|
+
width: number;
|
|
97
|
+
maxLines: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function AgentPanel({ agentName, text, width, maxLines }: AgentPanelProps) {
|
|
101
|
+
// Extract the last N meaningful lines efficiently:
|
|
102
|
+
// Instead of splitting the entire text, take only the tail portion
|
|
103
|
+
// to avoid creating a huge array from the full accumulated stream.
|
|
104
|
+
// We grab extra characters to account for potentially long or empty lines.
|
|
105
|
+
const tailChars = maxLines * 200;
|
|
106
|
+
const tail = text.length > tailChars ? text.slice(-tailChars) : text;
|
|
107
|
+
const allLines = tail.split("\n").filter((l) => l.trim().length > 0);
|
|
108
|
+
const lines = allLines.slice(-maxLines);
|
|
109
|
+
|
|
110
|
+
// Content width accounts for border (2 chars) and padding (2 chars)
|
|
111
|
+
const contentWidth = Math.max(10, width - 4);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<Box
|
|
115
|
+
flexDirection="column"
|
|
116
|
+
borderStyle="round"
|
|
117
|
+
borderColor="gray"
|
|
118
|
+
width={width}
|
|
119
|
+
paddingX={1}
|
|
120
|
+
paddingY={0}
|
|
121
|
+
>
|
|
122
|
+
{/* Panel header with agent name and spinner */}
|
|
123
|
+
<Box flexDirection="row" gap={1}>
|
|
124
|
+
<Text color="cyan">
|
|
125
|
+
<Spinner type="dots" />
|
|
126
|
+
</Text>
|
|
127
|
+
<Text color="cyan" bold>
|
|
128
|
+
{agentName.length > contentWidth - 4
|
|
129
|
+
? agentName.slice(0, contentWidth - 7) + "..."
|
|
130
|
+
: agentName}
|
|
131
|
+
</Text>
|
|
132
|
+
</Box>
|
|
133
|
+
|
|
134
|
+
{/* Stream content */}
|
|
135
|
+
{lines.map((line, i) => {
|
|
136
|
+
const isLatest = i === lines.length - 1;
|
|
137
|
+
const displayLine =
|
|
138
|
+
line.length > contentWidth
|
|
139
|
+
? line.slice(0, contentWidth - 3) + "..."
|
|
140
|
+
: line;
|
|
141
|
+
return (
|
|
142
|
+
<Text
|
|
143
|
+
key={i}
|
|
144
|
+
color={isLatest ? "white" : "gray"}
|
|
145
|
+
dimColor={!isLatest}
|
|
146
|
+
wrap="truncate"
|
|
147
|
+
>
|
|
148
|
+
{displayLine}
|
|
149
|
+
</Text>
|
|
150
|
+
);
|
|
151
|
+
})}
|
|
152
|
+
</Box>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box } from "ink";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
interface ContainerProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
padding?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Content container that fills the middle area between Header and Footer.
|
|
11
|
+
* Uses flexGrow to take all remaining vertical space.
|
|
12
|
+
*/
|
|
13
|
+
export function Container({ children, padding = 1 }: ContainerProps) {
|
|
14
|
+
return (
|
|
15
|
+
<Box
|
|
16
|
+
flexDirection="column"
|
|
17
|
+
paddingX={padding}
|
|
18
|
+
paddingY={0}
|
|
19
|
+
flexGrow={1}
|
|
20
|
+
>
|
|
21
|
+
{children}
|
|
22
|
+
</Box>
|
|
23
|
+
);
|
|
24
|
+
}
|