@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,396 @@
1
+ import React from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import figures from "figures";
5
+ import { useState } from "react";
6
+ import { writeFileSync, existsSync, mkdirSync } from "fs";
7
+ import path from "path";
8
+ import { ACCENT } from "../../colors";
9
+ import { callModelRaw } from "../../utils/ai";
10
+ import { DiffViewer, buildDiffs } from "../repo/DiffViewer";
11
+ import { ProviderPicker } from "../repo/ProviderPicker";
12
+ import type { DiffLine, FilePatch } from "../repo/DiffViewer";
13
+ import type { Provider } from "../../types/config";
14
+ import type { ImportantFile } from "../../types/repo";
15
+ import { fetchFileTree, readImportantFiles } from "../../utils/files";
16
+ import { readFileSync, readdirSync, statSync } from "fs";
17
+ import { useThinkingPhrase } from "../../utils/thinking";
18
+
19
+ type Stage =
20
+ | { type: "picking-provider" }
21
+ | { type: "reading-files" }
22
+ | { type: "thinking" }
23
+ | {
24
+ type: "preview";
25
+ plan: PromptPlan;
26
+ diffLines: DiffLine[][];
27
+ scrollOffset: number;
28
+ }
29
+ | { type: "applying" }
30
+ | { type: "done"; applied: AppliedFile[] }
31
+ | {
32
+ type: "viewing-file";
33
+ file: AppliedFile;
34
+ diffLines: DiffLine[];
35
+ scrollOffset: number;
36
+ }
37
+ | { type: "error"; message: string };
38
+
39
+ type PromptPlan = {
40
+ summary: string;
41
+ patches: FilePatch[];
42
+ };
43
+
44
+ type AppliedFile = {
45
+ path: string;
46
+ isNew: boolean;
47
+ patch: FilePatch;
48
+ };
49
+
50
+ function buildPrompt(userPrompt: string, files: ImportantFile[]): string {
51
+ const fileList = files
52
+ .map((f) => `### ${f.path}\n\`\`\`\n${f.content.slice(0, 3000)}\n\`\`\``)
53
+ .join("\n\n");
54
+
55
+ return `You are a senior software engineer working on a codebase. The user has made the following request:
56
+
57
+ "${userPrompt}"
58
+
59
+ Here are the relevant files in the codebase:
60
+
61
+ ${fileList}
62
+
63
+ Fulfill the user's request by providing the complete new content for any files that need to be created or modified.
64
+
65
+ Respond ONLY with a JSON object (no markdown, no explanation) with this exact shape:
66
+ {
67
+ "summary": "2-3 sentence explanation of what you did and why",
68
+ "patches": [
69
+ {
70
+ "path": "relative/path/to/file.ts",
71
+ "content": "complete new file content here",
72
+ "isNew": false
73
+ }
74
+ ]
75
+ }
76
+
77
+ Rules:
78
+ - Always provide the COMPLETE file content, not diffs or partial content
79
+ - isNew should be true only if you are creating a brand new file
80
+ - Only include files that actually need changes
81
+ - Keep changes focused on fulfilling the request
82
+ - Do not change unrelated code
83
+ - If the request is impossible or unclear, return an empty patches array with an explanation in summary`;
84
+ }
85
+
86
+ function ThinkingAboutStep({ prompt }: { prompt: string }) {
87
+ const phrase = useThinkingPhrase(true, "task");
88
+ return (
89
+ <Box gap={1}>
90
+ <Text color={ACCENT}>
91
+ <Spinner />
92
+ </Text>
93
+ <Text color={ACCENT}>{phrase}</Text>
94
+ <Text color="gray">"{prompt}"</Text>
95
+ </Box>
96
+ );
97
+ }
98
+
99
+ function applyPatches(repoPath: string, plan: PromptPlan): AppliedFile[] {
100
+ const applied: AppliedFile[] = [];
101
+ for (const patch of plan.patches) {
102
+ const fullPath = path.join(repoPath, patch.path);
103
+ const dir = path.dirname(fullPath);
104
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
105
+ writeFileSync(fullPath, patch.content, "utf-8");
106
+ applied.push({ path: patch.path, isNew: patch.isNew, patch });
107
+ }
108
+ return applied;
109
+ }
110
+
111
+ const SKIP_DIRS = new Set([
112
+ "node_modules",
113
+ ".git",
114
+ "dist",
115
+ "build",
116
+ ".next",
117
+ "out",
118
+ "coverage",
119
+ "__pycache__",
120
+ ".venv",
121
+ "venv",
122
+ ]);
123
+
124
+ function walkDir(dir: string, base = dir): string[] {
125
+ const results: string[] = [];
126
+ let entries: string[];
127
+ try {
128
+ entries = readdirSync(dir, { encoding: "utf-8" });
129
+ } catch {
130
+ return results;
131
+ }
132
+ for (const entry of entries) {
133
+ if (SKIP_DIRS.has(entry)) continue;
134
+ const full = path.join(dir, entry);
135
+ const rel = path.relative(base, full).replace(/\\/g, "/");
136
+ let isDir = false;
137
+ try {
138
+ isDir = statSync(full).isDirectory();
139
+ } catch {
140
+ continue;
141
+ }
142
+ if (isDir) results.push(...walkDir(full, base));
143
+ else results.push(rel);
144
+ }
145
+ return results;
146
+ }
147
+
148
+ export const PromptRunner = ({
149
+ repoPath,
150
+ userPrompt,
151
+ }: {
152
+ repoPath: string;
153
+ userPrompt: string;
154
+ }) => {
155
+ const [stage, setStage] = useState<Stage>({ type: "picking-provider" });
156
+ const [selectedIndex, setSelectedIndex] = useState(0);
157
+ const [files, setFiles] = useState<ImportantFile[]>([]);
158
+
159
+ const handleProviderDone = (provider: Provider) => {
160
+ setStage({ type: "reading-files" });
161
+
162
+ fetchFileTree(repoPath)
163
+ .catch(() => walkDir(repoPath))
164
+ .then((fileTree) => {
165
+ const importantFiles = readImportantFiles(repoPath, fileTree);
166
+ setFiles(importantFiles);
167
+ setStage({ type: "thinking" });
168
+ return callModelRaw(provider, buildPrompt(userPrompt, importantFiles));
169
+ })
170
+ .then((text) => {
171
+ const cleaned = text.replace(/```json|```/g, "").trim();
172
+ const match = cleaned.match(/\{[\s\S]*\}/);
173
+ if (!match) throw new Error("No JSON in model response");
174
+ const plan = JSON.parse(match[0]) as PromptPlan;
175
+
176
+ if (plan.patches.length === 0) {
177
+ setStage({
178
+ type: "error",
179
+ message: plan.summary || "Model made no changes.",
180
+ });
181
+ return;
182
+ }
183
+
184
+ const diffLines = buildDiffs(repoPath, plan.patches);
185
+ setStage({ type: "preview", plan, diffLines, scrollOffset: 0 });
186
+ })
187
+ .catch((err: unknown) =>
188
+ setStage({
189
+ type: "error",
190
+ message: err instanceof Error ? err.message : "Something went wrong",
191
+ }),
192
+ );
193
+ };
194
+
195
+ useInput((_, key) => {
196
+ if (stage.type === "preview") {
197
+ if (key.upArrow)
198
+ setStage({
199
+ ...stage,
200
+ scrollOffset: Math.max(0, stage.scrollOffset - 1),
201
+ });
202
+ if (key.downArrow)
203
+ setStage({ ...stage, scrollOffset: stage.scrollOffset + 1 });
204
+ if (key.escape) {
205
+ process.exit(0);
206
+ return;
207
+ }
208
+ if (key.return) {
209
+ setStage({ type: "applying" });
210
+ try {
211
+ const applied = applyPatches(repoPath, stage.plan);
212
+ setStage({ type: "done", applied });
213
+ } catch (err: unknown) {
214
+ setStage({
215
+ type: "error",
216
+ message:
217
+ err instanceof Error ? err.message : "Failed to write files",
218
+ });
219
+ }
220
+ }
221
+ return;
222
+ }
223
+
224
+ if (stage.type === "done") {
225
+ if (key.escape) {
226
+ process.exit(0);
227
+ return;
228
+ }
229
+ if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
230
+ if (key.downArrow)
231
+ setSelectedIndex((i) => Math.min(stage.applied.length - 1, i + 1));
232
+ if (key.return) {
233
+ const file = stage.applied[selectedIndex];
234
+ if (!file) return;
235
+ const diffLines = buildDiffs(repoPath, [file.patch])[0] ?? [];
236
+ setStage({ type: "viewing-file", file, diffLines, scrollOffset: 0 });
237
+ }
238
+ return;
239
+ }
240
+
241
+ if (stage.type === "viewing-file") {
242
+ if (key.upArrow)
243
+ setStage({
244
+ ...stage,
245
+ scrollOffset: Math.max(0, stage.scrollOffset - 1),
246
+ });
247
+ if (key.downArrow)
248
+ setStage({ ...stage, scrollOffset: stage.scrollOffset + 1 });
249
+ if (key.escape || key.return) {
250
+ setStage((prev) => {
251
+ if (prev.type !== "viewing-file") return prev;
252
+
253
+ process.exit(0);
254
+ return prev;
255
+ });
256
+ }
257
+ return;
258
+ }
259
+
260
+ if (stage.type === "error") {
261
+ if (key.return || key.escape) process.exit(1);
262
+ }
263
+ });
264
+
265
+ if (stage.type === "picking-provider") {
266
+ return <ProviderPicker onDone={handleProviderDone} />;
267
+ }
268
+
269
+ if (stage.type === "reading-files") {
270
+ return (
271
+ <Box marginTop={1} gap={1}>
272
+ <Text color={ACCENT}>
273
+ <Spinner />
274
+ </Text>
275
+ <Text>Reading codebase...</Text>
276
+ </Box>
277
+ );
278
+ }
279
+
280
+ if (stage.type === "thinking") {
281
+ return (
282
+ <Box flexDirection="column" marginTop={1} gap={1}>
283
+ <ThinkingAboutStep prompt={userPrompt} />
284
+ {files.length > 0 && (
285
+ <Box flexDirection="column" marginLeft={2}>
286
+ <Text color="gray">Using {files.length} files as context</Text>
287
+ </Box>
288
+ )}
289
+ </Box>
290
+ );
291
+ }
292
+
293
+ if (stage.type === "preview") {
294
+ const { plan, diffLines, scrollOffset } = stage;
295
+ return (
296
+ <Box flexDirection="column" marginTop={1} gap={1}>
297
+ <Text bold color="cyan">
298
+ {figures.info} Proposed Changes
299
+ </Text>
300
+ <Text color="white">{plan.summary}</Text>
301
+ <Box flexDirection="column" marginTop={1}>
302
+ <Text color="gray">{plan.patches.length} file(s) to change:</Text>
303
+ {plan.patches.map((p) => (
304
+ <Text key={p.path} color={p.isNew ? "green" : "yellow"}>
305
+ {" "}
306
+ {p.isNew ? figures.tick : figures.bullet} {p.path}
307
+ {p.isNew && <Text color="gray"> (new)</Text>}
308
+ </Text>
309
+ ))}
310
+ </Box>
311
+ <DiffViewer
312
+ patches={plan.patches}
313
+ diffs={diffLines}
314
+ scrollOffset={scrollOffset}
315
+ />
316
+ <Text color="gray">↑↓ scroll · enter to apply · esc to cancel</Text>
317
+ </Box>
318
+ );
319
+ }
320
+
321
+ if (stage.type === "applying") {
322
+ return (
323
+ <Box marginTop={1} gap={1}>
324
+ <Text color={ACCENT}>
325
+ <Spinner />
326
+ </Text>
327
+ <Text>Applying changes...</Text>
328
+ </Box>
329
+ );
330
+ }
331
+
332
+ if (stage.type === "done") {
333
+ return (
334
+ <Box flexDirection="column" marginTop={1} gap={1}>
335
+ <Text bold color="green">
336
+ {figures.tick} Done
337
+ </Text>
338
+ <Text color="gray">{stage.applied.length} file(s) written</Text>
339
+ {stage.applied.map((f, i) => {
340
+ const isSelected = i === selectedIndex;
341
+ return (
342
+ <Box key={f.path} marginLeft={1}>
343
+ <Text color={isSelected ? "cyan" : "green"}>
344
+ {isSelected
345
+ ? figures.arrowRight
346
+ : f.isNew
347
+ ? figures.tick
348
+ : figures.bullet}{" "}
349
+ {f.path}
350
+ {f.isNew && <Text color="gray"> (new)</Text>}
351
+ {isSelected && <Text color="gray"> · enter to view diff</Text>}
352
+ </Text>
353
+ </Box>
354
+ );
355
+ })}
356
+ <Text color="gray">↑↓ navigate · enter to view diff · esc to exit</Text>
357
+ </Box>
358
+ );
359
+ }
360
+
361
+ if (stage.type === "viewing-file") {
362
+ const { file, diffLines, scrollOffset } = stage;
363
+ return (
364
+ <Box flexDirection="column" marginTop={1} gap={1}>
365
+ <Box gap={1}>
366
+ <Text bold color="cyan">
367
+ {figures.info}
368
+ </Text>
369
+ <Text bold color="cyan">
370
+ {file.path}
371
+ </Text>
372
+ <Text color="gray">{file.isNew ? "(new file)" : "(modified)"}</Text>
373
+ </Box>
374
+ <DiffViewer
375
+ patches={[file.patch]}
376
+ diffs={[diffLines]}
377
+ scrollOffset={scrollOffset}
378
+ />
379
+ <Text color="gray">↑↓ scroll · esc or enter to exit</Text>
380
+ </Box>
381
+ );
382
+ }
383
+
384
+ if (stage.type === "error") {
385
+ return (
386
+ <Box flexDirection="column" marginTop={1} gap={1}>
387
+ <Text color="red">
388
+ {figures.cross} {stage.message}
389
+ </Text>
390
+ <Text color="gray">enter or esc to exit</Text>
391
+ </Box>
392
+ );
393
+ }
394
+
395
+ return null;
396
+ };
@@ -0,0 +1,274 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import type { Commit, DiffFile } from "../../utils/git";
4
+
5
+ const ACCENT = "#FF8C00";
6
+
7
+ type Props = {
8
+ commit: Commit | null;
9
+ diff: DiffFile[];
10
+ diffLoading: boolean;
11
+ diffScrollOffset: number;
12
+ showFullDiff: boolean;
13
+ width: number;
14
+ height: number;
15
+ };
16
+
17
+ function formatFullDate(dateStr: string): string {
18
+ try {
19
+ const d = new Date(dateStr);
20
+ return d.toLocaleDateString("en-US", {
21
+ weekday: "short",
22
+ year: "numeric",
23
+ month: "short",
24
+ day: "numeric",
25
+ hour: "2-digit",
26
+ minute: "2-digit",
27
+ });
28
+ } catch {
29
+ return dateStr;
30
+ }
31
+ }
32
+
33
+ function statusIcon(status: DiffFile["status"]): {
34
+ icon: string;
35
+ color: string;
36
+ } {
37
+ switch (status) {
38
+ case "added":
39
+ return { icon: "+", color: "green" };
40
+ case "deleted":
41
+ return { icon: "-", color: "red" };
42
+ case "renamed":
43
+ return { icon: "→", color: "yellow" };
44
+ default:
45
+ return { icon: "~", color: "cyan" };
46
+ }
47
+ }
48
+
49
+ export function CommitDetail({
50
+ commit,
51
+ diff,
52
+ diffLoading,
53
+ diffScrollOffset,
54
+ showFullDiff,
55
+ width,
56
+ height,
57
+ }: Props) {
58
+ if (!commit) {
59
+ return (
60
+ <Box
61
+ width={width}
62
+ height={height}
63
+ flexDirection="column"
64
+ alignItems="center"
65
+ justifyContent="center"
66
+ >
67
+ <Text color="gray" dimColor>
68
+ select a commit to view details
69
+ </Text>
70
+ </Box>
71
+ );
72
+ }
73
+
74
+ const divider = "─".repeat(Math.max(0, width - 2));
75
+
76
+ // Build all diff lines for scrolling
77
+ const allDiffLines: Array<{
78
+ type: string;
79
+ content: string;
80
+ fileHeader?: string;
81
+ }> = [];
82
+ for (const file of diff) {
83
+ const { icon, color } = statusIcon(file.status);
84
+ allDiffLines.push({
85
+ type: "fileheader",
86
+ content: `${icon} ${file.path}`,
87
+ fileHeader: color,
88
+ });
89
+ allDiffLines.push({
90
+ type: "filestat",
91
+ content: ` +${file.insertions} -${file.deletions}`,
92
+ });
93
+ if (showFullDiff) {
94
+ for (const line of file.lines) {
95
+ allDiffLines.push({ type: line.type, content: line.content });
96
+ }
97
+ }
98
+ }
99
+
100
+ const visibleDiffLines = allDiffLines.slice(
101
+ diffScrollOffset,
102
+ diffScrollOffset + Math.max(1, height - 18),
103
+ );
104
+
105
+ return (
106
+ <Box width={width} flexDirection="column">
107
+ {/* ── Commit header ── */}
108
+ <Box paddingX={1} marginBottom={1}>
109
+ <Text color="gray" dimColor>
110
+ {divider}
111
+ </Text>
112
+ </Box>
113
+
114
+ <Box paddingX={1} gap={2}>
115
+ <Text color={ACCENT} bold>
116
+ ◉ {commit.shortHash}
117
+ </Text>
118
+ {commit.parents.length > 1 && <Text color="magenta">merge commit</Text>}
119
+ {commit.refs && (
120
+ <Text color="yellow">
121
+ {commit.refs
122
+ .split(",")
123
+ .map((r) => r.trim())
124
+ .filter(Boolean)
125
+ .slice(0, 2)
126
+ .join(" ")}
127
+ </Text>
128
+ )}
129
+ </Box>
130
+
131
+ {/* message */}
132
+ <Box paddingX={1} marginTop={1}>
133
+ <Text color="white" bold wrap="wrap">
134
+ {commit.message}
135
+ </Text>
136
+ </Box>
137
+ {commit.body && (
138
+ <Box paddingX={1} marginTop={1}>
139
+ <Text color="gray" wrap="wrap">
140
+ {commit.body}
141
+ </Text>
142
+ </Box>
143
+ )}
144
+
145
+ {/* meta */}
146
+ <Box paddingX={1} marginTop={1} flexDirection="column" gap={0}>
147
+ <Box gap={2}>
148
+ <Text color="gray" dimColor>
149
+ author
150
+ </Text>
151
+ <Text color="cyan">{commit.author}</Text>
152
+ <Text color="gray" dimColor>
153
+ &lt;{commit.email}&gt;
154
+ </Text>
155
+ </Box>
156
+ <Box gap={2}>
157
+ <Text color="gray" dimColor>
158
+ date{" "}
159
+ </Text>
160
+ <Text color="white">{formatFullDate(commit.date)}</Text>
161
+ <Text color="gray" dimColor>
162
+ ({commit.relativeDate})
163
+ </Text>
164
+ </Box>
165
+ {commit.parents.length > 0 && (
166
+ <Box gap={2}>
167
+ <Text color="gray" dimColor>
168
+ parent
169
+ </Text>
170
+ <Text color="gray">
171
+ {commit.parents.map((p) => p.slice(0, 7)).join(", ")}
172
+ </Text>
173
+ </Box>
174
+ )}
175
+ </Box>
176
+
177
+ {/* stats bar */}
178
+ <Box paddingX={1} marginTop={1} gap={3}>
179
+ <Text color="green">+{commit.insertions} insertions</Text>
180
+ <Text color="red">-{commit.deletions} deletions</Text>
181
+ <Text color="gray" dimColor>
182
+ {commit.filesChanged} file{commit.filesChanged !== 1 ? "s" : ""}{" "}
183
+ changed
184
+ </Text>
185
+ </Box>
186
+
187
+ {/* ── Diff section ── */}
188
+ <Box paddingX={1} marginTop={1}>
189
+ <Text color="gray" dimColor>
190
+ {divider}
191
+ </Text>
192
+ </Box>
193
+
194
+ <Box paddingX={1} marginBottom={1} gap={2}>
195
+ <Text color={ACCENT}>CHANGES</Text>
196
+ <Text color="gray" dimColor>
197
+ {showFullDiff ? "[d] collapse diff" : "[d] expand diff"}
198
+ </Text>
199
+ {diffLoading && (
200
+ <Text color="gray" dimColor>
201
+ loading…
202
+ </Text>
203
+ )}
204
+ </Box>
205
+
206
+ {/* diff lines */}
207
+ {visibleDiffLines.map((line, i) => {
208
+ if (line.type === "fileheader") {
209
+ return (
210
+ <Box key={i} paddingX={1}>
211
+ <Text color={line.fileHeader ?? "white"} bold>
212
+ {line.content}
213
+ </Text>
214
+ </Box>
215
+ );
216
+ }
217
+ if (line.type === "filestat") {
218
+ return (
219
+ <Box key={i} paddingX={1}>
220
+ <Text color="gray" dimColor>
221
+ {line.content}
222
+ </Text>
223
+ </Box>
224
+ );
225
+ }
226
+ if (line.type === "header") {
227
+ return (
228
+ <Box key={i} paddingX={1}>
229
+ <Text color="cyan" dimColor>
230
+ {line.content.slice(0, width - 4)}
231
+ </Text>
232
+ </Box>
233
+ );
234
+ }
235
+ if (line.type === "add") {
236
+ return (
237
+ <Box key={i} paddingX={1}>
238
+ <Text color="green">
239
+ {"+"}
240
+ {line.content.slice(0, width - 5)}
241
+ </Text>
242
+ </Box>
243
+ );
244
+ }
245
+ if (line.type === "remove") {
246
+ return (
247
+ <Box key={i} paddingX={1}>
248
+ <Text color="red">
249
+ {"-"}
250
+ {line.content.slice(0, width - 5)}
251
+ </Text>
252
+ </Box>
253
+ );
254
+ }
255
+ return (
256
+ <Box key={i} paddingX={1}>
257
+ <Text color="gray" dimColor>
258
+ {" "}
259
+ {line.content.slice(0, width - 5)}
260
+ </Text>
261
+ </Box>
262
+ );
263
+ })}
264
+
265
+ {allDiffLines.length > visibleDiffLines.length + diffScrollOffset && (
266
+ <Box paddingX={1} marginTop={1}>
267
+ <Text color="gray" dimColor>
268
+ ↓ scroll diff with shift+↑↓
269
+ </Text>
270
+ </Box>
271
+ )}
272
+ </Box>
273
+ );
274
+ }