@ridit/lens 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 (51) hide show
  1. package/LENS.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +0 -0
  4. package/dist/index.js +49363 -0
  5. package/package.json +38 -0
  6. package/src/colors.ts +1 -0
  7. package/src/commands/chat.tsx +23 -0
  8. package/src/commands/provider.tsx +224 -0
  9. package/src/commands/repo.tsx +120 -0
  10. package/src/commands/review.tsx +294 -0
  11. package/src/commands/task.tsx +36 -0
  12. package/src/commands/timeline.tsx +22 -0
  13. package/src/components/chat/ChatMessage.tsx +176 -0
  14. package/src/components/chat/ChatOverlays.tsx +329 -0
  15. package/src/components/chat/ChatRunner.tsx +732 -0
  16. package/src/components/provider/ApiKeyStep.tsx +243 -0
  17. package/src/components/provider/ModelStep.tsx +73 -0
  18. package/src/components/provider/ProviderTypeStep.tsx +54 -0
  19. package/src/components/provider/RemoveProviderStep.tsx +83 -0
  20. package/src/components/repo/DiffViewer.tsx +175 -0
  21. package/src/components/repo/FileReviewer.tsx +70 -0
  22. package/src/components/repo/FileViewer.tsx +60 -0
  23. package/src/components/repo/IssueFixer.tsx +666 -0
  24. package/src/components/repo/LensFileMenu.tsx +122 -0
  25. package/src/components/repo/NoProviderPrompt.tsx +28 -0
  26. package/src/components/repo/PreviewRunner.tsx +217 -0
  27. package/src/components/repo/ProviderPicker.tsx +76 -0
  28. package/src/components/repo/RepoAnalysis.tsx +343 -0
  29. package/src/components/repo/StepRow.tsx +69 -0
  30. package/src/components/task/TaskRunner.tsx +396 -0
  31. package/src/components/timeline/CommitDetail.tsx +274 -0
  32. package/src/components/timeline/CommitList.tsx +174 -0
  33. package/src/components/timeline/TimelineChat.tsx +167 -0
  34. package/src/components/timeline/TimelineRunner.tsx +1209 -0
  35. package/src/index.tsx +60 -0
  36. package/src/types/chat.ts +69 -0
  37. package/src/types/config.ts +20 -0
  38. package/src/types/repo.ts +42 -0
  39. package/src/utils/ai.ts +233 -0
  40. package/src/utils/chat.ts +833 -0
  41. package/src/utils/config.ts +61 -0
  42. package/src/utils/files.ts +104 -0
  43. package/src/utils/git.ts +155 -0
  44. package/src/utils/history.ts +86 -0
  45. package/src/utils/lensfile.ts +77 -0
  46. package/src/utils/llm.ts +81 -0
  47. package/src/utils/preview.ts +119 -0
  48. package/src/utils/repo.ts +69 -0
  49. package/src/utils/stats.ts +174 -0
  50. package/src/utils/thinking.tsx +191 -0
  51. package/tsconfig.json +24 -0
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@ridit/lens",
3
+ "version": "0.1.0",
4
+ "description": "Know Your Codebase.",
5
+ "author": "Ridit Jangra <riditjangra09@gmail.com> (https://ridit.space)",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/ridit-jangra/Lens"
10
+ },
11
+ "type": "module",
12
+ "main": "dist/index.js",
13
+ "bin": {
14
+ "lens": "./dist/index.js"
15
+ },
16
+ "scripts": {
17
+ "build": "bun build src/index.tsx --target bun --outfile dist/index.js",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "dependencies": {
21
+ "chalk": "^5.6.2",
22
+ "commander": "^14.0.3",
23
+ "figures": "^6.1.0",
24
+ "ink": "^6.8.0",
25
+ "ink-spinner": "^5.0.0",
26
+ "ink-text-input": "^6.0.0",
27
+ "nanoid": "^5.1.6",
28
+ "react": "^19.2.4",
29
+ "react-devtools-core": "^7.0.1"
30
+ },
31
+ "devDependencies": {
32
+ "@types/bun": "latest",
33
+ "@types/react": "^19.2.14"
34
+ },
35
+ "peerDependencies": {
36
+ "typescript": "^5"
37
+ }
38
+ }
package/src/colors.ts ADDED
@@ -0,0 +1 @@
1
+ export const ACCENT = "#DA7758";
@@ -0,0 +1,23 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import figures from "figures";
4
+ import { existsSync } from "fs";
5
+ import path from "path";
6
+ import { ChatRunner } from "../components/chat/ChatRunner";
7
+ import { ACCENT } from "../colors";
8
+
9
+ export const ChatCommand = ({ path: inputPath }: { path: string }) => {
10
+ const resolvedPath = path.resolve(inputPath);
11
+
12
+ if (!existsSync(resolvedPath)) {
13
+ return (
14
+ <Box marginTop={1}>
15
+ <Text color="red">
16
+ {figures.cross} Path not found: {resolvedPath}
17
+ </Text>
18
+ </Box>
19
+ );
20
+ }
21
+
22
+ return <ChatRunner repoPath={resolvedPath} />;
23
+ };
@@ -0,0 +1,224 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import figures from "figures";
3
+ import { useState } from "react";
4
+ import { nanoid } from "nanoid";
5
+ import { addProvider, loadConfig } from "../utils/config";
6
+ import { ProviderTypeStep } from "../components/provider/ProviderTypeStep";
7
+ import { ApiKeyStep } from "../components/provider/ApiKeyStep";
8
+ import { ModelStep } from "../components/provider/ModelStep";
9
+ import { RemoveProviderStep } from "../components/provider/RemoveProviderStep";
10
+ import type { Provider, ProviderType } from "../types/config";
11
+
12
+ type InitStage =
13
+ | { type: "menu" }
14
+ | { type: "provider-type" }
15
+ | { type: "api-key"; providerType: ProviderType }
16
+ | { type: "base-url"; providerType: ProviderType; apiKey: string }
17
+ | {
18
+ type: "model";
19
+ providerType: ProviderType;
20
+ apiKey: string;
21
+ baseUrl?: string;
22
+ }
23
+ | { type: "remove" }
24
+ | { type: "done"; provider: Provider };
25
+
26
+ const MENU_OPTIONS = [
27
+ { label: "Add a provider", action: "provider-type" },
28
+ { label: "Remove a provider", action: "remove" },
29
+ ] as const;
30
+
31
+ export const InitCommand = () => {
32
+ const [stage, setStage] = useState<InitStage>({ type: "menu" });
33
+ const [completedSteps, setCompletedSteps] = useState<string[]>([]);
34
+ const [menuIndex, setMenuIndex] = useState(0);
35
+
36
+ const pushStep = (label: string) => setCompletedSteps((s) => [...s, label]);
37
+
38
+ useInput((input, key) => {
39
+ if (stage.type !== "menu") return;
40
+ if (key.upArrow) setMenuIndex((i) => Math.max(0, i - 1));
41
+ if (key.downArrow)
42
+ setMenuIndex((i) => Math.min(MENU_OPTIONS.length - 1, i + 1));
43
+ if (key.return) {
44
+ const action = MENU_OPTIONS[menuIndex]?.action;
45
+ if (action === "provider-type") setStage({ type: "provider-type" });
46
+ if (action === "remove") setStage({ type: "remove" });
47
+ }
48
+ });
49
+
50
+ if (stage.type === "menu") {
51
+ const config = loadConfig();
52
+ return (
53
+ <Box flexDirection="column" gap={1}>
54
+ {completedSteps.map((s, i) => (
55
+ <Text key={i} color="green">
56
+ {figures.tick} {s}
57
+ </Text>
58
+ ))}
59
+ <Text bold color="cyan">
60
+ Lens — provider setup
61
+ </Text>
62
+ {config.providers.length > 0 && (
63
+ <Text color="gray">
64
+ {figures.info} {config.providers.length} provider(s) configured
65
+ </Text>
66
+ )}
67
+ {MENU_OPTIONS.map((opt, i) => (
68
+ <Box key={opt.action} marginLeft={1}>
69
+ <Text color={i === menuIndex ? "cyan" : "white"}>
70
+ {i === menuIndex ? figures.arrowRight : " "}
71
+ {" "}
72
+ {opt.label}
73
+ </Text>
74
+ </Box>
75
+ ))}
76
+ <Text color="gray">↑↓ navigate · enter to select</Text>
77
+ </Box>
78
+ );
79
+ }
80
+
81
+ if (stage.type === "remove") {
82
+ return (
83
+ <Box flexDirection="column" gap={1}>
84
+ {completedSteps.map((s, i) => (
85
+ <Text key={i} color="green">
86
+ {figures.tick} {s}
87
+ </Text>
88
+ ))}
89
+ <RemoveProviderStep onDone={() => setStage({ type: "menu" })} />
90
+ </Box>
91
+ );
92
+ }
93
+
94
+ if (stage.type === "provider-type") {
95
+ return (
96
+ <Box flexDirection="column" gap={1}>
97
+ {completedSteps.map((s, i) => (
98
+ <Text key={i} color="green">
99
+ {figures.tick} {s}
100
+ </Text>
101
+ ))}
102
+ <ProviderTypeStep
103
+ onSelect={(providerType) => {
104
+ pushStep(`Provider: ${providerType}`);
105
+ setStage({ type: "api-key", providerType });
106
+ }}
107
+ />
108
+ </Box>
109
+ );
110
+ }
111
+
112
+ if (stage.type === "api-key") {
113
+ return (
114
+ <Box flexDirection="column" gap={1}>
115
+ {completedSteps.map((s, i) => (
116
+ <Text key={i} color="green">
117
+ {figures.tick} {s}
118
+ </Text>
119
+ ))}
120
+ <ApiKeyStep
121
+ providerType={stage.providerType}
122
+ onSubmit={(value) => {
123
+ if (stage.providerType === "custom") {
124
+ const { apiKey, baseUrl } = value as {
125
+ apiKey: string;
126
+ baseUrl?: string;
127
+ };
128
+ pushStep("API key saved");
129
+ if (baseUrl) pushStep(`Base URL: ${baseUrl}`);
130
+ setStage({
131
+ type: "model",
132
+ providerType: stage.providerType,
133
+ apiKey,
134
+ baseUrl,
135
+ });
136
+ } else if (stage.providerType === "ollama") {
137
+ pushStep(`Base URL: ${value}`);
138
+ setStage({
139
+ type: "model",
140
+ providerType: stage.providerType,
141
+ apiKey: "",
142
+ baseUrl: value as string,
143
+ });
144
+ } else {
145
+ pushStep("API key saved");
146
+ setStage({
147
+ type: "model",
148
+ providerType: stage.providerType,
149
+ apiKey: value as string,
150
+ });
151
+ }
152
+ }}
153
+ />
154
+ </Box>
155
+ );
156
+ }
157
+
158
+ if (stage.type === "base-url") {
159
+ return (
160
+ <Box flexDirection="column" gap={1}>
161
+ {completedSteps.map((s, i) => (
162
+ <Text key={i} color="green">
163
+ {figures.tick} {s}
164
+ </Text>
165
+ ))}
166
+ <ApiKeyStep
167
+ providerType="ollama"
168
+ onSubmit={(baseUrl) => {
169
+ pushStep(`Base URL: ${baseUrl}`);
170
+ setStage({
171
+ type: "model",
172
+ providerType: stage.providerType,
173
+ apiKey: stage.apiKey,
174
+ baseUrl: baseUrl as string,
175
+ });
176
+ }}
177
+ />
178
+ </Box>
179
+ );
180
+ }
181
+
182
+ if (stage.type === "model") {
183
+ return (
184
+ <Box flexDirection="column" gap={1}>
185
+ {completedSteps.map((s, i) => (
186
+ <Text key={i} color="green">
187
+ {figures.tick} {s}
188
+ </Text>
189
+ ))}
190
+ <ModelStep
191
+ providerType={stage.providerType}
192
+ onSelect={(model) => {
193
+ const provider: Provider = {
194
+ id: nanoid(8),
195
+ type: stage.providerType,
196
+ name: `${stage.providerType}-${model}`,
197
+ apiKey: stage.apiKey || undefined,
198
+ baseUrl: stage.baseUrl,
199
+ model,
200
+ };
201
+ addProvider(provider);
202
+ pushStep(`Model: ${model}`);
203
+ setStage({ type: "done", provider });
204
+ }}
205
+ />
206
+ </Box>
207
+ );
208
+ }
209
+
210
+ return (
211
+ <Box flexDirection="column" gap={1}>
212
+ {completedSteps.map((s, i) => (
213
+ <Text key={i} color="green">
214
+ {figures.tick} {s}
215
+ </Text>
216
+ ))}
217
+ <Text color="green">{figures.tick} Provider configured successfully</Text>
218
+ <Text color="gray">
219
+ {figures.info} Run <Text color="cyan">lens init</Text> again to manage
220
+ providers.
221
+ </Text>
222
+ </Box>
223
+ );
224
+ };
@@ -0,0 +1,120 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import figures from "figures";
3
+ import { useEffect, useState } from "react";
4
+ import path from "path";
5
+ import os from "os";
6
+ import { startCloneRepo } from "../utils/repo";
7
+ import { fetchFileTree, readImportantFiles } from "../utils/files";
8
+ import { StepRow } from "../components/repo/StepRow";
9
+ import { FileReviewer } from "../components/repo/FileReviewer";
10
+ import { RepoAnalysis } from "../components/repo/RepoAnalysis";
11
+ import type { Step, ImportantFile } from "../types/repo";
12
+
13
+ export const RepoCommand = ({ url }: { url: string }) => {
14
+ const [steps, setSteps] = useState<Step[]>([
15
+ { type: "cloning", status: "pending" },
16
+ ]);
17
+ const [importantFiles, setImportantFiles] = useState<ImportantFile[]>([]);
18
+ const [fileTree, setFileTree] = useState<string[]>([]);
19
+ const [repoPath, setRepoPath] = useState<string>("");
20
+ const [reviewDone, setReviewDone] = useState(false);
21
+
22
+ const updateLastStep = (updated: Step) =>
23
+ setSteps((prev) => [...prev.slice(0, -1), updated]);
24
+
25
+ const pushStep = (step: Step) => setSteps((prev) => [...prev, step]);
26
+
27
+ const handleCloneSuccess = (rPath: string) => {
28
+ setRepoPath(rPath);
29
+ updateLastStep({ type: "cloning", status: "done" });
30
+ pushStep({ type: "fetching-tree", status: "pending" });
31
+
32
+ fetchFileTree(rPath)
33
+ .then((files) => {
34
+ updateLastStep({ type: "fetching-tree", status: "done" });
35
+ pushStep({ type: "reading-files", status: "pending" });
36
+ setFileTree(files);
37
+ const found = readImportantFiles(rPath, files);
38
+ setImportantFiles(found);
39
+ updateLastStep({ type: "reading-files", status: "done" });
40
+ })
41
+ .catch(() => updateLastStep({ type: "fetching-tree", status: "done" }));
42
+ };
43
+
44
+ useEffect(() => {
45
+ startCloneRepo(url).then((result) => {
46
+ if (result.done) {
47
+ const repoName = path
48
+ .basename(new URL(url).pathname)
49
+ .replace(/\.git$/, "");
50
+ handleCloneSuccess(path.join(os.tmpdir(), repoName));
51
+ } else if (result.folderExists) {
52
+ updateLastStep({
53
+ type: "folder-exists",
54
+ status: "pending",
55
+ repoPath: result.repoPath,
56
+ });
57
+ } else {
58
+ updateLastStep({
59
+ type: "error",
60
+ message: result.error ?? "Unknown error",
61
+ });
62
+ }
63
+ });
64
+ }, [url]);
65
+
66
+ useInput((input) => {
67
+ const last = steps[steps.length - 1];
68
+ if (last?.type !== "folder-exists") return;
69
+ const rPath = last.repoPath;
70
+
71
+ if (input === "y" || input === "Y") {
72
+ updateLastStep({ type: "cloning", status: "pending" });
73
+ startCloneRepo(url, { forceReclone: true }).then((result) => {
74
+ if (result.done) {
75
+ handleCloneSuccess(rPath);
76
+ } else if (!result.folderExists) {
77
+ updateLastStep({
78
+ type: "error",
79
+ message: result.error ?? "Unknown error",
80
+ });
81
+ }
82
+ });
83
+ }
84
+
85
+ if (input === "n" || input === "N") handleCloneSuccess(rPath);
86
+ });
87
+
88
+ const allDone =
89
+ steps[steps.length - 1]?.type === "reading-files" &&
90
+ (steps[steps.length - 1] as Extract<Step, { type: "reading-files" }>)
91
+ .status === "done";
92
+
93
+ return (
94
+ <Box flexDirection="column">
95
+ {steps.map((step, i) => (
96
+ <StepRow key={i} step={step} />
97
+ ))}
98
+
99
+ {allDone && !reviewDone && importantFiles.length > 0 && (
100
+ <FileReviewer
101
+ files={importantFiles}
102
+ onDone={() => setReviewDone(true)}
103
+ />
104
+ )}
105
+
106
+ {allDone && importantFiles.length === 0 && !reviewDone && (
107
+ <Text color="gray">{figures.info} No important files found</Text>
108
+ )}
109
+
110
+ {(reviewDone || (allDone && importantFiles.length === 0)) && (
111
+ <RepoAnalysis
112
+ repoUrl={url}
113
+ repoPath={repoPath}
114
+ fileTree={fileTree}
115
+ files={importantFiles}
116
+ />
117
+ )}
118
+ </Box>
119
+ );
120
+ };
@@ -0,0 +1,294 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import figures from "figures";
4
+ import { useEffect, useState } from "react";
5
+ import path from "path";
6
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
7
+ import { fetchFileTree, readImportantFiles } from "../utils/files";
8
+ import { computeStats, formatNumber, topLanguages } from "../utils/stats";
9
+ import { RepoAnalysis } from "../components/repo/RepoAnalysis";
10
+ import { LensFileMenu } from "../components/repo/LensFileMenu";
11
+ import {
12
+ lensFileExists,
13
+ readLensFile,
14
+ lensFileToAnalysisResult,
15
+ } from "../utils/lensfile";
16
+ import type { ImportantFile } from "../types/repo";
17
+ import type { CodeStats } from "../utils/stats";
18
+ import type { LensMenuChoice } from "../components/repo/LensFileMenu";
19
+
20
+ type ReviewStage =
21
+ | { type: "scanning" }
22
+ | {
23
+ type: "lens-menu";
24
+ fileTree: string[];
25
+ files: ImportantFile[];
26
+ stats: CodeStats;
27
+ }
28
+ | {
29
+ type: "stats";
30
+ stats: CodeStats;
31
+ files: ImportantFile[];
32
+ fileTree: string[];
33
+ }
34
+ | { type: "error"; message: string };
35
+
36
+ function StatRow({ label, value }: { label: string; value: string }) {
37
+ const PAD = 20;
38
+ return (
39
+ <Box>
40
+ <Text color="gray">{label.padEnd(PAD, " ")}</Text>
41
+ <Text color="white" bold>
42
+ {value}
43
+ </Text>
44
+ </Box>
45
+ );
46
+ }
47
+
48
+ function Divider() {
49
+ return <Text color="gray">{"─".repeat(36)}</Text>;
50
+ }
51
+
52
+ const SKIP_DIRS = new Set([
53
+ "node_modules",
54
+ ".git",
55
+ "dist",
56
+ "build",
57
+ ".next",
58
+ "out",
59
+ "coverage",
60
+ "__pycache__",
61
+ ".venv",
62
+ "venv",
63
+ ]);
64
+
65
+ function parseGitignore(dir: string): string[] {
66
+ const p = path.join(dir, ".gitignore");
67
+ if (!existsSync(p)) return [];
68
+ try {
69
+ return readFileSync(p, "utf-8")
70
+ .split("\n")
71
+ .map((l) => l.trim())
72
+ .filter((l) => l && !l.startsWith("#"));
73
+ } catch {
74
+ return [];
75
+ }
76
+ }
77
+
78
+ function matchesGitignore(
79
+ patterns: string[],
80
+ relPath: string,
81
+ isDir: boolean,
82
+ ): boolean {
83
+ const name = path.basename(relPath);
84
+ for (const pattern of patterns) {
85
+ if (pattern.endsWith("/")) {
86
+ if (isDir && name === pattern.slice(0, -1)) return true;
87
+ continue;
88
+ }
89
+ if (pattern.startsWith("!")) continue;
90
+ if (pattern.includes("*")) {
91
+ const regex = new RegExp(
92
+ "^" +
93
+ pattern
94
+ .replace(/\./g, "\\.")
95
+ .replace(/\*\*/g, ".*")
96
+ .replace(/\*/g, "[^/]*") +
97
+ "$",
98
+ );
99
+ if (regex.test(name) || regex.test(relPath)) return true;
100
+ continue;
101
+ }
102
+ if (
103
+ name === pattern ||
104
+ relPath === pattern ||
105
+ relPath.startsWith(pattern + "/")
106
+ )
107
+ return true;
108
+ }
109
+ return false;
110
+ }
111
+
112
+ function walkDir(dir: string, base = dir, patterns?: string[]): string[] {
113
+ const p = patterns ?? parseGitignore(base);
114
+ const results: string[] = [];
115
+ let entries: string[];
116
+ try {
117
+ entries = readdirSync(dir, { encoding: "utf-8" });
118
+ } catch {
119
+ return results;
120
+ }
121
+ for (const entry of entries) {
122
+ if (SKIP_DIRS.has(entry)) continue;
123
+ const full = path.join(dir, entry);
124
+ const rel = path.relative(base, full).replace(/\\/g, "/");
125
+ let isDir = false;
126
+ try {
127
+ isDir = statSync(full).isDirectory();
128
+ } catch {
129
+ continue;
130
+ }
131
+ if (matchesGitignore(p, rel, isDir)) continue;
132
+ if (isDir) results.push(...walkDir(full, base, p));
133
+ else results.push(rel);
134
+ }
135
+ return results;
136
+ }
137
+
138
+ function StatsPanel({
139
+ resolvedPath,
140
+ stats,
141
+ }: {
142
+ resolvedPath: string;
143
+ stats: CodeStats;
144
+ }) {
145
+ const langs = topLanguages(stats.languages);
146
+ return (
147
+ <Box flexDirection="column" marginTop={1} gap={0}>
148
+ <Text bold color="cyan">
149
+ {figures.hamburger} {path.basename(resolvedPath)}
150
+ </Text>
151
+ <Divider />
152
+ <StatRow label="Lines of Code" value={formatNumber(stats.codeLines)} />
153
+ <StatRow label="Total Lines" value={formatNumber(stats.totalLines)} />
154
+ <StatRow label="Files" value={formatNumber(stats.totalFiles)} />
155
+ <StatRow label="Languages" value={langs || "—"} />
156
+ <StatRow label="Functions" value={formatNumber(stats.functions)} />
157
+ <StatRow label="Classes" value={formatNumber(stats.classes)} />
158
+ <StatRow label="Comment Lines" value={formatNumber(stats.commentLines)} />
159
+ <StatRow label="Blank Lines" value={formatNumber(stats.blankLines)} />
160
+ <Divider />
161
+ </Box>
162
+ );
163
+ }
164
+
165
+ export const ReviewCommand = ({
166
+ path: inputPath,
167
+ onExit,
168
+ }: {
169
+ path: string;
170
+ onExit?: () => void;
171
+ }) => {
172
+ const [stage, setStage] = useState<ReviewStage>({ type: "scanning" });
173
+
174
+ const [preloadedResult, setPreloadedResult] = useState<
175
+ import("../types/repo").AnalysisResult | null
176
+ >(null);
177
+ const resolvedPath = path.resolve(inputPath);
178
+
179
+ useEffect(() => {
180
+ if (!existsSync(resolvedPath)) {
181
+ setStage({ type: "error", message: `Path not found: ${resolvedPath}` });
182
+ return;
183
+ }
184
+
185
+ fetchFileTree(resolvedPath)
186
+ .catch(() => walkDir(resolvedPath))
187
+ .then((fileTree) => {
188
+ const stats = computeStats(resolvedPath, fileTree);
189
+ const files = readImportantFiles(resolvedPath, fileTree);
190
+
191
+ if (lensFileExists(resolvedPath)) {
192
+ setStage({ type: "lens-menu", fileTree, files, stats });
193
+ } else {
194
+ setStage({ type: "stats", stats, files, fileTree });
195
+ }
196
+ })
197
+ .catch((err: unknown) =>
198
+ setStage({
199
+ type: "error",
200
+ message: err instanceof Error ? err.message : "Failed to scan",
201
+ }),
202
+ );
203
+ }, [resolvedPath]);
204
+
205
+ const handleLensChoice = (
206
+ choice: LensMenuChoice,
207
+ fileTree: string[],
208
+ files: ImportantFile[],
209
+ stats: CodeStats,
210
+ ) => {
211
+ const lf = readLensFile(resolvedPath);
212
+
213
+ if (choice === "use-cached" && lf) {
214
+ setPreloadedResult(lensFileToAnalysisResult(lf));
215
+ setStage({ type: "stats", stats, files, fileTree });
216
+ return;
217
+ }
218
+
219
+ if (choice === "fix-issues" && lf) {
220
+ setPreloadedResult(lensFileToAnalysisResult(lf));
221
+ setStage({ type: "stats", stats, files, fileTree });
222
+ return;
223
+ }
224
+
225
+ if (choice === "security" && lf) {
226
+ setPreloadedResult(lensFileToAnalysisResult(lf));
227
+ setStage({ type: "stats", stats, files, fileTree });
228
+ return;
229
+ }
230
+
231
+ setStage({ type: "stats", stats, files, fileTree });
232
+ };
233
+
234
+ if (stage.type === "scanning") {
235
+ return (
236
+ <Box marginTop={1} gap={1}>
237
+ <Text color="cyan">{figures.pointer}</Text>
238
+ <Text>Scanning codebase...</Text>
239
+ </Box>
240
+ );
241
+ }
242
+
243
+ if (stage.type === "error") {
244
+ return (
245
+ <Box marginTop={1}>
246
+ <Text color="red">
247
+ {figures.cross} {stage.message}
248
+ </Text>
249
+ </Box>
250
+ );
251
+ }
252
+
253
+ if (stage.type === "lens-menu") {
254
+ const lf = readLensFile(resolvedPath);
255
+ if (!lf) {
256
+ setStage({
257
+ type: "stats",
258
+ stats: stage.stats,
259
+ files: stage.files,
260
+ fileTree: stage.fileTree,
261
+ });
262
+ return null;
263
+ }
264
+ const { fileTree, files, stats } = stage;
265
+ return (
266
+ <Box flexDirection="column" gap={1}>
267
+ <StatsPanel resolvedPath={resolvedPath} stats={stats} />
268
+ <LensFileMenu
269
+ repoPath={resolvedPath}
270
+ lensFile={lf}
271
+ onChoice={(choice) =>
272
+ handleLensChoice(choice, fileTree, files, stats)
273
+ }
274
+ />
275
+ </Box>
276
+ );
277
+ }
278
+
279
+ const { stats, files, fileTree } = stage;
280
+
281
+ return (
282
+ <Box flexDirection="column" gap={1}>
283
+ <StatsPanel resolvedPath={resolvedPath} stats={stats} />
284
+ <RepoAnalysis
285
+ repoUrl={resolvedPath}
286
+ repoPath={resolvedPath}
287
+ fileTree={fileTree}
288
+ files={files}
289
+ preloadedResult={preloadedResult ?? undefined}
290
+ onExit={onExit}
291
+ />
292
+ </Box>
293
+ );
294
+ };