@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
@@ -0,0 +1,732 @@
1
+ import React from "react";
2
+ import { Box, Text, Static, useInput } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import { useState, useRef } from "react";
5
+ import path from "path";
6
+ import os from "os";
7
+ import { ACCENT } from "../../colors";
8
+ import { buildDiffs } from "../repo/DiffViewer";
9
+ import { ProviderPicker } from "../repo/ProviderPicker";
10
+ import { fetchFileTree, readImportantFiles } from "../../utils/files";
11
+ import { startCloneRepo } from "../../utils/repo";
12
+ import { useThinkingPhrase } from "../../utils/thinking";
13
+ import {
14
+ walkDir,
15
+ readClipboard,
16
+ applyPatches,
17
+ extractGithubUrl,
18
+ toCloneUrl,
19
+ parseCloneTag,
20
+ runShell,
21
+ fetchUrl,
22
+ readFile,
23
+ writeFile,
24
+ buildSystemPrompt,
25
+ parseResponse,
26
+ callChat,
27
+ searchWeb,
28
+ } from "../../utils/chat";
29
+ import { StaticMessage } from "./ChatMessage";
30
+ import {
31
+ PermissionPrompt,
32
+ InputBox,
33
+ ShortcutBar,
34
+ TypewriterText,
35
+ CloneOfferView,
36
+ CloningView,
37
+ CloneExistsView,
38
+ CloneDoneView,
39
+ CloneErrorView,
40
+ PreviewView,
41
+ ViewingFileView,
42
+ } from "./ChatOverlays";
43
+ import { TimelineRunner } from "../timeline/TimelineRunner";
44
+ import type { Provider } from "../../types/config";
45
+ import type { Message, ChatStage } from "../../types/chat";
46
+ import {
47
+ appendHistory,
48
+ buildHistorySummary,
49
+ clearRepoHistory,
50
+ } from "../../utils/history";
51
+ import { readLensFile } from "../../utils/lensfile";
52
+ import { ReviewCommand } from "../../commands/review";
53
+
54
+ const COMMANDS = [
55
+ { cmd: "/timeline", desc: "browse commit history" },
56
+ { cmd: "/clear history", desc: "wipe session memory for this repo" },
57
+ { cmd: "/review", desc: "review current codebsae" },
58
+ ];
59
+
60
+ function CommandPalette({
61
+ query,
62
+ onSelect,
63
+ }: {
64
+ query: string;
65
+ onSelect: (cmd: string) => void;
66
+ }) {
67
+ const q = query.toLowerCase();
68
+ const matches = COMMANDS.filter((c) => c.cmd.startsWith(q));
69
+ if (!matches.length) return null;
70
+
71
+ return (
72
+ <Box flexDirection="column" marginBottom={1} marginLeft={2}>
73
+ {matches.map((c, i) => {
74
+ const isExact = c.cmd === query;
75
+ return (
76
+ <Box key={i} gap={2}>
77
+ <Text color={isExact ? ACCENT : "white"} bold={isExact}>
78
+ {c.cmd}
79
+ </Text>
80
+ <Text color="gray" dimColor>
81
+ {c.desc}
82
+ </Text>
83
+ </Box>
84
+ );
85
+ })}
86
+ </Box>
87
+ );
88
+ }
89
+
90
+ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
91
+ const [stage, setStage] = useState<ChatStage>({ type: "picking-provider" });
92
+ const [committed, setCommitted] = useState<Message[]>([]);
93
+ const [provider, setProvider] = useState<Provider | null>(null);
94
+ const [systemPrompt, setSystemPrompt] = useState("");
95
+ const [inputValue, setInputValue] = useState("");
96
+ const [pendingMsgIndex, setPendingMsgIndex] = useState<number | null>(null);
97
+ const [allMessages, setAllMessages] = useState<Message[]>([]);
98
+ const [clonedUrls, setClonedUrls] = useState<Set<string>>(new Set());
99
+ const [showTimeline, setShowTimeline] = useState(false);
100
+ const [showReview, setShowReview] = useState(false);
101
+
102
+ const inputBuffer = useRef("");
103
+ const flushTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
104
+ const thinkingPhrase = useThinkingPhrase(stage.type === "thinking");
105
+
106
+ const flushBuffer = () => {
107
+ const buf = inputBuffer.current;
108
+ if (!buf) return;
109
+ inputBuffer.current = "";
110
+ setInputValue((v) => v + buf);
111
+ };
112
+
113
+ const scheduleFlush = () => {
114
+ if (flushTimer.current !== null) return;
115
+ flushTimer.current = setTimeout(() => {
116
+ flushTimer.current = null;
117
+ flushBuffer();
118
+ }, 16);
119
+ };
120
+
121
+ const handleError = (currentAll: Message[]) => (err: unknown) => {
122
+ const errMsg: Message = {
123
+ role: "assistant",
124
+ content: `Error: ${err instanceof Error ? err.message : "Something went wrong"}`,
125
+ type: "text",
126
+ };
127
+ setAllMessages([...currentAll, errMsg]);
128
+ setCommitted((prev) => [...prev, errMsg]);
129
+ setStage({ type: "idle" });
130
+ };
131
+
132
+ const processResponse = (raw: string, currentAll: Message[]) => {
133
+ const parsed = parseResponse(raw);
134
+
135
+ if (parsed.kind === "changes") {
136
+ if (parsed.patches.length === 0) {
137
+ const msg: Message = {
138
+ role: "assistant",
139
+ content: parsed.content,
140
+ type: "text",
141
+ };
142
+ setAllMessages([...currentAll, msg]);
143
+ setCommitted((prev) => [...prev, msg]);
144
+ setStage({ type: "idle" });
145
+ return;
146
+ }
147
+ const assistantMsg: Message = {
148
+ role: "assistant",
149
+ content: parsed.content,
150
+ type: "plan",
151
+ patches: parsed.patches,
152
+ applied: false,
153
+ };
154
+ const withAssistant = [...currentAll, assistantMsg];
155
+ setAllMessages(withAssistant);
156
+ setPendingMsgIndex(withAssistant.length - 1);
157
+ const diffLines = buildDiffs(repoPath, parsed.patches);
158
+ setStage({
159
+ type: "preview",
160
+ patches: parsed.patches,
161
+ diffLines,
162
+ scrollOffset: 0,
163
+ pendingMessages: currentAll,
164
+ });
165
+ return;
166
+ }
167
+
168
+ if (
169
+ parsed.kind === "shell" ||
170
+ parsed.kind === "fetch" ||
171
+ parsed.kind === "read-file" ||
172
+ parsed.kind === "write-file" ||
173
+ parsed.kind === "search"
174
+ ) {
175
+ let tool: Parameters<typeof PermissionPrompt>[0]["tool"];
176
+ if (parsed.kind === "shell") {
177
+ tool = { type: "shell", command: parsed.command };
178
+ } else if (parsed.kind === "fetch") {
179
+ tool = { type: "fetch", url: parsed.url };
180
+ } else if (parsed.kind === "read-file") {
181
+ tool = { type: "read-file", filePath: parsed.filePath };
182
+ } else if (parsed.kind === "search") {
183
+ tool = { type: "search", query: parsed.query };
184
+ } else {
185
+ tool = {
186
+ type: "write-file",
187
+ filePath: parsed.filePath,
188
+ fileContent: parsed.fileContent,
189
+ };
190
+ }
191
+
192
+ if (parsed.content) {
193
+ const preambleMsg: Message = {
194
+ role: "assistant",
195
+ content: parsed.content,
196
+ type: "text",
197
+ };
198
+ setAllMessages([...currentAll, preambleMsg]);
199
+ setCommitted((prev) => [...prev, preambleMsg]);
200
+ }
201
+
202
+ setStage({
203
+ type: "permission",
204
+ tool,
205
+ pendingMessages: currentAll,
206
+ resolve: async (approved: boolean) => {
207
+ let result = "(denied by user)";
208
+ if (approved) {
209
+ try {
210
+ setStage({ type: "thinking" });
211
+ if (parsed.kind === "shell") {
212
+ result = await runShell(parsed.command, repoPath);
213
+ } else if (parsed.kind === "fetch") {
214
+ result = await fetchUrl(parsed.url);
215
+ } else if (parsed.kind === "read-file") {
216
+ result = readFile(parsed.filePath, repoPath);
217
+ } else if (parsed.kind === "write-file") {
218
+ result = writeFile(
219
+ parsed.filePath,
220
+ parsed.fileContent,
221
+ repoPath,
222
+ );
223
+ } else if (parsed.kind === "search") {
224
+ result = await searchWeb(parsed.query);
225
+ }
226
+ } catch (err: unknown) {
227
+ result = `Error: ${err instanceof Error ? err.message : "failed"}`;
228
+ }
229
+ }
230
+
231
+ if (approved && !result.startsWith("Error:")) {
232
+ const kindMap = {
233
+ shell: "shell-run",
234
+ fetch: "url-fetched",
235
+ "read-file": "file-read",
236
+ "write-file": "file-written",
237
+ search: "url-fetched",
238
+ } as const;
239
+ appendHistory({
240
+ kind: kindMap[parsed.kind as keyof typeof kindMap] ?? "shell-run",
241
+ detail:
242
+ parsed.kind === "shell"
243
+ ? parsed.command
244
+ : parsed.kind === "fetch"
245
+ ? parsed.url
246
+ : parsed.kind === "search"
247
+ ? parsed.query
248
+ : parsed.filePath,
249
+ summary: result.split("\n")[0]?.slice(0, 120) ?? "",
250
+ repoPath,
251
+ });
252
+ }
253
+
254
+ const toolName =
255
+ parsed.kind === "shell"
256
+ ? "shell"
257
+ : parsed.kind === "fetch"
258
+ ? "fetch"
259
+ : parsed.kind === "read-file"
260
+ ? "read-file"
261
+ : parsed.kind === "search"
262
+ ? "search"
263
+ : "write-file";
264
+
265
+ const toolContent =
266
+ parsed.kind === "shell"
267
+ ? parsed.command
268
+ : parsed.kind === "fetch"
269
+ ? parsed.url
270
+ : parsed.kind === "search"
271
+ ? parsed.query
272
+ : parsed.filePath;
273
+
274
+ const toolMsg: Message = {
275
+ role: "assistant",
276
+ type: "tool",
277
+ toolName,
278
+ content: toolContent,
279
+ result,
280
+ approved,
281
+ };
282
+
283
+ const withTool = [...currentAll, toolMsg];
284
+ setAllMessages(withTool);
285
+ setCommitted((prev) => [...prev, toolMsg]);
286
+
287
+ setStage({ type: "thinking" });
288
+ callChat(provider!, systemPrompt, withTool)
289
+ .then((r: string) => processResponse(r, withTool))
290
+ .catch(handleError(withTool));
291
+ },
292
+ });
293
+ return;
294
+ }
295
+
296
+ if (parsed.kind === "clone") {
297
+ if (parsed.content) {
298
+ const preambleMsg: Message = {
299
+ role: "assistant",
300
+ content: parsed.content,
301
+ type: "text",
302
+ };
303
+ setAllMessages([...currentAll, preambleMsg]);
304
+ setCommitted((prev) => [...prev, preambleMsg]);
305
+ }
306
+ setStage({
307
+ type: "clone-offer",
308
+ repoUrl: parsed.repoUrl,
309
+ launchAnalysis: true,
310
+ });
311
+ return;
312
+ }
313
+
314
+ const msg: Message = {
315
+ role: "assistant",
316
+ content: parsed.content,
317
+ type: "text",
318
+ };
319
+ const withMsg = [...currentAll, msg];
320
+ setAllMessages(withMsg);
321
+ setCommitted((prev) => [...prev, msg]);
322
+
323
+ const lastUserMsg = [...currentAll]
324
+ .reverse()
325
+ .find((m) => m.role === "user");
326
+ const githubUrl = lastUserMsg
327
+ ? extractGithubUrl(lastUserMsg.content)
328
+ : null;
329
+
330
+ if (githubUrl && !clonedUrls.has(githubUrl)) {
331
+ setTimeout(() => {
332
+ setStage({ type: "clone-offer", repoUrl: githubUrl });
333
+ }, 80);
334
+ } else {
335
+ setStage({ type: "idle" });
336
+ }
337
+ };
338
+
339
+ const sendMessage = (text: string) => {
340
+ if (!provider) return;
341
+
342
+ if (text.trim().toLowerCase() === "/timeline") {
343
+ setShowTimeline(true);
344
+ return;
345
+ }
346
+
347
+ if (text.trim().toLowerCase() === "/review") {
348
+ setShowReview(true);
349
+ return;
350
+ }
351
+
352
+ if (text.trim().toLowerCase() === "/clear history") {
353
+ clearRepoHistory(repoPath);
354
+ const clearedMsg: Message = {
355
+ role: "assistant",
356
+ content: "History cleared for this repo.",
357
+ type: "text",
358
+ };
359
+ setCommitted((prev) => [...prev, clearedMsg]);
360
+ setAllMessages((prev) => [...prev, clearedMsg]);
361
+ return;
362
+ }
363
+
364
+ const userMsg: Message = { role: "user", content: text, type: "text" };
365
+ const nextAll = [...allMessages, userMsg];
366
+ setCommitted((prev) => [...prev, userMsg]);
367
+ setAllMessages(nextAll);
368
+ setStage({ type: "thinking" });
369
+ callChat(provider, systemPrompt, nextAll)
370
+ .then((raw: string) => processResponse(raw, nextAll))
371
+ .catch(handleError(nextAll));
372
+ };
373
+
374
+ useInput((input, key) => {
375
+ if (showTimeline) return;
376
+
377
+ if (stage.type === "idle") {
378
+ if (key.ctrl && input === "c") {
379
+ process.exit(0);
380
+ return;
381
+ }
382
+
383
+ if (key.tab && inputValue.startsWith("/")) {
384
+ const q = inputValue.toLowerCase();
385
+ const match = COMMANDS.find((c) => c.cmd.startsWith(q));
386
+ if (match) setInputValue(match.cmd);
387
+ return;
388
+ }
389
+ return;
390
+ }
391
+
392
+ if (stage.type === "clone-offer") {
393
+ if (input === "y" || input === "Y" || key.return) {
394
+ const { repoUrl } = stage;
395
+ const launch = stage.launchAnalysis ?? false;
396
+ const cloneUrl = toCloneUrl(repoUrl);
397
+ setStage({ type: "cloning", repoUrl });
398
+ startCloneRepo(cloneUrl).then((result) => {
399
+ if (result.done) {
400
+ const repoName =
401
+ cloneUrl
402
+ .split("/")
403
+ .pop()
404
+ ?.replace(/\.git$/, "") ?? "repo";
405
+ const destPath = path.join(os.tmpdir(), repoName);
406
+ const fileCount = walkDir(destPath).length;
407
+ appendHistory({
408
+ kind: "url-fetched",
409
+ detail: repoUrl,
410
+ summary: `Cloned ${repoName} — ${fileCount} files`,
411
+ repoPath,
412
+ });
413
+ setClonedUrls((prev) => new Set([...prev, repoUrl]));
414
+ setStage({
415
+ type: "clone-done",
416
+ repoUrl,
417
+ destPath,
418
+ fileCount,
419
+ launchAnalysis: launch,
420
+ });
421
+ } else if (result.folderExists && result.repoPath) {
422
+ setStage({
423
+ type: "clone-exists",
424
+ repoUrl,
425
+ repoPath: result.repoPath,
426
+ });
427
+ } else {
428
+ setStage({
429
+ type: "clone-error",
430
+ message:
431
+ !result.folderExists && result.error
432
+ ? result.error
433
+ : "Clone failed",
434
+ });
435
+ }
436
+ });
437
+ return;
438
+ }
439
+ if (input === "n" || input === "N" || key.escape)
440
+ setStage({ type: "idle" });
441
+ return;
442
+ }
443
+
444
+ if (stage.type === "clone-exists") {
445
+ if (input === "y" || input === "Y") {
446
+ const { repoUrl, repoPath: existingPath } = stage;
447
+ const cloneUrl = toCloneUrl(repoUrl);
448
+ setStage({ type: "cloning", repoUrl });
449
+ startCloneRepo(cloneUrl, { forceReclone: true }).then((result) => {
450
+ if (result.done) {
451
+ const fileCount = walkDir(existingPath).length;
452
+ setStage({
453
+ type: "clone-done",
454
+ repoUrl,
455
+ destPath: existingPath,
456
+ fileCount,
457
+ });
458
+ } else {
459
+ setStage({
460
+ type: "clone-error",
461
+ message:
462
+ !result.folderExists && result.error
463
+ ? result.error
464
+ : "Clone failed",
465
+ });
466
+ }
467
+ });
468
+ return;
469
+ }
470
+ if (input === "n" || input === "N") {
471
+ const { repoUrl, repoPath: existingPath } = stage;
472
+ const fileCount = walkDir(existingPath).length;
473
+ setStage({
474
+ type: "clone-done",
475
+ repoUrl,
476
+ destPath: existingPath,
477
+ fileCount,
478
+ });
479
+ return;
480
+ }
481
+ return;
482
+ }
483
+
484
+ if (stage.type === "clone-done" || stage.type === "clone-error") {
485
+ if (key.return || key.escape) {
486
+ if (stage.type === "clone-done") {
487
+ const repoName = stage.repoUrl.split("/").pop() ?? "repo";
488
+
489
+ const summaryMsg: Message = {
490
+ role: "assistant",
491
+ type: "text",
492
+ content: `Cloned **${repoName}** (${stage.fileCount} files) to \`${stage.destPath}\`.\n\nAsk me anything about it — I can read files, explain how it works, or suggest improvements.`,
493
+ };
494
+
495
+ const contextMsg: Message = {
496
+ role: "assistant",
497
+ type: "tool",
498
+ toolName: "fetch",
499
+ content: stage.repoUrl,
500
+ result: `Clone complete. Repo: ${repoName}. Local path: ${stage.destPath}. ${stage.fileCount} files. Use read-file with full path e.g. read-file ${stage.destPath}/README.md`,
501
+ approved: true,
502
+ };
503
+ const withClone = [...allMessages, contextMsg, summaryMsg];
504
+ setAllMessages(withClone);
505
+ setCommitted((prev) => [...prev, summaryMsg]);
506
+ setStage({ type: "idle" });
507
+ } else {
508
+ setStage({ type: "idle" });
509
+ }
510
+ }
511
+ return;
512
+ }
513
+
514
+ if (stage.type === "cloning") return;
515
+
516
+ if (stage.type === "permission") {
517
+ if (input === "y" || input === "Y" || key.return) {
518
+ stage.resolve(true);
519
+ return;
520
+ }
521
+ if (input === "n" || input === "N" || key.escape) {
522
+ stage.resolve(false);
523
+ return;
524
+ }
525
+ return;
526
+ }
527
+
528
+ if (stage.type === "preview") {
529
+ if (key.upArrow) {
530
+ setStage({
531
+ ...stage,
532
+ scrollOffset: Math.max(0, stage.scrollOffset - 1),
533
+ });
534
+ return;
535
+ }
536
+ if (key.downArrow) {
537
+ setStage({ ...stage, scrollOffset: stage.scrollOffset + 1 });
538
+ return;
539
+ }
540
+ if (key.escape || input === "s" || input === "S") {
541
+ if (pendingMsgIndex !== null) {
542
+ const msg = allMessages[pendingMsgIndex];
543
+ if (msg?.type === "plan") {
544
+ setCommitted((prev) => [...prev, { ...msg, applied: false }]);
545
+ appendHistory({
546
+ kind: "code-skipped",
547
+ detail: msg.patches
548
+ .map((p: { path: string }) => p.path)
549
+ .join(", "),
550
+ summary: `Skipped changes to ${msg.patches.length} file(s)`,
551
+ repoPath,
552
+ });
553
+ }
554
+ }
555
+ setPendingMsgIndex(null);
556
+ setStage({ type: "idle" });
557
+ return;
558
+ }
559
+ if (key.return || input === "a" || input === "A") {
560
+ try {
561
+ applyPatches(repoPath, stage.patches);
562
+ appendHistory({
563
+ kind: "code-applied",
564
+ detail: stage.patches.map((p) => p.path).join(", "),
565
+ summary: `Applied changes to ${stage.patches.length} file(s)`,
566
+ repoPath,
567
+ });
568
+ } catch {
569
+ /* non-fatal */
570
+ }
571
+ if (pendingMsgIndex !== null) {
572
+ const msg = allMessages[pendingMsgIndex];
573
+ if (msg?.type === "plan") {
574
+ const applied: Message = { ...msg, applied: true };
575
+ setAllMessages((prev) =>
576
+ prev.map((m, i) => (i === pendingMsgIndex ? applied : m)),
577
+ );
578
+ setCommitted((prev) => [...prev, applied]);
579
+ }
580
+ }
581
+ setPendingMsgIndex(null);
582
+ setStage({ type: "idle" });
583
+ return;
584
+ }
585
+ }
586
+
587
+ if (stage.type === "viewing-file") {
588
+ if (key.upArrow) {
589
+ setStage({
590
+ ...stage,
591
+ scrollOffset: Math.max(0, stage.scrollOffset - 1),
592
+ });
593
+ return;
594
+ }
595
+ if (key.downArrow) {
596
+ setStage({ ...stage, scrollOffset: stage.scrollOffset + 1 });
597
+ return;
598
+ }
599
+ if (key.escape || key.return) {
600
+ setStage({ type: "idle" });
601
+ return;
602
+ }
603
+ }
604
+ });
605
+
606
+ const handleProviderDone = (p: Provider) => {
607
+ setProvider(p);
608
+ setStage({ type: "loading" });
609
+ fetchFileTree(repoPath)
610
+ .catch(() => walkDir(repoPath))
611
+ .then((fileTree) => {
612
+ const importantFiles = readImportantFiles(repoPath, fileTree);
613
+ const historySummary = buildHistorySummary(repoPath);
614
+ const lensFile = readLensFile(repoPath);
615
+ const lensContext = lensFile
616
+ ? `
617
+
618
+ ## LENS.md (previous analysis)
619
+ ${lensFile.overview}
620
+
621
+ Important folders: ${lensFile.importantFolders.join(", ")}
622
+ Suggestions: ${lensFile.suggestions.slice(0, 3).join("; ")}`
623
+ : "";
624
+ setSystemPrompt(
625
+ buildSystemPrompt(importantFiles, historySummary) + lensContext,
626
+ );
627
+ const historyNote = historySummary
628
+ ? "\n\nI have memory of previous actions in this repo."
629
+ : "";
630
+ const lensGreetNote = lensFile
631
+ ? "\n\nFound LENS.md — I have context from a previous analysis of this repo."
632
+ : "";
633
+ const greeting: Message = {
634
+ role: "assistant",
635
+ content: `Welcome to Lens \nCodebase loaded — ${importantFiles.length} files indexed.${historyNote}${lensGreetNote}\nAsk me anything, tell me what to build, share a URL, or ask me to read/write files.\n\nTip: type /timeline to browse commit history.`,
636
+ type: "text",
637
+ };
638
+ setCommitted([greeting]);
639
+ setAllMessages([greeting]);
640
+ setStage({ type: "idle" });
641
+ })
642
+ .catch(() => setStage({ type: "idle" }));
643
+ };
644
+
645
+ if (stage.type === "picking-provider")
646
+ return <ProviderPicker onDone={handleProviderDone} />;
647
+
648
+ if (stage.type === "loading") {
649
+ return (
650
+ <Box gap={1} marginTop={1}>
651
+ <Text color={ACCENT}>*</Text>
652
+ <Text color={ACCENT}>
653
+ <Spinner />
654
+ </Text>
655
+ <Text color="gray" dimColor>
656
+ indexing codebase…
657
+ </Text>
658
+ </Box>
659
+ );
660
+ }
661
+
662
+ if (showTimeline) {
663
+ return (
664
+ <TimelineRunner
665
+ repoPath={repoPath}
666
+ onExit={() => setShowTimeline(false)}
667
+ />
668
+ );
669
+ }
670
+
671
+ if (showReview) {
672
+ return (
673
+ <ReviewCommand path={repoPath} onExit={() => setShowReview(false)} />
674
+ );
675
+ }
676
+
677
+ if (stage.type === "clone-offer")
678
+ return <CloneOfferView stage={stage} committed={committed} />;
679
+ if (stage.type === "cloning")
680
+ return <CloningView stage={stage} committed={committed} />;
681
+ if (stage.type === "clone-exists")
682
+ return <CloneExistsView stage={stage} committed={committed} />;
683
+ if (stage.type === "clone-done")
684
+ return <CloneDoneView stage={stage} committed={committed} />;
685
+ if (stage.type === "clone-error")
686
+ return <CloneErrorView stage={stage} committed={committed} />;
687
+ if (stage.type === "preview")
688
+ return <PreviewView stage={stage} committed={committed} />;
689
+ if (stage.type === "viewing-file")
690
+ return <ViewingFileView stage={stage} committed={committed} />;
691
+
692
+ return (
693
+ <Box flexDirection="column">
694
+ <Static items={committed}>
695
+ {(msg, i) => <StaticMessage key={i} msg={msg} />}
696
+ </Static>
697
+
698
+ {stage.type === "thinking" && (
699
+ <Box gap={1}>
700
+ <Text color={ACCENT}>●</Text>
701
+ <TypewriterText text={thinkingPhrase} />
702
+ </Box>
703
+ )}
704
+
705
+ {stage.type === "permission" && (
706
+ <PermissionPrompt tool={stage.tool} onDecide={stage.resolve} />
707
+ )}
708
+
709
+ {stage.type === "idle" && (
710
+ <Box flexDirection="column">
711
+ {inputValue.startsWith("/") && (
712
+ <CommandPalette
713
+ query={inputValue}
714
+ onSelect={(cmd) => {
715
+ setInputValue(cmd);
716
+ }}
717
+ />
718
+ )}
719
+ <InputBox
720
+ value={inputValue}
721
+ onChange={setInputValue}
722
+ onSubmit={(val) => {
723
+ if (val.trim()) sendMessage(val.trim());
724
+ setInputValue("");
725
+ }}
726
+ />
727
+ <ShortcutBar />
728
+ </Box>
729
+ )}
730
+ </Box>
731
+ );
732
+ };