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,153 @@
1
+ const HTML_ESC_MAP: Record<string, string> = {
2
+ "&": "&amp;",
3
+ "<": "&lt;",
4
+ ">": "&gt;",
5
+ '"': "&quot;",
6
+ "'": "&#39;",
7
+ };
8
+
9
+ export function esc(str: string): string {
10
+ return str.replace(/[&<>"']/g, (c) => HTML_ESC_MAP[c] ?? c);
11
+ }
12
+
13
+ /** Minimal markdown-to-HTML for section content — line-based state machine */
14
+ export function mdToHtml(text: string): string {
15
+ const lines = text.split("\n");
16
+ const out: string[] = [];
17
+ let i = 0;
18
+
19
+ while (i < lines.length) {
20
+ const line = lines[i];
21
+ const trimmed = line.trim();
22
+
23
+ // Skip blank lines
24
+ if (!trimmed) { i++; continue; }
25
+
26
+ // Code block (fenced)
27
+ if (trimmed.startsWith("```")) {
28
+ const lang = trimmed.slice(3).trim();
29
+ const codeLines: string[] = [];
30
+ i++;
31
+ while (i < lines.length && !lines[i].trim().startsWith("```")) {
32
+ codeLines.push(lines[i]);
33
+ i++;
34
+ }
35
+ i++; // skip closing ```
36
+ out.push(`<pre><code${lang ? ` class="lang-${esc(lang)}"` : ""}>${esc(codeLines.join("\n"))}</code></pre>`);
37
+ continue;
38
+ }
39
+
40
+ // Heading
41
+ const hMatch = trimmed.match(/^(#{1,4})\s+(.+)/);
42
+ if (hMatch) {
43
+ const level = Math.min(hMatch[1].length + 2, 6);
44
+ out.push(`<h${level}>${inlineFormat(hMatch[2])}</h${level}>`);
45
+ i++;
46
+ continue;
47
+ }
48
+
49
+ // Table: line starts with |, and next line is a separator (|---|)
50
+ if (trimmed.startsWith("|") && i + 1 < lines.length && /^\|[\s-:|]+\|$/.test(lines[i + 1].trim())) {
51
+ const tableLines: string[] = [trimmed];
52
+ i++;
53
+ while (i < lines.length && lines[i].trim().startsWith("|")) {
54
+ tableLines.push(lines[i].trim());
55
+ i++;
56
+ }
57
+ out.push(renderTable(tableLines.join("\n")));
58
+ continue;
59
+ }
60
+
61
+ // Checkbox list (- [ ] or - [x])
62
+ if (/^[-*]\s\[[ xX]\]\s/.test(trimmed)) {
63
+ const items: string[] = [];
64
+ while (i < lines.length && /^[-*]\s\[[ xX]\]\s/.test(lines[i].trim())) {
65
+ items.push(lines[i].trim());
66
+ i++;
67
+ }
68
+ out.push(`<ul class="checklist">${items.map((t) => {
69
+ const checked = /^[-*]\s\[[xX]\]/.test(t);
70
+ const text = t.replace(/^[-*]\s\[[ xX]\]\s+/, "");
71
+ return `<li class="checklist-item${checked ? " checked" : ""}" onclick="toggleCheck(this)"><span class="checkbox${checked ? " checkbox-checked" : ""}">${checked ? "&#10003;" : ""}</span><span class="checklist-text">${inlineFormat(text)}</span></li>`;
72
+ }).join("")}</ul>`);
73
+ continue;
74
+ }
75
+
76
+ // Unordered list
77
+ if (/^[-*]\s/.test(trimmed)) {
78
+ const items: string[] = [];
79
+ while (i < lines.length && /^[-*]\s/.test(lines[i].trim())) {
80
+ items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
81
+ i++;
82
+ }
83
+ out.push(`<ul>${items.map((t) => `<li>${inlineFormat(t)}</li>`).join("")}</ul>`);
84
+ continue;
85
+ }
86
+
87
+ // Ordered list
88
+ if (/^\d+\.\s/.test(trimmed)) {
89
+ const items: string[] = [];
90
+ while (i < lines.length && /^\d+\.\s/.test(lines[i].trim())) {
91
+ items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
92
+ i++;
93
+ }
94
+ out.push(`<ol>${items.map((t) => `<li>${inlineFormat(t)}</li>`).join("")}</ol>`);
95
+ continue;
96
+ }
97
+
98
+ // Paragraph: collect consecutive non-blank, non-special lines
99
+ const paraLines: string[] = [trimmed];
100
+ i++;
101
+ while (i < lines.length) {
102
+ const next = lines[i].trim();
103
+ if (!next || next.startsWith("```") || next.startsWith("#") || next.startsWith("|") || /^[-*]\s/.test(next) || /^\d+\.\s/.test(next)) break;
104
+ paraLines.push(next);
105
+ i++;
106
+ }
107
+ out.push(`<p>${inlineFormat(paraLines.join(" "))}</p>`);
108
+ }
109
+
110
+ return out.join("\n");
111
+ }
112
+
113
+ export function isSafeUrl(url: string): boolean {
114
+ const decoded = url.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').trim();
115
+ const scheme = decoded.toLowerCase().split(":")[0];
116
+ if (!decoded.includes(":")) return true; // relative URLs are safe
117
+ return scheme === "http" || scheme === "https" || scheme === "mailto";
118
+ }
119
+
120
+ function inlineFormat(text: string): string {
121
+ return esc(text)
122
+ .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
123
+ .replace(/\*(.+?)\*/g, "<em>$1</em>")
124
+ .replace(/`([^`]+)`/g, '<code class="inline">$1</code>')
125
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, url) => {
126
+ if (isSafeUrl(url)) {
127
+ return `<a href="${esc(url)}">${label}</a>`;
128
+ }
129
+ return label;
130
+ });
131
+ }
132
+
133
+ function renderTable(text: string): string {
134
+ const rows = text
135
+ .split("\n")
136
+ .map((r) => r.trim())
137
+ .filter(Boolean);
138
+ if (rows.length < 2) return `<p>${esc(text)}</p>`;
139
+
140
+ const parseRow = (row: string) =>
141
+ row
142
+ .split("|")
143
+ .map((c) => c.trim())
144
+ .filter(Boolean);
145
+
146
+ const headers = parseRow(rows[0]);
147
+ const bodyRows = rows.slice(2).map(parseRow); // skip separator
148
+
149
+ return `<div class="table-wrap"><table>
150
+ <thead><tr>${headers.map((h) => `<th>${inlineFormat(h)}</th>`).join("")}</tr></thead>
151
+ <tbody>${bodyRows.map((cells) => `<tr>${cells.map((c) => `<td>${inlineFormat(c)}</td>`).join("")}</tr>`).join("")}</tbody>
152
+ </table></div>`;
153
+ }
@@ -0,0 +1,22 @@
1
+ import { spawn } from "child_process";
2
+ import * as os from "os";
3
+ import { debugLog } from "../utils/debug.js";
4
+
5
+ const SAFE_PATH = /^[a-zA-Z0-9_\-./\\: ]+$/;
6
+
7
+ export function openInBrowser(filePath: string): void {
8
+ if (!SAFE_PATH.test(filePath)) return;
9
+
10
+ const platform = os.platform();
11
+
12
+ let child;
13
+ if (platform === "win32") {
14
+ child = spawn("cmd", ["/c", "start", "", filePath], { shell: false });
15
+ } else if (platform === "darwin") {
16
+ child = spawn("open", [filePath]);
17
+ } else {
18
+ child = spawn("xdg-open", [filePath]);
19
+ }
20
+
21
+ child.on("error", (err) => debugLog("browser:open", `Failed to open browser: ${err.message}`));
22
+ }
@@ -0,0 +1,48 @@
1
+ import { z } from "zod";
2
+
3
+ // ── Normalization functions ─────────────────────────────────────────────
4
+
5
+ export function normalizeSeverity(s: string): "critical" | "warning" | "info" | "suggestion" {
6
+ const lower = s.toLowerCase().trim();
7
+ if (["critical", "error", "high", "severe"].includes(lower)) return "critical";
8
+ if (["warning", "warn", "medium", "moderate"].includes(lower)) return "warning";
9
+ if (["info", "information", "low", "note"].includes(lower)) return "info";
10
+ if (["suggestion", "hint", "improvement", "enhancement"].includes(lower)) return "suggestion";
11
+ return "info";
12
+ }
13
+
14
+ export function normalizeEffort(s: string): "low" | "medium" | "high" {
15
+ const lower = s.toLowerCase().trim();
16
+ if (["low", "easy", "trivial", "simple"].includes(lower)) return "low";
17
+ if (["high", "hard", "complex", "difficult"].includes(lower)) return "high";
18
+ return "medium";
19
+ }
20
+
21
+ // Accept any string for category — AI agents produce varied labels
22
+ export const CategorySchema = z.string();
23
+
24
+ export const FindingSchema = z.object({
25
+ id: z.string(),
26
+ severity: z.string().transform(normalizeSeverity),
27
+ category: CategorySchema,
28
+ title: z.string(),
29
+ description: z.string(),
30
+ files: z.array(z.string()).default([]),
31
+ recommendation: z.string().default(""),
32
+ effort: z.string().transform(normalizeEffort).default("medium"),
33
+ codeSnippet: z.string().optional(),
34
+ });
35
+
36
+ export const SEVERITY_ORDER: Record<string, number> = { critical: 0, warning: 1, info: 2, suggestion: 3 };
37
+
38
+ export const SEVERITY_EMOJI: Record<string, string> = {
39
+ critical: "\u{1F534}",
40
+ warning: "\u{1F7E0}",
41
+ info: "\u{1F535}",
42
+ suggestion: "\u{1F4A1}",
43
+ };
44
+
45
+ export type Severity = "critical" | "warning" | "info" | "suggestion";
46
+ export type Effort = "low" | "medium" | "high";
47
+ export type Category = z.infer<typeof CategorySchema>;
48
+ export type Finding = z.infer<typeof FindingSchema>;
@@ -0,0 +1,64 @@
1
+ import { z } from "zod";
2
+ import { FindingSchema } from "./findings.js";
3
+
4
+ export { FindingSchema } from "./findings.js";
5
+
6
+ export const SubReportSchema = z.object({
7
+ agentId: z.string(),
8
+ agentName: z.string(),
9
+ title: z.string(),
10
+ summary: z.string(),
11
+ sections: z.array(
12
+ z.object({
13
+ heading: z.string(),
14
+ content: z.string(),
15
+ })
16
+ ),
17
+ findings: z.array(FindingSchema),
18
+ metadata: z
19
+ .object({
20
+ filesAnalyzed: z.number().default(0),
21
+ tokensUsed: z
22
+ .object({ input: z.number(), output: z.number() })
23
+ .default({ input: 0, output: 0 }),
24
+ duration: z.number().default(0),
25
+ })
26
+ .default({}),
27
+ });
28
+
29
+ export const FindingSummarySchema = z.object({
30
+ critical: z.number().default(0),
31
+ warning: z.number().default(0),
32
+ info: z.number().default(0),
33
+ suggestion: z.number().default(0),
34
+ });
35
+
36
+ export const ReportMetadataSchema = z.object({
37
+ id: z.string(),
38
+ type: z.string(),
39
+ projectName: z.string(),
40
+ projectPath: z.string(),
41
+ createdAt: z.string(),
42
+ duration: z.number(),
43
+ model: z.string(),
44
+ tokens: z.object({ input: z.number(), output: z.number() }),
45
+ agentStatuses: z.array(
46
+ z.object({
47
+ agentId: z.string(),
48
+ status: z.enum(["completed", "failed", "skipped"]),
49
+ duration: z.number().optional(),
50
+ })
51
+ ),
52
+ grade: z.string().optional(),
53
+ sections: z.array(z.string()),
54
+ });
55
+
56
+ export const FullReportSchema = z.object({
57
+ metadata: ReportMetadataSchema,
58
+ executiveSummary: z.string(),
59
+ findingSummary: FindingSummarySchema,
60
+ subReports: z.array(SubReportSchema),
61
+ allFindings: z.array(FindingSchema).optional(),
62
+ markdownContent: z.string(),
63
+ });
64
+
@@ -0,0 +1,271 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import { Header } from "../components/layout/Header.js";
5
+ import { Footer } from "../components/layout/Footer.js";
6
+ import { Container } from "../components/layout/Container.js";
7
+ import {
8
+ detectInstalledClis,
9
+ type DetectedCli,
10
+ } from "../config/cli-detection.js";
11
+ import { useTerminalSize } from "../hooks/useTerminalSize.js";
12
+ import type { OpenReportConfig } from "../config/schema.js";
13
+ import type { Screen } from "../types/index.js";
14
+
15
+ export interface ConfigChanges {
16
+ defaultProvider: string;
17
+ defaultModel: string;
18
+ maxConcurrency: number;
19
+ temperature: number;
20
+ todoList: boolean;
21
+ }
22
+
23
+ interface ConfigScreenProps {
24
+ config: OpenReportConfig;
25
+ onNavigate: (screen: Screen, params?: Record<string, string>) => void;
26
+ onSave: (changes: ConfigChanges) => void;
27
+ }
28
+
29
+ // Models available per CLI agent
30
+ const CLI_MODELS: Record<string, string[]> = {
31
+ "claude-code": ["sonnet", "opus", "haiku"],
32
+ "gemini-cli": ["gemini-cli"],
33
+ "codex-cli": ["codex-cli"],
34
+ };
35
+
36
+ type ConfigField = "provider" | "model" | "concurrency" | "temperature" | "todoList";
37
+
38
+ export function ConfigScreen({
39
+ config,
40
+ onNavigate,
41
+ onSave,
42
+ }: ConfigScreenProps) {
43
+ const [selectedField, setSelectedField] = useState<number>(0);
44
+ const [currentProvider, setCurrentProvider] = useState(
45
+ config.defaultProvider
46
+ );
47
+ const [currentModel, setCurrentModel] = useState(config.defaultModel);
48
+ const [concurrency, setConcurrency] = useState(config.agents.maxConcurrency);
49
+ const [temperature, setTemperature] = useState(config.agents.temperature);
50
+ const [todoList, setTodoList] = useState(config.features?.todoList ?? false);
51
+ const [saved, setSaved] = useState(false);
52
+
53
+ // CLI tools state
54
+ const [detectedClis, setDetectedClis] = useState<DetectedCli[]>([]);
55
+ const [cliDetecting, setCliDetecting] = useState(true);
56
+
57
+ // Detect installed CLI tools on mount
58
+ useEffect(() => {
59
+ setCliDetecting(true);
60
+ detectInstalledClis().then((clis) => {
61
+ setDetectedClis(clis);
62
+ setCliDetecting(false);
63
+
64
+ // Auto-switch to first available agent if current isn't detected
65
+ const currentIsAvailable = clis.some((c) => c.id === currentProvider);
66
+ if (!currentIsAvailable && clis.length > 0) {
67
+ setCurrentProvider(clis[0].id);
68
+ const models = CLI_MODELS[clis[0].id] || [];
69
+ if (models.length > 0) {
70
+ setCurrentModel(models[0]);
71
+ }
72
+ }
73
+ });
74
+ }, []);
75
+
76
+ // Only CLI agents are available for analysis
77
+ const allProviders = detectedClis.map((c) => c.id);
78
+
79
+ const getModelsForProvider = (provider: string): string[] => {
80
+ return CLI_MODELS[provider] || [];
81
+ };
82
+
83
+ const getProviderLabel = (id: string): string => {
84
+ const cli = detectedClis.find((c) => c.id === id);
85
+ if (cli) return cli.name;
86
+ return id;
87
+ };
88
+
89
+ const isDetected = detectedClis.some((c) => c.id === currentProvider);
90
+
91
+ const hasChanges =
92
+ currentProvider !== config.defaultProvider ||
93
+ currentModel !== config.defaultModel ||
94
+ concurrency !== config.agents.maxConcurrency ||
95
+ temperature !== config.agents.temperature ||
96
+ todoList !== (config.features?.todoList ?? false);
97
+
98
+ const fields: Array<{ id: ConfigField; label: string; value: string }> = [
99
+ {
100
+ id: "provider",
101
+ label: "Agent",
102
+ value: getProviderLabel(currentProvider),
103
+ },
104
+ { id: "model", label: "Model", value: currentModel },
105
+ {
106
+ id: "concurrency",
107
+ label: "Max Concurrency",
108
+ value: String(concurrency),
109
+ },
110
+ { id: "temperature", label: "Temperature", value: String(temperature) },
111
+ { id: "todoList", label: "Todo List", value: todoList ? "ON" : "OFF" },
112
+ ];
113
+
114
+ useInput((input, key) => {
115
+ if (key.upArrow) {
116
+ setSelectedField((prev) => Math.max(0, prev - 1));
117
+ setSaved(false);
118
+ } else if (key.downArrow) {
119
+ setSelectedField((prev) => Math.min(fields.length - 1, prev + 1));
120
+ setSaved(false);
121
+ } else if (key.leftArrow || key.rightArrow) {
122
+ setSaved(false);
123
+ const field = fields[selectedField];
124
+ const direction = key.rightArrow ? 1 : -1;
125
+
126
+ switch (field.id) {
127
+ case "provider": {
128
+ if (allProviders.length === 0) break;
129
+ const idx = allProviders.indexOf(currentProvider);
130
+ const newIdx =
131
+ (idx + direction + allProviders.length) % allProviders.length;
132
+ const newProvider = allProviders[newIdx];
133
+ setCurrentProvider(newProvider);
134
+ // Auto-select first model
135
+ const models = getModelsForProvider(newProvider);
136
+ if (models.length > 0 && !models.includes(currentModel)) {
137
+ setCurrentModel(models[0]);
138
+ }
139
+ break;
140
+ }
141
+ case "model": {
142
+ const models = getModelsForProvider(currentProvider);
143
+ if (models.length === 0) break;
144
+ const idx = models.indexOf(currentModel);
145
+ const newIdx = (idx + direction + models.length) % models.length;
146
+ setCurrentModel(models[newIdx]);
147
+ break;
148
+ }
149
+ case "concurrency": {
150
+ setConcurrency((prev) =>
151
+ Math.min(10, Math.max(1, prev + direction))
152
+ );
153
+ break;
154
+ }
155
+ case "temperature": {
156
+ setTemperature((prev) => {
157
+ const next = prev + direction * 0.1;
158
+ return Math.round(Math.min(1, Math.max(0, next)) * 10) / 10;
159
+ });
160
+ break;
161
+ }
162
+ case "todoList": {
163
+ setTodoList((prev) => !prev);
164
+ break;
165
+ }
166
+ }
167
+ } else if (key.return || input === "s") {
168
+ onSave({
169
+ defaultProvider: currentProvider,
170
+ defaultModel: currentModel,
171
+ maxConcurrency: concurrency,
172
+ temperature,
173
+ todoList,
174
+ });
175
+ setSaved(true);
176
+ } else if (key.escape || input === "q") {
177
+ onNavigate("home");
178
+ }
179
+ });
180
+
181
+ const { columns, rows } = useTerminalSize();
182
+
183
+ return (
184
+ <Box flexDirection="column" width={columns} height={rows}>
185
+ <Header modelName={currentModel} />
186
+
187
+ <Container>
188
+ <Text bold>Configuration</Text>
189
+ <Text color="gray">
190
+ Use arrows to change values, Enter to save
191
+ </Text>
192
+
193
+ <Box flexDirection="column" paddingY={1}>
194
+ {fields.map((field, i) => (
195
+ <Box key={field.id} flexDirection="row" gap={2}>
196
+ <Text color={i === selectedField ? "yellow" : "gray"}>
197
+ {i === selectedField ? ">" : " "}
198
+ </Text>
199
+ <Box width={20}>
200
+ <Text bold={i === selectedField}>{field.label}</Text>
201
+ </Box>
202
+ <Text color="cyan">
203
+ {i === selectedField ? "◀ " : " "}
204
+ {field.value}
205
+ {i === selectedField ? " ▶" : " "}
206
+ </Text>
207
+ </Box>
208
+ ))}
209
+ </Box>
210
+
211
+ {/* Agent status */}
212
+ <Box flexDirection="column" paddingY={0} gap={0}>
213
+ {cliDetecting ? (
214
+ <Box gap={1}>
215
+ <Spinner type="dots" />
216
+ <Text color="gray">Detecting installed agents...</Text>
217
+ </Box>
218
+ ) : detectedClis.length === 0 ? (
219
+ <Box flexDirection="column">
220
+ <Text color="red">
221
+ No code agents detected on your system
222
+ </Text>
223
+ <Text color="gray">
224
+ Install one of: claude, gemini, codex
225
+ </Text>
226
+ </Box>
227
+ ) : (
228
+ <Box flexDirection="column" gap={0}>
229
+ {isDetected && (
230
+ <Text color="green">
231
+ {getProviderLabel(currentProvider)} detected - uses your existing authentication
232
+ </Text>
233
+ )}
234
+ {!isDetected && (
235
+ <Text color="yellow">
236
+ {currentProvider} not found - use arrows to select an installed agent
237
+ </Text>
238
+ )}
239
+ <Text color="gray" dimColor>
240
+ Available: {detectedClis.map((c) => c.name).join(", ")}
241
+ </Text>
242
+ </Box>
243
+ )}
244
+ </Box>
245
+
246
+ <Box paddingY={1}>
247
+ {saved ? (
248
+ <Text color="green" bold>
249
+ Configuration saved!
250
+ </Text>
251
+ ) : hasChanges ? (
252
+ <Text color="yellow">
253
+ Unsaved changes - press Enter or [s] to save
254
+ </Text>
255
+ ) : (
256
+ <Text color="gray">No changes</Text>
257
+ )}
258
+ </Box>
259
+ </Container>
260
+
261
+ <Footer
262
+ actions={[
263
+ { key: "←→", label: "Change" },
264
+ { key: "↑↓", label: "Navigate" },
265
+ { key: "enter", label: "Save" },
266
+ { key: "esc", label: "Back" },
267
+ ]}
268
+ />
269
+ </Box>
270
+ );
271
+ }