@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,666 @@
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 "./DiffViewer";
11
+ import type { DiffLine, FilePatch } from "./DiffViewer";
12
+ import type { Provider } from "../../types/config";
13
+ import type { AnalysisResult, ImportantFile } from "../../types/repo";
14
+
15
+ type FixStage =
16
+ | { type: "picking-issue" }
17
+ | {
18
+ type: "fixing";
19
+ issue: string;
20
+ progress?: { current: number; total: number };
21
+ }
22
+ | {
23
+ type: "preview";
24
+ issue: string;
25
+ plan: FixPlan;
26
+ diffLines: DiffLine[][];
27
+ isFixAll: boolean;
28
+ remainingIssues: FixableIssue[];
29
+ scrollOffset: number;
30
+ }
31
+ | { type: "applying"; issue: string; plan: FixPlan }
32
+ | { type: "done"; applied: AppliedFix[]; remainingIssues: FixableIssue[] }
33
+ | {
34
+ type: "fix-all-summary";
35
+ allApplied: AppliedFix[];
36
+ failed: string[];
37
+ selectedFile: number;
38
+ scrollOffset: number;
39
+ }
40
+ | {
41
+ type: "viewing-file";
42
+ file: AppliedFix;
43
+ diffLines: DiffLine[];
44
+ scrollOffset: number;
45
+ returnTo: "done" | "fix-all-summary";
46
+ doneState?: { applied: AppliedFix[]; remainingIssues: FixableIssue[] };
47
+ summaryState?: { allApplied: AppliedFix[]; failed: string[] };
48
+ }
49
+ | { type: "error"; message: string };
50
+
51
+ type FixPlan = {
52
+ summary: string;
53
+ patches: FilePatch[];
54
+ };
55
+
56
+ type AppliedFix = {
57
+ path: string;
58
+ isNew: boolean;
59
+ issueLabel: string;
60
+ patch: FilePatch;
61
+ };
62
+
63
+ type FixableIssue = {
64
+ label: string;
65
+ category: "security" | "config" | "suggestion";
66
+ };
67
+
68
+ function buildFixPrompt(
69
+ repoPath: string,
70
+ issue: string,
71
+ requestedFiles: ImportantFile[],
72
+ ): string {
73
+ const fileList = requestedFiles
74
+ .map((f) => `### ${f.path}\n\`\`\`\n${f.content.slice(0, 3000)}\n\`\`\``)
75
+ .join("\n\n");
76
+
77
+ return `You are a senior software engineer. You need to fix the following issue in a codebase:
78
+
79
+ Issue: ${issue}
80
+
81
+ Here are the relevant files:
82
+
83
+ ${fileList}
84
+
85
+ Fix this issue by providing the complete new content for any files that need to be created or modified.
86
+
87
+ Respond ONLY with a JSON object (no markdown, no explanation) with this exact shape:
88
+ {
89
+ "summary": "1-2 sentence explanation of what you changed and why",
90
+ "patches": [
91
+ {
92
+ "path": "relative/path/to/file.ts",
93
+ "content": "complete new file content here",
94
+ "isNew": false
95
+ }
96
+ ]
97
+ }
98
+
99
+ Rules:
100
+ - Always provide the COMPLETE file content, not diffs or partial content
101
+ - isNew should be true only if you are creating a brand new file
102
+ - Only include files that actually need changes
103
+ - Keep changes minimal and focused on the issue
104
+ - Do not change unrelated code`;
105
+ }
106
+
107
+ function applyPatches(
108
+ repoPath: string,
109
+ plan: FixPlan,
110
+ issueLabel: string,
111
+ ): AppliedFix[] {
112
+ const applied: AppliedFix[] = [];
113
+ for (const patch of plan.patches) {
114
+ const fullPath = path.join(repoPath, patch.path);
115
+ const dir = path.dirname(fullPath);
116
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
117
+ writeFileSync(fullPath, patch.content, "utf-8");
118
+ applied.push({ path: patch.path, isNew: patch.isNew, issueLabel, patch });
119
+ }
120
+ return applied;
121
+ }
122
+
123
+ async function runFixAll(
124
+ repoPath: string,
125
+ issues: FixableIssue[],
126
+ requestedFiles: ImportantFile[],
127
+ provider: Provider,
128
+ onProgress: (current: number, total: number, issue: string) => void,
129
+ ): Promise<{ allApplied: AppliedFix[]; failed: string[] }> {
130
+ const allApplied: AppliedFix[] = [];
131
+ const failed: string[] = [];
132
+
133
+ for (let idx = 0; idx < issues.length; idx++) {
134
+ const issue = issues[idx]!;
135
+ onProgress(idx + 1, issues.length, issue.label);
136
+ try {
137
+ const text = await callModelRaw(
138
+ provider,
139
+ buildFixPrompt(repoPath, issue.label, requestedFiles),
140
+ );
141
+ const cleaned = text.replace(/```json|```/g, "").trim();
142
+ const match = cleaned.match(/\{[\s\S]*\}/);
143
+ if (!match) throw new Error("No JSON in response");
144
+ const plan = JSON.parse(match[0]) as FixPlan;
145
+ const applied = applyPatches(repoPath, plan, issue.label);
146
+ allApplied.push(...applied);
147
+ } catch {
148
+ failed.push(issue.label);
149
+ }
150
+ }
151
+
152
+ return { allApplied, failed };
153
+ }
154
+
155
+ export const IssueFixer = ({
156
+ repoPath,
157
+ result,
158
+ requestedFiles,
159
+ provider,
160
+ onDone,
161
+ }: {
162
+ repoPath: string;
163
+ result: AnalysisResult;
164
+ requestedFiles: ImportantFile[];
165
+ provider: Provider;
166
+ onDone: () => void;
167
+ }) => {
168
+ const [stage, setStage] = useState<FixStage>({ type: "picking-issue" });
169
+ const [selectedIndex, setSelectedIndex] = useState(0);
170
+ const [fixedLabels, setFixedLabels] = useState<Set<string>>(new Set());
171
+
172
+ const allIssues: FixableIssue[] = [
173
+ ...result.securityIssues.map((s) => ({
174
+ label: s,
175
+ category: "security" as const,
176
+ })),
177
+ ...result.missingConfigs.map((s) => ({
178
+ label: s,
179
+ category: "config" as const,
180
+ })),
181
+ ...result.suggestions.map((s) => ({
182
+ label: s,
183
+ category: "suggestion" as const,
184
+ })),
185
+ ];
186
+
187
+ const fixableIssues = allIssues.filter((i) => !fixedLabels.has(i.label));
188
+
189
+ const markFixed = (labels: string[]) => {
190
+ setFixedLabels((prev) => {
191
+ const next = new Set(prev);
192
+ labels.forEach((l) => next.add(l));
193
+ return next;
194
+ });
195
+ };
196
+
197
+ const FIX_ALL_INDEX = 0;
198
+ const totalOptions = fixableIssues.length + 1;
199
+
200
+ useInput((_, key) => {
201
+ if (stage.type === "picking-issue") {
202
+ if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
203
+ if (key.downArrow)
204
+ setSelectedIndex((i) => Math.min(totalOptions - 1, i + 1));
205
+ if (key.escape) {
206
+ onDone();
207
+ return;
208
+ }
209
+ if (key.return) {
210
+ if (selectedIndex === FIX_ALL_INDEX) {
211
+ setStage({
212
+ type: "fixing",
213
+ issue: "all issues",
214
+ progress: { current: 0, total: fixableIssues.length },
215
+ });
216
+ runFixAll(
217
+ repoPath,
218
+ fixableIssues,
219
+ requestedFiles,
220
+ provider,
221
+ (current, total, issue) => {
222
+ setStage({ type: "fixing", issue, progress: { current, total } });
223
+ },
224
+ ).then(({ allApplied, failed }) => {
225
+ const failedSet = new Set(failed);
226
+ markFixed(
227
+ fixableIssues
228
+ .filter((i) => !failedSet.has(i.label))
229
+ .map((i) => i.label),
230
+ );
231
+ setStage({
232
+ type: "fix-all-summary",
233
+ allApplied,
234
+ failed,
235
+ selectedFile: 0,
236
+ scrollOffset: 0,
237
+ });
238
+ });
239
+ return;
240
+ }
241
+ const issue = fixableIssues[selectedIndex - 1];
242
+ if (!issue) return;
243
+ setStage({ type: "fixing", issue: issue.label });
244
+ callModelRaw(
245
+ provider,
246
+ buildFixPrompt(repoPath, issue.label, requestedFiles),
247
+ )
248
+ .then((text: string) => {
249
+ const cleaned = text.replace(/```json|```/g, "").trim();
250
+ const match = cleaned.match(/\{[\s\S]*\}/);
251
+ if (!match) throw new Error("No JSON in response");
252
+ const plan = JSON.parse(match[0]) as FixPlan;
253
+ const diffLines = buildDiffs(repoPath, plan.patches);
254
+ const remaining = fixableIssues.filter(
255
+ (_, i) => i !== selectedIndex - 1,
256
+ );
257
+ setStage({
258
+ type: "preview",
259
+ issue: issue.label,
260
+ plan,
261
+ diffLines,
262
+ isFixAll: false,
263
+ remainingIssues: remaining,
264
+ scrollOffset: 0,
265
+ });
266
+ })
267
+ .catch((err: unknown) =>
268
+ setStage({
269
+ type: "error",
270
+ message: err instanceof Error ? err.message : "Fix failed",
271
+ }),
272
+ );
273
+ }
274
+ return;
275
+ }
276
+
277
+ if (stage.type === "preview") {
278
+ if (key.escape) {
279
+ setStage({ type: "picking-issue" });
280
+ return;
281
+ }
282
+ if (key.upArrow)
283
+ setStage({
284
+ ...stage,
285
+ scrollOffset: Math.max(0, stage.scrollOffset - 1),
286
+ });
287
+ if (key.downArrow)
288
+ setStage({ ...stage, scrollOffset: stage.scrollOffset + 1 });
289
+ if (key.return) {
290
+ const { plan, issue, remainingIssues } = stage;
291
+ setStage({ type: "applying", issue, plan });
292
+ try {
293
+ const applied = applyPatches(repoPath, plan, issue);
294
+ markFixed([issue]);
295
+ setSelectedIndex(0);
296
+ setStage({ type: "done", applied, remainingIssues });
297
+ } catch (err: unknown) {
298
+ setStage({
299
+ type: "error",
300
+ message:
301
+ err instanceof Error ? err.message : "Failed to write files",
302
+ });
303
+ }
304
+ }
305
+ return;
306
+ }
307
+
308
+ if (stage.type === "done") {
309
+ if (key.escape) {
310
+ onDone();
311
+ return;
312
+ }
313
+ if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
314
+ if (key.downArrow)
315
+ setSelectedIndex((i) => Math.min(stage.applied.length - 1, i + 1));
316
+ if (key.return) {
317
+ const file = stage.applied[selectedIndex];
318
+ if (!file) return;
319
+ const diffLines = buildDiffs(repoPath, [file.patch])[0] ?? [];
320
+ setStage({
321
+ type: "viewing-file",
322
+ file,
323
+ diffLines,
324
+ scrollOffset: 0,
325
+ returnTo: "done",
326
+ doneState: {
327
+ applied: stage.applied,
328
+ remainingIssues: stage.remainingIssues,
329
+ },
330
+ });
331
+ }
332
+ return;
333
+ }
334
+
335
+ if (stage.type === "fix-all-summary") {
336
+ if (key.escape) {
337
+ onDone();
338
+ return;
339
+ }
340
+ if (key.upArrow)
341
+ setStage({
342
+ ...stage,
343
+ selectedFile: Math.max(0, stage.selectedFile - 1),
344
+ });
345
+ if (key.downArrow)
346
+ setStage({
347
+ ...stage,
348
+ selectedFile: Math.min(
349
+ stage.allApplied.length - 1,
350
+ stage.selectedFile + 1,
351
+ ),
352
+ });
353
+ if (key.return) {
354
+ const file = stage.allApplied[stage.selectedFile];
355
+ if (!file) return;
356
+ const diffLines = buildDiffs(repoPath, [file.patch])[0] ?? [];
357
+ setStage({
358
+ type: "viewing-file",
359
+ file,
360
+ diffLines,
361
+ scrollOffset: 0,
362
+ returnTo: "fix-all-summary",
363
+ summaryState: { allApplied: stage.allApplied, failed: stage.failed },
364
+ });
365
+ }
366
+ return;
367
+ }
368
+
369
+ if (stage.type === "viewing-file") {
370
+ if (key.upArrow)
371
+ setStage({
372
+ ...stage,
373
+ scrollOffset: Math.max(0, stage.scrollOffset - 1),
374
+ });
375
+ if (key.downArrow)
376
+ setStage({ ...stage, scrollOffset: stage.scrollOffset + 1 });
377
+ if (key.escape || key.return) {
378
+ if (stage.returnTo === "done" && stage.doneState) {
379
+ setStage({ type: "done", ...stage.doneState });
380
+ setSelectedIndex(0);
381
+ } else if (stage.returnTo === "fix-all-summary" && stage.summaryState) {
382
+ setStage({
383
+ type: "fix-all-summary",
384
+ ...stage.summaryState,
385
+ selectedFile: 0,
386
+ scrollOffset: 0,
387
+ });
388
+ }
389
+ }
390
+ return;
391
+ }
392
+
393
+ if (stage.type === "error") {
394
+ if (key.return || key.escape) onDone();
395
+ }
396
+ });
397
+
398
+ if (stage.type === "picking-issue") {
399
+ const allDone = fixableIssues.length === 0;
400
+ return (
401
+ <Box flexDirection="column" marginTop={1} gap={1}>
402
+ <Box gap={2}>
403
+ <Text bold color="cyan">
404
+ ⚙ Issues
405
+ </Text>
406
+ {fixedLabels.size > 0 && (
407
+ <Text color="gray">
408
+ {figures.tick} {fixedLabels.size} fixed
409
+ {allDone
410
+ ? " · all done!"
411
+ : ` · ${fixableIssues.length} remaining`}
412
+ </Text>
413
+ )}
414
+ </Box>
415
+ {allIssues
416
+ .filter((i) => fixedLabels.has(i.label))
417
+ .map((issue, i) => (
418
+ <Box key={`fixed-${i}`} marginLeft={1}>
419
+ <Text color="gray">
420
+ {figures.tick}
421
+ {" "}
422
+ {issue.label}
423
+ </Text>
424
+ </Box>
425
+ ))}
426
+ {allDone ? (
427
+ <>
428
+ <Text color="green">{figures.tick} All issues fixed!</Text>
429
+ <Text color="gray">esc to go back</Text>
430
+ </>
431
+ ) : (
432
+ <>
433
+ <Box marginLeft={1}>
434
+ <Text color={selectedIndex === FIX_ALL_INDEX ? "cyan" : "white"}>
435
+ {selectedIndex === FIX_ALL_INDEX ? figures.arrowRight : " "}
436
+ {" "}
437
+ <Text bold>Fix all remaining</Text>
438
+ <Text color="gray">
439
+ {" "}
440
+ {fixableIssues.length} issues · no preview
441
+ </Text>
442
+ </Text>
443
+ </Box>
444
+ {fixableIssues.map((issue, i) => {
445
+ const isSelected = i + 1 === selectedIndex;
446
+ const color =
447
+ issue.category === "security"
448
+ ? "red"
449
+ : issue.category === "config"
450
+ ? "yellow"
451
+ : "white";
452
+ return (
453
+ <Box key={i} marginLeft={1}>
454
+ <Text color={isSelected ? "cyan" : color}>
455
+ {isSelected ? figures.arrowRight : " "}
456
+ {" "}
457
+ {issue.label}
458
+ </Text>
459
+ </Box>
460
+ );
461
+ })}
462
+ <Text color="gray">
463
+ ↑↓ navigate · enter to fix · esc to go back
464
+ </Text>
465
+ </>
466
+ )}
467
+ </Box>
468
+ );
469
+ }
470
+
471
+ if (stage.type === "fixing") {
472
+ const { progress } = stage;
473
+ return (
474
+ <Box flexDirection="column" marginTop={1} gap={1}>
475
+ <Box>
476
+ <Text color={ACCENT}>
477
+ <Spinner />
478
+ </Text>
479
+ <Box marginLeft={1}>
480
+ {progress ? (
481
+ <Text>
482
+ [{progress.current}/{progress.total}] Fixing:{" "}
483
+ <Text color="cyan">{stage.issue}</Text>
484
+ </Text>
485
+ ) : (
486
+ <Text>
487
+ Generating fix for: <Text color="cyan">{stage.issue}</Text>
488
+ </Text>
489
+ )}
490
+ </Box>
491
+ </Box>
492
+ {progress && (
493
+ <Box marginLeft={2}>
494
+ <Text color="gray">
495
+ {"█".repeat(progress.current)}
496
+ {"░".repeat(progress.total - progress.current)}{" "}
497
+ {Math.round((progress.current / progress.total) * 100)}%
498
+ </Text>
499
+ </Box>
500
+ )}
501
+ </Box>
502
+ );
503
+ }
504
+
505
+ if (stage.type === "preview") {
506
+ const { plan, diffLines, scrollOffset } = stage;
507
+ return (
508
+ <Box flexDirection="column" marginTop={1} gap={1}>
509
+ <Text bold color="cyan">
510
+ {figures.info} Fix Preview
511
+ </Text>
512
+ <Text color="white">{plan.summary}</Text>
513
+ <Box flexDirection="column" marginTop={1}>
514
+ <DiffViewer
515
+ patches={plan.patches}
516
+ diffs={diffLines}
517
+ scrollOffset={scrollOffset}
518
+ />
519
+ </Box>
520
+ <Text color="gray">↑↓ scroll · enter to apply · esc to go back</Text>
521
+ </Box>
522
+ );
523
+ }
524
+
525
+ if (stage.type === "applying") {
526
+ return (
527
+ <Box marginTop={1}>
528
+ <Text color={ACCENT}>
529
+ <Spinner />
530
+ </Text>
531
+ <Box marginLeft={1}>
532
+ <Text>Applying fixes...</Text>
533
+ </Box>
534
+ </Box>
535
+ );
536
+ }
537
+
538
+ if (stage.type === "done") {
539
+ return (
540
+ <Box flexDirection="column" marginTop={1} gap={1}>
541
+ <Text bold color="green">
542
+ {figures.tick} Fix applied
543
+ </Text>
544
+ {stage.applied.map((f, i) => {
545
+ const isSelected = i === selectedIndex;
546
+ return (
547
+ <Box key={f.path} marginLeft={1}>
548
+ <Text color={isSelected ? "cyan" : "green"}>
549
+ {isSelected
550
+ ? figures.arrowRight
551
+ : f.isNew
552
+ ? figures.tick
553
+ : figures.bullet}{" "}
554
+ {f.path}
555
+ {isSelected && <Text color="gray"> · enter to view diff</Text>}
556
+ </Text>
557
+ </Box>
558
+ );
559
+ })}
560
+ {stage.remainingIssues.length > 0 && (
561
+ <Text color="gray">
562
+ {figures.info} {stage.remainingIssues.length} issue(s) remaining ·
563
+ esc to go back to list
564
+ </Text>
565
+ )}
566
+ <Text color="gray">
567
+ ↑↓ navigate · enter to view diff · esc to go back
568
+ </Text>
569
+ </Box>
570
+ );
571
+ }
572
+
573
+ if (stage.type === "fix-all-summary") {
574
+ const { allApplied, failed, selectedFile } = stage;
575
+ return (
576
+ <Box flexDirection="column" marginTop={1} gap={1}>
577
+ <Text bold color="green">
578
+ {figures.tick} Fix all complete
579
+ </Text>
580
+ <Text color="gray">
581
+ {allApplied.length} file(s) written · {failed.length} issue(s) failed
582
+ </Text>
583
+ {allApplied.length > 0 && (
584
+ <Box flexDirection="column" gap={0}>
585
+ <Text color="gray">Applied:</Text>
586
+ {allApplied.map((f, i) => {
587
+ const isSelected = i === selectedFile;
588
+ return (
589
+ <Box key={i} marginLeft={1}>
590
+ <Text color={isSelected ? "cyan" : "green"}>
591
+ {isSelected
592
+ ? figures.arrowRight
593
+ : f.isNew
594
+ ? figures.tick
595
+ : figures.bullet}{" "}
596
+ {f.path}
597
+ <Text color="gray">
598
+ {isSelected
599
+ ? " · enter to view diff"
600
+ : ` ← ${f.issueLabel.slice(0, 40)}${f.issueLabel.length > 40 ? "…" : ""}`}
601
+ </Text>
602
+ </Text>
603
+ </Box>
604
+ );
605
+ })}
606
+ </Box>
607
+ )}
608
+ {failed.length > 0 && (
609
+ <Box flexDirection="column" gap={0}>
610
+ <Text color="red">Failed:</Text>
611
+ {failed.map((label, i) => (
612
+ <Box key={i} marginLeft={1}>
613
+ <Text color="red">
614
+ {figures.cross} {label}
615
+ </Text>
616
+ </Box>
617
+ ))}
618
+ </Box>
619
+ )}
620
+ <Text color="gray">
621
+ ↑↓ navigate · enter to view diff · esc to go back
622
+ </Text>
623
+ </Box>
624
+ );
625
+ }
626
+
627
+ if (stage.type === "viewing-file") {
628
+ const { file, diffLines, scrollOffset: viewScroll } = stage;
629
+ return (
630
+ <Box flexDirection="column" marginTop={1} gap={1}>
631
+ <Box gap={1}>
632
+ <Text bold color="cyan">
633
+ {figures.info}
634
+ </Text>
635
+ <Text bold color="cyan">
636
+ {file.path}
637
+ </Text>
638
+ <Text color="gray">{file.isNew ? "(new file)" : "(modified)"}</Text>
639
+ </Box>
640
+ <Text color="gray" dimColor>
641
+ {file.issueLabel.slice(0, 80)}
642
+ {file.issueLabel.length > 80 ? "…" : ""}
643
+ </Text>
644
+ <DiffViewer
645
+ patches={[file.patch]}
646
+ diffs={[diffLines]}
647
+ scrollOffset={viewScroll}
648
+ />
649
+ <Text color="gray">↑↓ scroll · esc or enter to go back</Text>
650
+ </Box>
651
+ );
652
+ }
653
+
654
+ if (stage.type === "error") {
655
+ return (
656
+ <Box flexDirection="column" marginTop={1} gap={1}>
657
+ <Text color="red">
658
+ {figures.cross} {stage.message}
659
+ </Text>
660
+ <Text color="gray">enter or esc to go back</Text>
661
+ </Box>
662
+ );
663
+ }
664
+
665
+ return null;
666
+ };