@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/src/index.tsx ADDED
@@ -0,0 +1,60 @@
1
+ import React from "react";
2
+ import { render } from "ink";
3
+ import { Command } from "commander";
4
+ import { RepoCommand } from "./commands/repo";
5
+ import { InitCommand } from "./commands/provider";
6
+ import { ReviewCommand } from "./commands/review";
7
+ import { TaskCommand } from "./commands/task";
8
+ import { ChatCommand } from "./commands/chat";
9
+ import { TimelineCommand } from "./commands/timeline";
10
+
11
+ const program = new Command();
12
+
13
+ program
14
+ .command("repo <url>")
15
+ .description("Analyze a remote repository")
16
+ .action((url) => {
17
+ render(<RepoCommand url={url} />);
18
+ });
19
+
20
+ program
21
+ .command("provider")
22
+ .description("Configure AI providers")
23
+ .action(() => {
24
+ render(<InitCommand />);
25
+ });
26
+
27
+ program
28
+ .command("review [path]")
29
+ .description("Review a local codebase")
30
+ .action((inputPath) => {
31
+ render(<ReviewCommand path={inputPath ?? "."} />);
32
+ });
33
+
34
+ program
35
+ .command("task <text>")
36
+ .description("Apply a natural language change to the codebase")
37
+ .option("-p, --path <path>", "Path to the repo", ".")
38
+ .action((text: string, opts: { path: string }) => {
39
+ render(<TaskCommand prompt={text} path={opts.path} />);
40
+ });
41
+
42
+ program
43
+ .command("chat")
44
+ .description("Chat with your codebase — ask questions or make changes")
45
+ .option("-p, --path <path>", "Path to the repo", ".")
46
+ .action((opts: { path: string }) => {
47
+ render(<ChatCommand path={opts.path} />);
48
+ });
49
+
50
+ program
51
+ .command("timeline")
52
+ .description(
53
+ "Explore your code history — see commits, changes, and evolution",
54
+ )
55
+ .option("-p, --path <path>", "Path to the repo", ".")
56
+ .action((opts: { path: string }) => {
57
+ render(<TimelineCommand path={opts.path} />);
58
+ });
59
+
60
+ program.parse(process.argv);
@@ -0,0 +1,69 @@
1
+ import type { FilePatch, DiffLine } from "../components/repo/DiffViewer";
2
+
3
+ // ── Tool calls ────────────────────────────────────────────────────────────────
4
+
5
+ export type ToolCall =
6
+ | { type: "shell"; command: string }
7
+ | { type: "fetch"; url: string }
8
+ | { type: "read-file"; filePath: string }
9
+ | { type: "write-file"; filePath: string; fileContent: string }
10
+ | { type: "search"; query: string };
11
+
12
+ // ── Messages ──────────────────────────────────────────────────────────────────
13
+
14
+ export type Message =
15
+ | { role: "user" | "assistant"; type: "text"; content: string }
16
+ | {
17
+ role: "assistant";
18
+ type: "tool";
19
+ toolName: "shell" | "fetch" | "read-file" | "write-file" | "search";
20
+ content: string;
21
+ result: string;
22
+ approved: boolean;
23
+ }
24
+ | {
25
+ role: "assistant";
26
+ type: "plan";
27
+ content: string;
28
+ patches: FilePatch[];
29
+ applied: boolean;
30
+ };
31
+
32
+ // ── Chat stage ────────────────────────────────────────────────────────────────
33
+
34
+ export type ChatStage =
35
+ | { type: "picking-provider" }
36
+ | { type: "loading" }
37
+ | { type: "idle" }
38
+ | { type: "thinking" }
39
+ | { type: "error"; message: string }
40
+ | {
41
+ type: "permission";
42
+ tool: ToolCall;
43
+ pendingMessages: Message[];
44
+ resolve: (approved: boolean) => void;
45
+ }
46
+ | {
47
+ type: "preview";
48
+ patches: FilePatch[];
49
+ diffLines: DiffLine[][];
50
+ scrollOffset: number;
51
+ pendingMessages: Message[];
52
+ }
53
+ | {
54
+ type: "viewing-file";
55
+ file: { path: string; isNew: boolean; patch: FilePatch };
56
+ diffLines: DiffLine[];
57
+ scrollOffset: number;
58
+ }
59
+ | { type: "clone-offer"; repoUrl: string; launchAnalysis?: boolean }
60
+ | { type: "cloning"; repoUrl: string }
61
+ | { type: "clone-exists"; repoUrl: string; repoPath: string }
62
+ | {
63
+ type: "clone-done";
64
+ repoUrl: string;
65
+ destPath: string;
66
+ fileCount: number;
67
+ launchAnalysis?: boolean;
68
+ }
69
+ | { type: "clone-error"; message: string };
@@ -0,0 +1,20 @@
1
+ export type ProviderType =
2
+ | "anthropic"
3
+ | "gemini"
4
+ | "openai"
5
+ | "ollama"
6
+ | "custom";
7
+
8
+ export type Provider = {
9
+ id: string;
10
+ type: ProviderType;
11
+ name: string;
12
+ apiKey?: string;
13
+ baseUrl?: string;
14
+ model: string;
15
+ };
16
+
17
+ export type Config = {
18
+ providers: Provider[];
19
+ defaultProviderId?: string;
20
+ };
@@ -0,0 +1,42 @@
1
+ export type Step =
2
+ | { type: "cloning"; status: "pending" | "done" }
3
+ | { type: "folder-exists"; status: "pending"; repoPath: string }
4
+ | { type: "fetching-tree"; status: "pending" | "done" }
5
+ | { type: "reading-files"; status: "pending" | "done" }
6
+ | { type: "error"; message: string };
7
+
8
+ export type ReviewStage = "list" | "file";
9
+
10
+ export type FileTree = {
11
+ name: string;
12
+ children?: FileTree[];
13
+ };
14
+
15
+ export type ImportantFile = {
16
+ path: string;
17
+ content: string;
18
+ };
19
+
20
+ export type AIProvider =
21
+ | "anthropic"
22
+ | "gemini"
23
+ | "ollama"
24
+ | "openai"
25
+ | "custom";
26
+
27
+ export type AnalysisResult = {
28
+ overview: string;
29
+ importantFolders: string[];
30
+ missingConfigs: string[];
31
+ securityIssues: string[];
32
+ suggestions: string[];
33
+ };
34
+
35
+ export type PackageManager = "npm" | "yarn" | "pnpm" | "pip" | "unknown";
36
+
37
+ export type PreviewInfo = {
38
+ packageManager: PackageManager;
39
+ installCmd: string;
40
+ devCmd: string;
41
+ port: number | null;
42
+ };
@@ -0,0 +1,233 @@
1
+ import { exec } from "child_process";
2
+ import { readFileSync, existsSync } from "fs";
3
+ import path from "path";
4
+ import type { Provider } from "../types/config";
5
+ import type { AnalysisResult, ImportantFile } from "../types/repo";
6
+
7
+ export function buildFileListPrompt(
8
+ repoUrl: string,
9
+ fileTree: string[],
10
+ ): string {
11
+ return `You are a senior software engineer. You are about to analyze this repository:
12
+ Repository URL: ${repoUrl}
13
+
14
+ Here is the complete file tree (${fileTree.length} files):
15
+ ${fileTree.join("\n")}
16
+
17
+ Your job is to select the files you need to read to fully understand what this project is, what it does, and how it works.
18
+
19
+ Rules:
20
+ - ALWAYS include package.json, tsconfig.json, README.md if they exist
21
+ - ALWAYS include ALL files inside src/ — especially index files, main entry points, and any files that reveal the project's purpose (components, hooks, utilities, exports)
22
+ - Include config files: vite.config, eslint.config, tailwind.config, etc.
23
+ - If there is a src/index.ts or src/main.ts or src/lib/index.ts, ALWAYS include it — these reveal what the project exports
24
+ - Do NOT skip source files just because there are many — pick up to 30 files
25
+ - Prefer breadth: pick at least one file from every folder under src/
26
+
27
+ Respond ONLY with a JSON array of file paths relative to repo root. No markdown, no explanation. Example:
28
+ ["package.json", "src/main.ts", "src/components/Button.tsx"]`;
29
+ }
30
+
31
+ export function buildAnalysisPrompt(
32
+ repoUrl: string,
33
+ files: ImportantFile[],
34
+ ): string {
35
+ const fileList = files
36
+ .map((f) => `### ${f.path}\n\`\`\`\n${f.content.slice(0, 3000)}\n\`\`\``)
37
+ .join("\n\n");
38
+
39
+ return `You are a senior software engineer analyzing a repository.
40
+ Repository URL: ${repoUrl}
41
+
42
+ Here are the file contents:
43
+
44
+ ${fileList}
45
+
46
+ Analyze this repository thoroughly using the actual file contents above.
47
+
48
+ Important instructions:
49
+ - Read the actual source code carefully to determine what the project really is
50
+ - Look at every component, hook, utility and describe what it actually does
51
+ - importantFolders must describe EVERY folder with specifics: what files are in it, what they do, and why they matter
52
+ - suggestions must be specific to the actual code you read — reference real file names, real function names, real patterns you saw
53
+ - missingConfigs should only list things genuinely missing for THIS type of project
54
+ - securityIssues must reference actual file names and line patterns found
55
+ - overview must be specific: name the actual components/features/exports you saw, not just the tech stack
56
+
57
+ Respond ONLY with a JSON object (no markdown, no explanation) with this exact shape:
58
+ {
59
+ "overview": "3-5 sentences. Name the actual components, features, or exports you found. Describe what the project does, who would use it, and what makes it distinctive. Be specific — mention actual file names or component names.",
60
+ "importantFolders": [
61
+ "src/components: contains X, Y, Z components. ButtonComponent uses CVA for variants. Each component is exported from index.ts."
62
+ ],
63
+ "missingConfigs": ["only configs genuinely missing and relevant — explain WHY each is missing for this specific project"],
64
+ "securityIssues": ["reference actual file names and patterns found"],
65
+ "suggestions": ["each suggestion must reference actual code — e.g. 'In src/components/Button.tsx, consider adding ...' not generic advice"]
66
+ }`;
67
+ }
68
+
69
+ function parseStringArray(text: string): string[] {
70
+ const cleaned = text.replace(/```json|```/g, "").trim();
71
+ const match = cleaned.match(/\[[\s\S]*\]/);
72
+ if (!match) return [];
73
+ try {
74
+ return JSON.parse(match[0]) as string[];
75
+ } catch {
76
+ return [];
77
+ }
78
+ }
79
+
80
+ function parseResult(text: string): AnalysisResult {
81
+ const cleaned = text.replace(/```json|```/g, "").trim();
82
+ const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
83
+ if (!jsonMatch) throw new Error(`No JSON found in response:\n${cleaned}`);
84
+
85
+ const parsed = JSON.parse(jsonMatch[0]) as Partial<AnalysisResult>;
86
+
87
+ return {
88
+ overview: parsed.overview ?? "No overview provided",
89
+ importantFolders: parsed.importantFolders ?? [],
90
+ missingConfigs: parsed.missingConfigs ?? [],
91
+ securityIssues: parsed.securityIssues ?? [],
92
+ suggestions: parsed.suggestions ?? [],
93
+ };
94
+ }
95
+
96
+ export function checkOllamaInstalled(): Promise<boolean> {
97
+ return new Promise((resolve) => {
98
+ exec("ollama --version", (err) => resolve(!err));
99
+ });
100
+ }
101
+
102
+ export function getOllamaModels(): Promise<string[]> {
103
+ return new Promise((resolve) => {
104
+ exec("ollama list", (err, stdout) => {
105
+ if (err) return resolve([]);
106
+ const models = stdout
107
+ .trim()
108
+ .split("\n")
109
+ .slice(1)
110
+ .map((line) => line.split(/\s+/)[0] ?? "")
111
+ .filter(Boolean);
112
+ resolve(models);
113
+ });
114
+ });
115
+ }
116
+
117
+ async function callModel(provider: Provider, prompt: string): Promise<string> {
118
+ switch (provider.type) {
119
+ case "anthropic": {
120
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
121
+ method: "POST",
122
+ headers: {
123
+ "Content-Type": "application/json",
124
+ "x-api-key": provider.apiKey ?? "",
125
+ "anthropic-version": "2023-06-01",
126
+ },
127
+ body: JSON.stringify({
128
+ model: provider.model,
129
+ max_tokens: 2048,
130
+ messages: [{ role: "user", content: prompt }],
131
+ }),
132
+ });
133
+ if (!response.ok)
134
+ throw new Error(`Anthropic API error: ${response.statusText}`);
135
+ const data = (await response.json()) as any;
136
+ return data.content
137
+ .filter((b: { type: string }) => b.type === "text")
138
+ .map((b: { text: string }) => b.text)
139
+ .join("");
140
+ }
141
+
142
+ case "gemini": {
143
+ const response = await fetch(
144
+ `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${provider.apiKey ?? ""}`,
145
+ {
146
+ method: "POST",
147
+ headers: { "Content-Type": "application/json" },
148
+ body: JSON.stringify({
149
+ contents: [{ parts: [{ text: prompt }] }],
150
+ }),
151
+ },
152
+ );
153
+ if (!response.ok)
154
+ throw new Error(`Gemini API error: ${response.statusText}`);
155
+ const data = (await response.json()) as any;
156
+ return data.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
157
+ }
158
+
159
+ case "ollama": {
160
+ const baseUrl = provider.baseUrl ?? "http://localhost:11434";
161
+ const response = await fetch(`${baseUrl}/api/generate`, {
162
+ method: "POST",
163
+ headers: { "Content-Type": "application/json" },
164
+ body: JSON.stringify({
165
+ model: provider.model,
166
+ prompt,
167
+ stream: false,
168
+ }),
169
+ });
170
+ if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
171
+ const data = (await response.json()) as any;
172
+ return data.response ?? "";
173
+ }
174
+
175
+ case "openai":
176
+ case "custom": {
177
+ const baseUrl = provider.baseUrl ?? "https://api.openai.com/v1";
178
+ const response = await fetch(`${baseUrl}/chat/completions`, {
179
+ method: "POST",
180
+ headers: {
181
+ "Content-Type": "application/json",
182
+ Authorization: `Bearer ${provider.apiKey ?? ""}`,
183
+ },
184
+ body: JSON.stringify({
185
+ model: provider.model,
186
+ messages: [{ role: "user", content: prompt }],
187
+ }),
188
+ });
189
+ if (!response.ok)
190
+ throw new Error(`OpenAI-compat API error: ${response.statusText}`);
191
+ const data = (await response.json()) as any;
192
+ return data.choices?.[0]?.message?.content ?? "";
193
+ }
194
+
195
+ default:
196
+ throw new Error(`Unknown provider type`);
197
+ }
198
+ }
199
+
200
+ export async function requestFileList(
201
+ repoUrl: string,
202
+ repoPath: string,
203
+ fileTree: string[],
204
+ provider: Provider,
205
+ ): Promise<ImportantFile[]> {
206
+ const prompt = buildFileListPrompt(repoUrl, fileTree);
207
+ const text = await callModel(provider, prompt);
208
+ const requestedPaths = parseStringArray(text);
209
+
210
+ const files: ImportantFile[] = [];
211
+ for (const filePath of requestedPaths) {
212
+ const fullPath = path.join(repoPath, filePath);
213
+ if (existsSync(fullPath)) {
214
+ try {
215
+ const content = readFileSync(fullPath, "utf-8");
216
+ files.push({ path: filePath, content });
217
+ } catch {}
218
+ }
219
+ }
220
+ return files;
221
+ }
222
+
223
+ export async function analyzeRepo(
224
+ repoUrl: string,
225
+ files: ImportantFile[],
226
+ provider: Provider,
227
+ ): Promise<AnalysisResult> {
228
+ const prompt = buildAnalysisPrompt(repoUrl, files);
229
+ const text = await callModel(provider, prompt);
230
+ return parseResult(text);
231
+ }
232
+
233
+ export const callModelRaw = callModel;