@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,36 @@
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 { PromptRunner } from "../components/task/TaskRunner";
7
+
8
+ export const TaskCommand = ({
9
+ prompt,
10
+ path: inputPath,
11
+ }: {
12
+ prompt: string;
13
+ path: string;
14
+ }) => {
15
+ const resolvedPath = path.resolve(inputPath);
16
+
17
+ if (!existsSync(resolvedPath)) {
18
+ return (
19
+ <Box marginTop={1}>
20
+ <Text color="red">
21
+ {figures.cross} Path not found: {resolvedPath}
22
+ </Text>
23
+ </Box>
24
+ );
25
+ }
26
+
27
+ if (!prompt.trim()) {
28
+ return (
29
+ <Box marginTop={1}>
30
+ <Text color="red">{figures.cross} Prompt cannot be empty.</Text>
31
+ </Box>
32
+ );
33
+ }
34
+
35
+ return <PromptRunner repoPath={resolvedPath} userPrompt={prompt} />;
36
+ };
@@ -0,0 +1,22 @@
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 { TimelineRunner } from "../components/timeline/TimelineRunner";
7
+
8
+ export const TimelineCommand = ({ path: inputPath }: { path: string }) => {
9
+ const resolvedPath = path.resolve(inputPath);
10
+
11
+ if (!existsSync(resolvedPath)) {
12
+ return (
13
+ <Box marginTop={1}>
14
+ <Text color="red">
15
+ {figures.cross} Path not found: {resolvedPath}
16
+ </Text>
17
+ </Box>
18
+ );
19
+ }
20
+
21
+ return <TimelineRunner repoPath={resolvedPath} />;
22
+ };
@@ -0,0 +1,176 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { ACCENT } from "../../colors";
4
+ import type { Message } from "../../types/chat";
5
+
6
+ function InlineText({ text }: { text: string }) {
7
+ const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*)/g);
8
+ return (
9
+ <>
10
+ {parts.map((part, i) => {
11
+ if (part.startsWith("`") && part.endsWith("`")) {
12
+ return (
13
+ <Text key={i} color={ACCENT}>
14
+ {part.slice(1, -1)}
15
+ </Text>
16
+ );
17
+ }
18
+ if (part.startsWith("**") && part.endsWith("**")) {
19
+ return (
20
+ <Text key={i} bold color="white">
21
+ {part.slice(2, -2)}
22
+ </Text>
23
+ );
24
+ }
25
+ return (
26
+ <Text key={i} color="white">
27
+ {part}
28
+ </Text>
29
+ );
30
+ })}
31
+ </>
32
+ );
33
+ }
34
+
35
+ function CodeBlock({ lang, code }: { lang: string; code: string }) {
36
+ return (
37
+ <Box flexDirection="column" marginY={1} marginLeft={2}>
38
+ {lang && <Text color="gray">{lang}</Text>}
39
+ {code.split("\n").map((line, i) => (
40
+ <Text key={i} color={ACCENT}>
41
+ {" "}
42
+ {line}
43
+ </Text>
44
+ ))}
45
+ </Box>
46
+ );
47
+ }
48
+
49
+ function MessageBody({ content }: { content: string }) {
50
+ const segments = content.split(/(```[\s\S]*?```)/g);
51
+
52
+ return (
53
+ <Box flexDirection="column">
54
+ {segments.map((seg, si) => {
55
+ if (seg.startsWith("```")) {
56
+ const lines = seg.slice(3).split("\n");
57
+ const lang = lines[0]?.trim() ?? "";
58
+ const code = lines
59
+ .slice(1)
60
+ .join("\n")
61
+ .replace(/```\s*$/, "")
62
+ .trimEnd();
63
+ return <CodeBlock key={si} lang={lang} code={code} />;
64
+ }
65
+
66
+ const lines = seg.split("\n").filter((l) => l.trim() !== "");
67
+ return (
68
+ <Box key={si} flexDirection="column">
69
+ {lines.map((line, li) => {
70
+ if (line.match(/^[-*•]\s/)) {
71
+ return (
72
+ <Box key={li} gap={1}>
73
+ <Text color={ACCENT}>*</Text>
74
+ <InlineText text={line.slice(2).trim()} />
75
+ </Box>
76
+ );
77
+ }
78
+
79
+ if (line.match(/^\d+\.\s/)) {
80
+ const num = line.match(/^(\d+)\.\s/)![1];
81
+ return (
82
+ <Box key={li} gap={1}>
83
+ <Text color="gray">{num}.</Text>
84
+ <InlineText text={line.replace(/^\d+\.\s/, "").trim()} />
85
+ </Box>
86
+ );
87
+ }
88
+
89
+ return (
90
+ <Box key={li}>
91
+ <InlineText text={line} />
92
+ </Box>
93
+ );
94
+ })}
95
+ </Box>
96
+ );
97
+ })}
98
+ </Box>
99
+ );
100
+ }
101
+
102
+ export function StaticMessage({ msg }: { msg: Message }) {
103
+ if (msg.role === "user") {
104
+ return (
105
+ <Box marginBottom={1} gap={1}>
106
+ <Text color="gray">{">"}</Text>
107
+ <Text color="white" bold>
108
+ {msg.content}
109
+ </Text>
110
+ </Box>
111
+ );
112
+ }
113
+
114
+ if (msg.type === "tool") {
115
+ const icons: Record<string, string> = {
116
+ shell: "$",
117
+ fetch: "~>",
118
+ "read-file": "r",
119
+ "write-file": "w",
120
+ search: "?",
121
+ };
122
+ const icon = icons[msg.toolName] ?? "·";
123
+ const label =
124
+ msg.toolName === "shell"
125
+ ? msg.content
126
+ : msg.toolName === "search"
127
+ ? `"${msg.content}"`
128
+ : msg.content;
129
+
130
+ return (
131
+ <Box flexDirection="column" marginBottom={1}>
132
+ <Box gap={1}>
133
+ <Text color={msg.approved ? ACCENT : "red"}>{icon}</Text>
134
+ <Text color={msg.approved ? "gray" : "red"} dimColor={!msg.approved}>
135
+ {label}
136
+ </Text>
137
+ {!msg.approved && <Text color="red">denied</Text>}
138
+ </Box>
139
+ {msg.approved && msg.result && (
140
+ <Box marginLeft={2}>
141
+ <Text color="gray">
142
+ {msg.result.split("\n")[0]?.slice(0, 120)}
143
+ {(msg.result.split("\n")[0]?.length ?? 0) > 120 ? "…" : ""}
144
+ </Text>
145
+ </Box>
146
+ )}
147
+ </Box>
148
+ );
149
+ }
150
+
151
+ if (msg.type === "plan") {
152
+ return (
153
+ <Box flexDirection="column" marginBottom={1}>
154
+ <Box gap={1}>
155
+ <Text color={ACCENT}>*</Text>
156
+ <MessageBody content={msg.content} />
157
+ </Box>
158
+ <Box marginLeft={2} gap={1}>
159
+ <Text color={msg.applied ? "green" : "gray"}>
160
+ {msg.applied ? "✓" : "·"}
161
+ </Text>
162
+ <Text color={msg.applied ? "green" : "gray"} dimColor={!msg.applied}>
163
+ {msg.applied ? "changes applied" : "changes skipped"}
164
+ </Text>
165
+ </Box>
166
+ </Box>
167
+ );
168
+ }
169
+
170
+ return (
171
+ <Box marginBottom={1} gap={1}>
172
+ <Text color={ACCENT}>●</Text>
173
+ <MessageBody content={msg.content} />
174
+ </Box>
175
+ );
176
+ }
@@ -0,0 +1,329 @@
1
+ import React from "react";
2
+ import { Box, Text, Static } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import TextInput from "ink-text-input";
5
+ import figures from "figures";
6
+ import { ACCENT } from "../../colors";
7
+ import { DiffViewer } from "../repo/DiffViewer";
8
+ import { StaticMessage } from "./ChatMessage";
9
+ import type { DiffLine, FilePatch } from "../repo/DiffViewer";
10
+ import type { Message, ToolCall, ChatStage } from "../../types/chat";
11
+
12
+ function History({ committed }: { committed: Message[] }) {
13
+ return (
14
+ <Static items={committed}>
15
+ {(msg, i) => <StaticMessage key={i} msg={msg} />}
16
+ </Static>
17
+ );
18
+ }
19
+
20
+ function Hint({ text }: { text: string }) {
21
+ return (
22
+ <Text color="gray" dimColor>
23
+ {text}
24
+ </Text>
25
+ );
26
+ }
27
+
28
+ export function PermissionPrompt({
29
+ tool,
30
+ onDecide,
31
+ }: {
32
+ tool: ToolCall;
33
+ onDecide: (approved: boolean) => void;
34
+ }) {
35
+ let icon: string;
36
+ let label: string;
37
+ let value: string;
38
+
39
+ if (tool.type === "shell") {
40
+ icon = "$";
41
+ label = "run";
42
+ value = tool.command;
43
+ } else if (tool.type === "fetch") {
44
+ icon = "~>";
45
+ label = "fetch";
46
+ value = tool.url;
47
+ } else if (tool.type === "read-file") {
48
+ icon = "r";
49
+ label = "read";
50
+ value = tool.filePath;
51
+ } else if (tool.type === "write-file") {
52
+ icon = "w";
53
+ label = "write";
54
+ value = `${tool.filePath} (${tool.fileContent.length} bytes)`;
55
+ } else {
56
+ icon = "?";
57
+ label = "search";
58
+ value = tool.query;
59
+ }
60
+
61
+ return (
62
+ <Box flexDirection="column" marginY={1}>
63
+ <Box gap={1}>
64
+ <Text color={ACCENT}>{icon}</Text>
65
+ <Text color="gray">{label}</Text>
66
+ <Text color="white">{value}</Text>
67
+ </Box>
68
+ <Box gap={1} marginLeft={2}>
69
+ <Text color="gray">y/enter allow · n/esc deny</Text>
70
+ </Box>
71
+ </Box>
72
+ );
73
+ }
74
+
75
+ export function InputBox({
76
+ value,
77
+ onChange,
78
+ onSubmit,
79
+ }: {
80
+ value: string;
81
+ onChange: (v: string) => void;
82
+ onSubmit: (v: string) => void;
83
+ }) {
84
+ return (
85
+ <Box
86
+ marginTop={1}
87
+ borderBottom
88
+ borderTop
89
+ borderRight={false}
90
+ borderLeft={false}
91
+ borderColor={"gray"}
92
+ borderStyle="single"
93
+ >
94
+ <Box gap={1}>
95
+ <Text color={ACCENT}>{">"}</Text>
96
+ <TextInput value={value} onChange={onChange} onSubmit={onSubmit} />
97
+ </Box>
98
+ </Box>
99
+ );
100
+ }
101
+
102
+ export function TypewriterText({
103
+ text,
104
+ color = ACCENT,
105
+ speed = 38,
106
+ }: {
107
+ text: string;
108
+ color?: string;
109
+ speed?: number;
110
+ }) {
111
+ const [displayed, setDisplayed] = React.useState("");
112
+ const [target, setTarget] = React.useState(text);
113
+
114
+ React.useEffect(() => {
115
+ setDisplayed("");
116
+ setTarget(text);
117
+ }, [text]);
118
+
119
+ React.useEffect(() => {
120
+ if (displayed.length >= target.length) return;
121
+ const t = setTimeout(
122
+ () => setDisplayed(target.slice(0, displayed.length + 1)),
123
+ speed,
124
+ );
125
+ return () => clearTimeout(t);
126
+ }, [displayed, target, speed]);
127
+
128
+ return <Text color={color}>{displayed}</Text>;
129
+ }
130
+
131
+ export function ShortcutBar() {
132
+ return (
133
+ <Box gap={3} marginTop={0}>
134
+ <Text color="gray" dimColor>
135
+ enter send · ^v paste · ^c exit
136
+ </Text>
137
+ </Box>
138
+ );
139
+ }
140
+
141
+ export function CloneOfferView({
142
+ stage,
143
+ committed,
144
+ }: {
145
+ stage: Extract<ChatStage, { type: "clone-offer" }>;
146
+ committed: Message[];
147
+ }) {
148
+ return (
149
+ <Box flexDirection="column">
150
+ <History committed={committed} />
151
+ <Box flexDirection="column" marginY={1}>
152
+ <Box gap={1}>
153
+ <Text color={ACCENT}>*</Text>
154
+ <Text color="white">clone </Text>
155
+ <Text color={ACCENT}>{stage.repoUrl}</Text>
156
+ <Text color="white">?</Text>
157
+ </Box>
158
+ <Hint text=" y/enter clone · n/esc skip" />
159
+ </Box>
160
+ </Box>
161
+ );
162
+ }
163
+
164
+ export function CloningView({
165
+ stage,
166
+ committed,
167
+ }: {
168
+ stage: Extract<ChatStage, { type: "cloning" }>;
169
+ committed: Message[];
170
+ }) {
171
+ return (
172
+ <Box flexDirection="column">
173
+ <History committed={committed} />
174
+ <Box gap={1} marginTop={1}>
175
+ <Text color={ACCENT}>
176
+ <Spinner />
177
+ </Text>
178
+ <Text color="gray">cloning </Text>
179
+ <Text color={ACCENT}>{stage.repoUrl}</Text>
180
+ <Text color="gray">...</Text>
181
+ </Box>
182
+ </Box>
183
+ );
184
+ }
185
+
186
+ export function CloneExistsView({
187
+ stage,
188
+ committed,
189
+ }: {
190
+ stage: Extract<ChatStage, { type: "clone-exists" }>;
191
+ committed: Message[];
192
+ }) {
193
+ return (
194
+ <Box flexDirection="column">
195
+ <History committed={committed} />
196
+ <Box flexDirection="column" marginY={1}>
197
+ <Box gap={1}>
198
+ <Text color="yellow">!</Text>
199
+ <Text color="gray">already cloned at </Text>
200
+ <Text color="white">{stage.repoPath}</Text>
201
+ </Box>
202
+ <Hint text=" y re-clone · n use existing" />
203
+ </Box>
204
+ </Box>
205
+ );
206
+ }
207
+
208
+ export function CloneDoneView({
209
+ stage,
210
+ committed,
211
+ }: {
212
+ stage: Extract<ChatStage, { type: "clone-done" }>;
213
+ committed: Message[];
214
+ }) {
215
+ const repoName = stage.repoUrl.split("/").pop() ?? "repo";
216
+ return (
217
+ <Box flexDirection="column">
218
+ <History committed={committed} />
219
+ <Box flexDirection="column" marginY={1}>
220
+ <Box gap={1}>
221
+ <Text color="green">✓</Text>
222
+ <Text color="white" bold>
223
+ {repoName}
224
+ </Text>
225
+ <Text color="gray">
226
+ cloned · {stage.fileCount} files · {stage.destPath}
227
+ </Text>
228
+ </Box>
229
+ <Hint text=" enter/esc continue" />
230
+ </Box>
231
+ </Box>
232
+ );
233
+ }
234
+
235
+ export function CloneErrorView({
236
+ stage,
237
+ committed,
238
+ }: {
239
+ stage: Extract<ChatStage, { type: "clone-error" }>;
240
+ committed: Message[];
241
+ }) {
242
+ return (
243
+ <Box flexDirection="column">
244
+ <History committed={committed} />
245
+ <Box flexDirection="column" marginY={1}>
246
+ <Box gap={1}>
247
+ <Text color="red">✗</Text>
248
+ <Text color="red">{stage.message}</Text>
249
+ </Box>
250
+ <Hint text=" enter/esc continue" />
251
+ </Box>
252
+ </Box>
253
+ );
254
+ }
255
+
256
+ export function PreviewView({
257
+ stage,
258
+ committed,
259
+ }: {
260
+ stage: Extract<ChatStage, { type: "preview" }>;
261
+ committed: Message[];
262
+ }) {
263
+ const { patches, diffLines, scrollOffset } = stage;
264
+ return (
265
+ <Box flexDirection="column">
266
+ <History committed={committed} />
267
+ <Box gap={1} marginTop={1}>
268
+ <Text color={ACCENT}>*</Text>
269
+ <Text color="white" bold>
270
+ proposed changes
271
+ </Text>
272
+ <Text color="gray">
273
+ ({patches.length} file{patches.length !== 1 ? "s" : ""})
274
+ </Text>
275
+ </Box>
276
+ <Box flexDirection="column" marginLeft={2} marginTop={1}>
277
+ {patches.map((p) => (
278
+ <Box key={p.path} gap={1}>
279
+ <Text color={p.isNew ? "green" : "yellow"}>
280
+ {p.isNew ? "+" : "~"}
281
+ </Text>
282
+ <Text color={p.isNew ? "green" : "yellow"}>{p.path}</Text>
283
+ {p.isNew && (
284
+ <Text color="gray" dimColor>
285
+ new
286
+ </Text>
287
+ )}
288
+ </Box>
289
+ ))}
290
+ </Box>
291
+ <DiffViewer
292
+ patches={patches}
293
+ diffs={diffLines}
294
+ scrollOffset={scrollOffset}
295
+ />
296
+ <Hint text="↑↓ scroll · enter/a apply · s/esc skip" />
297
+ </Box>
298
+ );
299
+ }
300
+
301
+ export function ViewingFileView({
302
+ stage,
303
+ committed,
304
+ }: {
305
+ stage: Extract<ChatStage, { type: "viewing-file" }>;
306
+ committed: Message[];
307
+ }) {
308
+ const { file, diffLines, scrollOffset } = stage;
309
+ return (
310
+ <Box flexDirection="column">
311
+ <History committed={committed} />
312
+ <Box gap={1} marginTop={1}>
313
+ <Text color={ACCENT}>r</Text>
314
+ <Text color="white" bold>
315
+ {file.path}
316
+ </Text>
317
+ <Text color="gray" dimColor>
318
+ {file.isNew ? "new" : "modified"}
319
+ </Text>
320
+ </Box>
321
+ <DiffViewer
322
+ patches={[file.patch]}
323
+ diffs={[diffLines]}
324
+ scrollOffset={scrollOffset}
325
+ />
326
+ <Hint text="↑↓ scroll · enter/esc back" />
327
+ </Box>
328
+ );
329
+ }