@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,243 @@
1
+ import { Box, Text, useInput, type Key } from "ink";
2
+ import { useState } from "react";
3
+ import { execSync } from "child_process";
4
+ import type { ProviderType } from "../../types/config";
5
+
6
+ const LABELS: Record<ProviderType, string> = {
7
+ anthropic: "Anthropic API key",
8
+ gemini: "Gemini API key",
9
+ openai: "OpenAI API key",
10
+ ollama: "Ollama base URL (default: http://localhost:11434)",
11
+ custom: "API key",
12
+ };
13
+
14
+ function readClipboard(): string | null {
15
+ try {
16
+ if (process.platform === "win32") {
17
+ return execSync("powershell -command Get-Clipboard", {
18
+ encoding: "utf-8",
19
+ }).trim();
20
+ } else if (process.platform === "darwin") {
21
+ return execSync("pbpaste", { encoding: "utf-8" }).trim();
22
+ } else {
23
+ try {
24
+ return execSync("xclip -selection clipboard -o", {
25
+ encoding: "utf-8",
26
+ }).trim();
27
+ } catch {
28
+ return execSync("xsel --clipboard --output", {
29
+ encoding: "utf-8",
30
+ }).trim();
31
+ }
32
+ }
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ type CustomResult = { apiKey: string; baseUrl?: string };
39
+ type Field = "apiKey" | "baseUrl";
40
+
41
+ const useFieldInput = (initial: string, onPasteError: (v: boolean) => void) => {
42
+ const [value, setValue] = useState(initial);
43
+
44
+ const handle = (input: string, key: Key) => {
45
+ if (key.backspace || key.delete) {
46
+ setValue((v) => v.slice(0, -1));
47
+ onPasteError(false);
48
+ return;
49
+ }
50
+ if (key.ctrl && input === "v") {
51
+ const clip = readClipboard();
52
+ if (clip) {
53
+ setValue((v) => v + clip);
54
+ onPasteError(false);
55
+ } else onPasteError(true);
56
+ return;
57
+ }
58
+ if (key.ctrl && input === "a") {
59
+ setValue("");
60
+ return;
61
+ }
62
+ if (!key.ctrl && !key.meta && input) {
63
+ setValue((v) => v + input);
64
+ onPasteError(false);
65
+ }
66
+ };
67
+
68
+ return { value, setValue, handle };
69
+ };
70
+
71
+ const SimpleInput = ({
72
+ providerType,
73
+ onSubmit,
74
+ onSkip,
75
+ }: {
76
+ providerType: Exclude<ProviderType, "custom">;
77
+ onSubmit: (value: string) => void;
78
+ onSkip?: () => void;
79
+ }) => {
80
+ const [pasteError, setPasteError] = useState(false);
81
+ const isPassword = providerType !== "ollama";
82
+ const { value, handle } = useFieldInput(
83
+ providerType === "ollama" ? "http://localhost:11434" : "",
84
+ setPasteError,
85
+ );
86
+
87
+ useInput((input, key) => {
88
+ if (key.return) {
89
+ if (value.trim()) onSubmit(value.trim());
90
+ return;
91
+ }
92
+ if (key.escape && onSkip) {
93
+ onSkip();
94
+ return;
95
+ }
96
+ handle(input, key);
97
+ });
98
+
99
+ const display = isPassword ? "•".repeat(value.length) : value;
100
+
101
+ return (
102
+ <Box flexDirection="column" gap={1}>
103
+ <Text bold color="cyan">
104
+ {LABELS[providerType]}
105
+ </Text>
106
+ <Box borderStyle="round" borderColor="gray" paddingX={1}>
107
+ <Text>{display || " "}</Text>
108
+ </Box>
109
+ {pasteError ? (
110
+ <Text color="red">⚠ Could not read clipboard</Text>
111
+ ) : (
112
+ <Text color="gray">
113
+ enter to confirm · ctrl+v to paste · ctrl+a to clear
114
+ {onSkip ? " · esc to skip" : ""}
115
+ </Text>
116
+ )}
117
+ </Box>
118
+ );
119
+ };
120
+
121
+ const CustomInput = ({
122
+ onSubmit,
123
+ onSkip,
124
+ }: {
125
+ onSubmit: (result: CustomResult) => void;
126
+ onSkip?: () => void;
127
+ }) => {
128
+ const [activeField, setActiveField] = useState<Field>("apiKey");
129
+ const [pasteError, setPasteError] = useState(false);
130
+
131
+ const apiKeyField = useFieldInput("", setPasteError);
132
+ const baseUrlField = useFieldInput("", setPasteError);
133
+
134
+ const active = activeField === "apiKey" ? apiKeyField : baseUrlField;
135
+
136
+ useInput((input, key) => {
137
+ if (key.escape && onSkip) {
138
+ onSkip();
139
+ return;
140
+ }
141
+
142
+ if (key.tab) {
143
+ setActiveField((f) => (f === "apiKey" ? "baseUrl" : "apiKey"));
144
+ setPasteError(false);
145
+ return;
146
+ }
147
+
148
+ if (key.return) {
149
+ if (activeField === "apiKey" && apiKeyField.value.trim()) {
150
+ setActiveField("baseUrl");
151
+ return;
152
+ }
153
+ if (activeField === "baseUrl" && apiKeyField.value.trim()) {
154
+ onSubmit({
155
+ apiKey: apiKeyField.value.trim(),
156
+ baseUrl: baseUrlField.value.trim() || undefined,
157
+ });
158
+ return;
159
+ }
160
+ }
161
+
162
+ active.handle(input, key);
163
+ });
164
+
165
+ const fields: {
166
+ id: Field;
167
+ label: string;
168
+ password: boolean;
169
+ placeholder: string;
170
+ }[] = [
171
+ { id: "apiKey", label: "API key", password: true, placeholder: "sk-..." },
172
+ {
173
+ id: "baseUrl",
174
+ label: "Base URL",
175
+ password: false,
176
+ placeholder: "https://api.example.com/v1",
177
+ },
178
+ ];
179
+
180
+ return (
181
+ <Box flexDirection="column" gap={1}>
182
+ <Text bold color="cyan">
183
+ Custom provider
184
+ </Text>
185
+
186
+ {fields.map(({ id, label, password, placeholder }) => {
187
+ const isActive = activeField === id;
188
+ const val = id === "apiKey" ? apiKeyField.value : baseUrlField.value;
189
+ const display = password ? "•".repeat(val.length) : val;
190
+
191
+ return (
192
+ <Box key={id} flexDirection="column" gap={0}>
193
+ <Text color={isActive ? "cyan" : "gray"}>
194
+ {isActive ? "›" : " "} {label}
195
+ {id === "baseUrl" ? " (optional)" : ""}
196
+ </Text>
197
+ <Box
198
+ borderStyle="round"
199
+ borderColor={isActive ? "cyan" : "gray"}
200
+ paddingX={1}
201
+ >
202
+ <Text color={val ? "white" : "gray"}>
203
+ {display || placeholder}
204
+ </Text>
205
+ </Box>
206
+ </Box>
207
+ );
208
+ })}
209
+
210
+ {pasteError ? (
211
+ <Text color="red">⚠ Could not read clipboard</Text>
212
+ ) : (
213
+ <Text color="gray">
214
+ enter to next field · tab to switch · ctrl+v to paste · ctrl+a to
215
+ clear
216
+ {onSkip ? " · esc to skip" : ""}
217
+ </Text>
218
+ )}
219
+ </Box>
220
+ );
221
+ };
222
+
223
+ export const ApiKeyStep = ({
224
+ providerType,
225
+ onSubmit,
226
+ onSkip,
227
+ }: {
228
+ providerType: ProviderType;
229
+ onSubmit: (value: string | CustomResult) => void;
230
+ onSkip?: () => void;
231
+ }) => {
232
+ if (providerType === "custom") {
233
+ return <CustomInput onSubmit={onSubmit} onSkip={onSkip} />;
234
+ }
235
+
236
+ return (
237
+ <SimpleInput
238
+ providerType={providerType}
239
+ onSubmit={onSubmit}
240
+ onSkip={onSkip}
241
+ />
242
+ );
243
+ };
@@ -0,0 +1,73 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import figures from "figures";
3
+ import { useState } from "react";
4
+ import { DEFAULT_MODELS } from "../../utils/config";
5
+ import type { ProviderType } from "../../types/config";
6
+
7
+ export const ModelStep = ({
8
+ providerType,
9
+ onSelect,
10
+ }: {
11
+ providerType: ProviderType;
12
+ onSelect: (model: string) => void;
13
+ }) => {
14
+ const models = DEFAULT_MODELS[providerType] ?? [];
15
+ const [index, setIndex] = useState(0);
16
+ const [custom, setCustom] = useState("");
17
+ const [typing, setTyping] = useState(models.length === 0);
18
+
19
+ useInput((input, key) => {
20
+ if (typing) {
21
+ if (key.return && custom.trim()) {
22
+ onSelect(custom.trim());
23
+ return;
24
+ }
25
+ if (key.backspace || key.delete) {
26
+ setCustom((v) => v.slice(0, -1));
27
+ return;
28
+ }
29
+ if (!key.ctrl && !key.meta && input) setCustom((v) => v + input);
30
+ return;
31
+ }
32
+ if (key.upArrow) setIndex((i) => Math.max(0, i - 1));
33
+ if (key.downArrow) setIndex((i) => Math.min(models.length, i + 1));
34
+ if (key.return) {
35
+ if (index === models.length) setTyping(true);
36
+ else onSelect(models[index]!);
37
+ }
38
+ });
39
+
40
+ return (
41
+ <Box flexDirection="column" gap={1}>
42
+ <Text bold color="cyan">
43
+ Select a model
44
+ </Text>
45
+ {models.map((m, i) => {
46
+ const selected = !typing && i === index;
47
+ return (
48
+ <Box key={m} marginLeft={1}>
49
+ <Text color={selected ? "cyan" : "white"}>
50
+ {selected ? figures.arrowRight : " "}
51
+ {" "}
52
+ {m}
53
+ </Text>
54
+ </Box>
55
+ );
56
+ })}
57
+ <Box marginLeft={1}>
58
+ <Text color={index === models.length && !typing ? "cyan" : "gray"}>
59
+ {index === models.length && !typing ? figures.arrowRight : " "}
60
+ {" "}
61
+ {typing ? (
62
+ <Text>
63
+ Custom: <Text color="white">{custom || " "}</Text>
64
+ </Text>
65
+ ) : (
66
+ "Enter custom model name"
67
+ )}
68
+ </Text>
69
+ </Box>
70
+ <Text color="gray">↑↓ navigate · enter to select</Text>
71
+ </Box>
72
+ );
73
+ };
@@ -0,0 +1,54 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import figures from "figures";
3
+ import { useState } from "react";
4
+ import type { ProviderType } from "../../types/config";
5
+
6
+ const OPTIONS: { type: ProviderType; label: string; description: string }[] = [
7
+ { type: "anthropic", label: "Anthropic", description: "Claude models" },
8
+ { type: "openai", label: "OpenAI", description: "GPT models" },
9
+ { type: "ollama", label: "Ollama", description: "Local models" },
10
+ {
11
+ type: "custom",
12
+ label: "Custom provider",
13
+ description: "Any OpenAI-compatible API",
14
+ },
15
+ ];
16
+
17
+ export const ProviderTypeStep = ({
18
+ onSelect,
19
+ }: {
20
+ onSelect: (type: ProviderType) => void;
21
+ }) => {
22
+ const [index, setIndex] = useState(0);
23
+
24
+ useInput((_, key) => {
25
+ if (key.upArrow) setIndex((i) => Math.max(0, i - 1));
26
+ if (key.downArrow) setIndex((i) => Math.min(OPTIONS.length - 1, i + 1));
27
+ if (key.return) onSelect(OPTIONS[index]!.type);
28
+ });
29
+
30
+ return (
31
+ <Box flexDirection="column" gap={1}>
32
+ <Text bold color="cyan">
33
+ Select a provider
34
+ </Text>
35
+ {OPTIONS.map((opt, i) => {
36
+ const selected = i === index;
37
+ return (
38
+ <Box key={opt.type} marginLeft={1}>
39
+ <Text color={selected ? "cyan" : "white"}>
40
+ {selected ? figures.arrowRight : " "}
41
+ {" "}
42
+ <Text bold={selected}>{opt.label}</Text>
43
+ <Text color="gray">
44
+ {" "}
45
+ {opt.description}
46
+ </Text>
47
+ </Text>
48
+ </Box>
49
+ );
50
+ })}
51
+ <Text color="gray">↑↓ navigate · enter to select</Text>
52
+ </Box>
53
+ );
54
+ };
@@ -0,0 +1,83 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import figures from "figures";
3
+ import { useState } from "react";
4
+ import { loadConfig, saveConfig } from "../../utils/config";
5
+ import type { Provider } from "../../types/config";
6
+
7
+ export const RemoveProviderStep = ({ onDone }: { onDone: () => void }) => {
8
+ const config = loadConfig();
9
+ const providers = config.providers;
10
+ const [index, setIndex] = useState(0);
11
+ const [confirming, setConfirming] = useState(false);
12
+
13
+ useInput((input, key) => {
14
+ if (confirming) {
15
+ if (input === "y" || input === "Y") {
16
+ const updated = {
17
+ ...config,
18
+ providers: providers.filter((_, i) => i !== index),
19
+ defaultProviderId:
20
+ config.defaultProviderId === providers[index]?.id
21
+ ? providers.find((_, i) => i !== index)?.id
22
+ : config.defaultProviderId,
23
+ };
24
+ saveConfig(updated);
25
+ onDone();
26
+ } else {
27
+ setConfirming(false);
28
+ }
29
+ return;
30
+ }
31
+
32
+ if (key.upArrow) setIndex((i) => Math.max(0, i - 1));
33
+ if (key.downArrow) setIndex((i) => Math.min(providers.length - 1, i + 1));
34
+ if (key.return) setConfirming(true);
35
+ if (key.escape) onDone();
36
+ });
37
+
38
+ if (providers.length === 0) {
39
+ return (
40
+ <Box marginTop={1}>
41
+ <Text color="gray">{figures.info} No providers configured.</Text>
42
+ </Box>
43
+ );
44
+ }
45
+
46
+ const selected = providers[index];
47
+
48
+ if (confirming && selected) {
49
+ return (
50
+ <Box flexDirection="column" gap={1} marginTop={1}>
51
+ <Text color="red">
52
+ {figures.warning} Remove <Text bold>{selected.name}</Text>? (y/n)
53
+ </Text>
54
+ </Box>
55
+ );
56
+ }
57
+
58
+ return (
59
+ <Box flexDirection="column" gap={1} marginTop={1}>
60
+ <Text bold color="cyan">
61
+ Remove a provider
62
+ </Text>
63
+ {providers.map((p, i) => {
64
+ const isSelected = i === index;
65
+ return (
66
+ <Box key={p.id} marginLeft={1}>
67
+ <Text color={isSelected ? "red" : "white"}>
68
+ {isSelected ? figures.arrowRight : " "}
69
+ {" "}
70
+ <Text bold={isSelected}>{p.name}</Text>
71
+ <Text color="gray">
72
+ {" "}
73
+ {p.type} · {p.model}
74
+ {config.defaultProviderId === p.id ? " · default" : ""}
75
+ </Text>
76
+ </Text>
77
+ </Box>
78
+ );
79
+ })}
80
+ <Text color="gray">↑↓ navigate · enter to remove · esc to cancel</Text>
81
+ </Box>
82
+ );
83
+ };
@@ -0,0 +1,175 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import figures from "figures";
4
+
5
+ export type DiffLine = {
6
+ type: "added" | "removed" | "unchanged";
7
+ content: string;
8
+ lineNum: number;
9
+ };
10
+
11
+ export type FilePatch = {
12
+ path: string;
13
+ content: string;
14
+ isNew: boolean;
15
+ };
16
+
17
+ export function computeDiff(
18
+ oldContent: string,
19
+ newContent: string,
20
+ ): DiffLine[] {
21
+ const oldLines = oldContent.split("\n");
22
+ const newLines = newContent.split("\n");
23
+ const result: DiffLine[] = [];
24
+
25
+ const m = oldLines.length;
26
+ const n = newLines.length;
27
+
28
+ const dp: number[][] = Array.from({ length: m + 1 }, () =>
29
+ new Array(n + 1).fill(0),
30
+ );
31
+ for (let i = 1; i <= m; i++) {
32
+ for (let j = 1; j <= n; j++) {
33
+ if (oldLines[i - 1] === newLines[j - 1]) {
34
+ dp[i]![j] = dp[i - 1]![j - 1]! + 1;
35
+ } else {
36
+ dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!);
37
+ }
38
+ }
39
+ }
40
+
41
+ const raw: DiffLine[] = [];
42
+ let i = m;
43
+ let j = n;
44
+
45
+ while (i > 0 || j > 0) {
46
+ if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
47
+ raw.unshift({ type: "unchanged", content: oldLines[i - 1]!, lineNum: j });
48
+ i--;
49
+ j--;
50
+ } else if (j > 0 && (i === 0 || dp[i]![j - 1]! >= dp[i - 1]![j]!)) {
51
+ raw.unshift({ type: "added", content: newLines[j - 1]!, lineNum: j });
52
+ j--;
53
+ } else {
54
+ raw.unshift({ type: "removed", content: oldLines[i - 1]!, lineNum: i });
55
+ i--;
56
+ }
57
+ }
58
+
59
+ const CONTEXT = 3;
60
+ const changedIndices = new Set<number>();
61
+ raw.forEach((line, idx) => {
62
+ if (line.type !== "unchanged") {
63
+ for (
64
+ let k = Math.max(0, idx - CONTEXT);
65
+ k <= Math.min(raw.length - 1, idx + CONTEXT);
66
+ k++
67
+ ) {
68
+ changedIndices.add(k);
69
+ }
70
+ }
71
+ });
72
+
73
+ let lastIncluded = -1;
74
+ raw.forEach((line, idx) => {
75
+ if (!changedIndices.has(idx)) return;
76
+ if (lastIncluded !== -1 && idx > lastIncluded + 1) {
77
+ result.push({ type: "unchanged", content: "...", lineNum: -1 });
78
+ }
79
+ result.push(line);
80
+ lastIncluded = idx;
81
+ });
82
+
83
+ return result;
84
+ }
85
+
86
+ export function buildDiffs(
87
+ repoPath: string,
88
+ patches: FilePatch[],
89
+ ): DiffLine[][] {
90
+ const { readFileSync } = require("fs") as typeof import("fs");
91
+ const path = require("path") as typeof import("path");
92
+
93
+ return patches.map((patch) => {
94
+ if (patch.isNew) {
95
+ return patch.content.split("\n").map((line, i) => ({
96
+ type: "added" as const,
97
+ content: line,
98
+ lineNum: i + 1,
99
+ }));
100
+ }
101
+ const fullPath = path.join(repoPath, patch.path);
102
+ let oldContent = "";
103
+ try {
104
+ oldContent = readFileSync(fullPath, "utf-8");
105
+ } catch {}
106
+ return computeDiff(oldContent, patch.content);
107
+ });
108
+ }
109
+
110
+ export const DiffViewer = ({
111
+ patches,
112
+ diffs,
113
+ scrollOffset,
114
+ maxVisible = 20,
115
+ }: {
116
+ patches: FilePatch[];
117
+ diffs: DiffLine[][];
118
+ scrollOffset: number;
119
+ maxVisible?: number;
120
+ }) => {
121
+ const allLines: {
122
+ fileIdx: number;
123
+ fileName: string;
124
+ line: DiffLine | null;
125
+ }[] = [];
126
+
127
+ patches.forEach((patch, fi) => {
128
+ allLines.push({ fileIdx: fi, fileName: patch.path, line: null });
129
+ (diffs[fi] ?? []).forEach((line) => {
130
+ allLines.push({ fileIdx: fi, fileName: patch.path, line });
131
+ });
132
+ });
133
+
134
+ const visible = allLines.slice(scrollOffset, scrollOffset + maxVisible);
135
+
136
+ return (
137
+ <Box flexDirection="column" gap={0}>
138
+ {visible.map((entry, i) => {
139
+ if (!entry.line) {
140
+ return (
141
+ <Box key={`header-${entry.fileIdx}-${i}`}>
142
+ <Text bold color={entry.fileIdx % 2 === 0 ? "cyan" : "magenta"}>
143
+ {figures.bullet} {entry.fileName}
144
+ {patches[entry.fileIdx]?.isNew ? " (new file)" : ""}
145
+ </Text>
146
+ </Box>
147
+ );
148
+ }
149
+
150
+ const { type, content, lineNum } = entry.line;
151
+ const prefix = type === "added" ? "+" : type === "removed" ? "-" : " ";
152
+ const color =
153
+ type === "added" ? "green" : type === "removed" ? "red" : "gray";
154
+ const lineNumStr =
155
+ lineNum === -1 ? " " : String(lineNum).padStart(3, " ");
156
+
157
+ return (
158
+ <Box key={`line-${entry.fileIdx}-${i}`}>
159
+ <Text color="gray">{lineNumStr} </Text>
160
+ <Text color={color}>
161
+ {prefix} {content}
162
+ </Text>
163
+ </Box>
164
+ );
165
+ })}
166
+ {allLines.length > maxVisible && (
167
+ <Text color="gray">
168
+ {scrollOffset + maxVisible < allLines.length
169
+ ? `↓ ${allLines.length - scrollOffset - maxVisible} more lines`
170
+ : "end of diff"}
171
+ </Text>
172
+ )}
173
+ </Box>
174
+ );
175
+ };
@@ -0,0 +1,70 @@
1
+ import React from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import figures from "figures";
4
+ import { useState } from "react";
5
+ import { iconForFile } from "../../utils/files";
6
+ import { FileViewer } from "./FileViewer";
7
+ import type { ImportantFile, ReviewStage } from "../../types/repo";
8
+
9
+ export const FileReviewer = ({
10
+ files,
11
+ onDone,
12
+ }: {
13
+ files: ImportantFile[];
14
+ onDone: () => void;
15
+ }) => {
16
+ const [selectedIndex, setSelectedIndex] = useState(0);
17
+ const [reviewStage, setReviewStage] = useState<ReviewStage>("list");
18
+
19
+ useInput((_, key) => {
20
+ if (reviewStage === "file") return;
21
+
22
+ if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
23
+ if (key.downArrow)
24
+ setSelectedIndex((i) => Math.min(files.length - 1, i + 1));
25
+ if (key.return || key.rightArrow) setReviewStage("file");
26
+ if (key.escape) onDone();
27
+ if (key.rightArrow) onDone();
28
+ });
29
+
30
+ if (reviewStage === "file") {
31
+ const file = files[selectedIndex];
32
+ if (!file) return null;
33
+ return (
34
+ <FileViewer
35
+ file={file}
36
+ index={selectedIndex}
37
+ total={files.length}
38
+ onBack={() => setReviewStage("list")}
39
+ />
40
+ );
41
+ }
42
+
43
+ return (
44
+ <Box flexDirection="column" marginTop={1} gap={1}>
45
+ <Text bold color="cyan">
46
+ {figures.hamburger} {files.length} important file(s) found
47
+ <Text color="gray">
48
+ {" "}↑↓ navigate · enter to open · → next step · esc to skip
49
+ </Text>
50
+ </Text>
51
+
52
+ <Box flexDirection="column">
53
+ {files.map((file, i) => {
54
+ const isSelected = i === selectedIndex;
55
+ return (
56
+ <Box key={file.path} marginLeft={1}>
57
+ <Text color={isSelected ? "cyan" : "yellow"}>
58
+ {isSelected ? figures.arrowRight : " "}
59
+ {" "}
60
+ {iconForFile(file.path)}
61
+ {" "}
62
+ {file.path}
63
+ </Text>
64
+ </Box>
65
+ );
66
+ })}
67
+ </Box>
68
+ </Box>
69
+ );
70
+ };