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,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
+ }