@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,1209 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Text, Static, useInput } from "ink";
3
+ import TextInput from "ink-text-input";
4
+ import { execSync } from "child_process";
5
+ import { ProviderPicker } from "../repo/ProviderPicker";
6
+ import {
7
+ fetchCommits,
8
+ fetchDiff,
9
+ isGitRepo,
10
+ summarizeTimeline,
11
+ } from "../../utils/git";
12
+ import { callChat } from "../../utils/chat";
13
+ import type { Commit, DiffFile } from "../../utils/git";
14
+ import type { Provider } from "../../types/config";
15
+
16
+ const ACCENT = "#FF8C00";
17
+ const W = () => process.stdout.columns ?? 100;
18
+
19
+ // ── git tool helpers ──────────────────────────────────────────────────────────
20
+
21
+ function gitRun(cmd: string, cwd: string): { ok: boolean; out: string } {
22
+ try {
23
+ const out = execSync(cmd, {
24
+ cwd,
25
+ encoding: "utf-8",
26
+ stdio: ["pipe", "pipe", "pipe"],
27
+ timeout: 60_000,
28
+ }).trim();
29
+ return { ok: true, out: out || "(done)" };
30
+ } catch (e: any) {
31
+ const msg =
32
+ [e.stdout, e.stderr].filter(Boolean).join("\n").trim() || e.message;
33
+ return { ok: false, out: msg };
34
+ }
35
+ }
36
+
37
+ function getUnstagedDiff(cwd: string): string {
38
+ // includes both tracked changes and new untracked files
39
+ const tracked = gitRun("git diff HEAD", cwd).out;
40
+ const untracked = gitRun(`git ls-files --others --exclude-standard`, cwd).out;
41
+
42
+ const untrackedContent = untracked
43
+ .split("\n")
44
+ .filter(Boolean)
45
+ .slice(0, 10)
46
+ .map((f) => {
47
+ try {
48
+ const content = execSync(
49
+ `git show :0 "${f}" 2>/dev/null || type "${f}"`,
50
+ {
51
+ cwd,
52
+ encoding: "utf-8",
53
+ stdio: ["pipe", "pipe", "pipe"],
54
+ },
55
+ )
56
+ .trim()
57
+ .slice(0, 500);
58
+ return `=== new file: ${f} ===\n${content}`;
59
+ } catch {
60
+ return `=== new file: ${f} ===`;
61
+ }
62
+ })
63
+ .join("\n\n");
64
+
65
+ return [tracked.slice(0, 4000), untrackedContent]
66
+ .filter(Boolean)
67
+ .join("\n\n");
68
+ }
69
+
70
+ async function generateCommitMessage(
71
+ provider: Provider,
72
+ diff: string,
73
+ ): Promise<string> {
74
+ const system = `You are a commit message generator. Given a git diff, write a concise, imperative commit message.
75
+ Rules:
76
+ - First line: short summary, max 72 chars, imperative mood ("add", "fix", "update", not "added")
77
+ - If needed, one blank line then a short body (2-3 lines max)
78
+ - No markdown, no bullet points, no code blocks
79
+ - Output ONLY the commit message, nothing else`;
80
+
81
+ const msgs = [
82
+ {
83
+ role: "user" as const,
84
+ content: `Write a commit message for this diff:\n\n${diff}`,
85
+ type: "text" as const,
86
+ },
87
+ ];
88
+ const raw = await callChat(provider, system, msgs as any);
89
+ return typeof raw === "string" ? raw.trim() : "update files";
90
+ }
91
+
92
+ // ── tiny helpers ──────────────────────────────────────────────────────────────
93
+
94
+ function shortDate(d: string) {
95
+ try {
96
+ return new Date(d).toLocaleDateString("en-US", {
97
+ month: "short",
98
+ day: "numeric",
99
+ year: "2-digit",
100
+ });
101
+ } catch {
102
+ return d.slice(0, 10);
103
+ }
104
+ }
105
+
106
+ function trunc(s: string, n: number) {
107
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
108
+ }
109
+
110
+ function bar(ins: number, del: number): string {
111
+ const total = ins + del;
112
+ if (!total) return "";
113
+ const w = 10;
114
+ const addW = Math.round((ins / total) * w);
115
+ return "+" + "█".repeat(addW) + "░".repeat(w - addW) + "-";
116
+ }
117
+
118
+ // ── CommitRow ─────────────────────────────────────────────────────────────────
119
+
120
+ function CommitRow({
121
+ commit,
122
+ index,
123
+ isSelected,
124
+ showDiff,
125
+ diff,
126
+ diffScroll,
127
+ onRevert,
128
+ }: {
129
+ commit: Commit;
130
+ index: number;
131
+ isSelected: boolean;
132
+ showDiff: boolean;
133
+ diff: DiffFile[];
134
+ diffScroll: number;
135
+ onRevert: () => void;
136
+ }) {
137
+ const w = W();
138
+ const isMerge = commit.parents.length > 1;
139
+ const node = isMerge ? "⎇" : index === 0 ? "◉" : "●";
140
+
141
+ const refLabels = commit.refs
142
+ .split(",")
143
+ .map((r) => r.trim())
144
+ .filter(Boolean)
145
+ .map((r) =>
146
+ r.startsWith("HEAD -> ")
147
+ ? r.slice(8)
148
+ : r.startsWith("tag: ")
149
+ ? `v${r.slice(5)}`
150
+ : r,
151
+ )
152
+ .slice(0, 2);
153
+
154
+ return (
155
+ <Box flexDirection="column">
156
+ <Box gap={1}>
157
+ <Text color={isSelected ? ACCENT : "gray"}>
158
+ {isSelected ? "▶" : " "}
159
+ </Text>
160
+ <Text color={isSelected ? ACCENT : isMerge ? "magenta" : "gray"}>
161
+ {node}
162
+ </Text>
163
+ <Text color="gray" dimColor={!isSelected}>
164
+ {commit.shortHash}
165
+ </Text>
166
+ <Text color="cyan" dimColor={!isSelected}>
167
+ {shortDate(commit.date)}
168
+ </Text>
169
+ {refLabels.map((r) => (
170
+ <Text key={r} color="yellow">
171
+ [{r}]
172
+ </Text>
173
+ ))}
174
+ <Text
175
+ color={isSelected ? "white" : "gray"}
176
+ bold={isSelected}
177
+ wrap="truncate"
178
+ >
179
+ {trunc(commit.message, w - 36)}
180
+ </Text>
181
+ </Box>
182
+
183
+ {isSelected && !showDiff && (
184
+ <Box flexDirection="column" marginLeft={4} marginBottom={1}>
185
+ <Box gap={2}>
186
+ <Text color="gray" dimColor>
187
+ {commit.author}
188
+ </Text>
189
+ <Text color="gray" dimColor>
190
+ {commit.relativeDate}
191
+ </Text>
192
+ {commit.filesChanged > 0 && (
193
+ <>
194
+ <Text color="green">+{commit.insertions}</Text>
195
+ <Text color="red">-{commit.deletions}</Text>
196
+ <Text color="gray" dimColor>
197
+ {commit.filesChanged} file
198
+ {commit.filesChanged !== 1 ? "s" : ""}
199
+ </Text>
200
+ <Text color="gray" dimColor>
201
+ {bar(commit.insertions, commit.deletions)}
202
+ </Text>
203
+ </>
204
+ )}
205
+ </Box>
206
+ {commit.body ? (
207
+ <Text color="gray" dimColor wrap="wrap">
208
+ {trunc(commit.body, w - 8)}
209
+ </Text>
210
+ ) : null}
211
+ <Box gap={3} marginTop={1}>
212
+ <Text color="gray" dimColor>
213
+ enter diff
214
+ </Text>
215
+ <Text color="red" dimColor>
216
+ x revert
217
+ </Text>
218
+ </Box>
219
+ </Box>
220
+ )}
221
+
222
+ {isSelected && showDiff && (
223
+ <Box flexDirection="column" marginLeft={2} marginBottom={1}>
224
+ <Box gap={3} marginBottom={1}>
225
+ <Text color={ACCENT} bold>
226
+ DIFF
227
+ </Text>
228
+ <Text color="gray" dimColor>
229
+ {commit.shortHash} — {trunc(commit.message, 50)}
230
+ </Text>
231
+ <Text color="red" dimColor>
232
+ x revert
233
+ </Text>
234
+ <Text color="gray" dimColor>
235
+ esc close
236
+ </Text>
237
+ </Box>
238
+ <DiffPanel
239
+ files={diff}
240
+ scrollOffset={diffScroll}
241
+ maxLines={Math.max(8, (process.stdout.rows ?? 30) - 12)}
242
+ />
243
+ <Text color="gray" dimColor>
244
+ ↑↓ scroll · esc close
245
+ </Text>
246
+ </Box>
247
+ )}
248
+ </Box>
249
+ );
250
+ }
251
+
252
+ // ── DiffPanel ─────────────────────────────────────────────────────────────────
253
+
254
+ function DiffPanel({
255
+ files,
256
+ scrollOffset,
257
+ maxLines,
258
+ }: {
259
+ files: DiffFile[];
260
+ scrollOffset: number;
261
+ maxLines: number;
262
+ }) {
263
+ const w = W() - 6;
264
+
265
+ type RLine =
266
+ | {
267
+ k: "file";
268
+ path: string;
269
+ ins: number;
270
+ del: number;
271
+ status: DiffFile["status"];
272
+ }
273
+ | { k: "hunk" | "add" | "rem" | "ctx"; content: string };
274
+
275
+ const all: RLine[] = [];
276
+ for (const f of files) {
277
+ const icon =
278
+ f.status === "added"
279
+ ? "+"
280
+ : f.status === "deleted"
281
+ ? "-"
282
+ : f.status === "renamed"
283
+ ? "→"
284
+ : "~";
285
+ all.push({
286
+ k: "file",
287
+ path: `${icon} ${f.path}`,
288
+ ins: f.insertions,
289
+ del: f.deletions,
290
+ status: f.status,
291
+ });
292
+ for (const l of f.lines) {
293
+ if (l.type === "header") all.push({ k: "hunk", content: l.content });
294
+ else if (l.type === "add") all.push({ k: "add", content: l.content });
295
+ else if (l.type === "remove") all.push({ k: "rem", content: l.content });
296
+ else all.push({ k: "ctx", content: l.content });
297
+ }
298
+ }
299
+
300
+ if (!all.length)
301
+ return (
302
+ <Text color="gray" dimColor>
303
+ {" "}
304
+ no diff available
305
+ </Text>
306
+ );
307
+
308
+ const visible = all.slice(scrollOffset, scrollOffset + maxLines);
309
+ const hasMore = all.length > scrollOffset + maxLines;
310
+
311
+ return (
312
+ <Box flexDirection="column">
313
+ {visible.map((line, i) => {
314
+ if (line.k === "file") {
315
+ const color =
316
+ line.status === "added"
317
+ ? "green"
318
+ : line.status === "deleted"
319
+ ? "red"
320
+ : line.status === "renamed"
321
+ ? "yellow"
322
+ : "cyan";
323
+ return (
324
+ <Box key={i} gap={2} marginTop={i > 0 ? 1 : 0}>
325
+ <Text color={color} bold>
326
+ {trunc(line.path, w)}
327
+ </Text>
328
+ <Text color="green">+{line.ins}</Text>
329
+ <Text color="red">-{line.del}</Text>
330
+ </Box>
331
+ );
332
+ }
333
+ if (line.k === "hunk")
334
+ return (
335
+ <Text key={i} color="cyan" dimColor>
336
+ {trunc(line.content, w)}
337
+ </Text>
338
+ );
339
+ if (line.k === "add")
340
+ return (
341
+ <Text key={i} color="green">
342
+ {"+"}
343
+ {trunc(line.content, w - 1)}
344
+ </Text>
345
+ );
346
+ if (line.k === "rem")
347
+ return (
348
+ <Text key={i} color="red">
349
+ {"-"}
350
+ {trunc(line.content, w - 1)}
351
+ </Text>
352
+ );
353
+ return (
354
+ <Text key={i} color="gray" dimColor>
355
+ {" "}
356
+ {trunc(line.content, w - 1)}
357
+ </Text>
358
+ );
359
+ })}
360
+ {hasMore && (
361
+ <Text color="gray" dimColor>
362
+ {" "}
363
+ … {all.length - scrollOffset - maxLines} more lines
364
+ </Text>
365
+ )}
366
+ </Box>
367
+ );
368
+ }
369
+
370
+ // ── RevertConfirm overlay ─────────────────────────────────────────────────────
371
+
372
+ function RevertConfirm({
373
+ commit,
374
+ repoPath,
375
+ onDone,
376
+ }: {
377
+ commit: Commit;
378
+ repoPath: string;
379
+ onDone: (msg: string | null) => void;
380
+ }) {
381
+ const [status, setStatus] = useState<"confirm" | "running" | "done">(
382
+ "confirm",
383
+ );
384
+ const [result, setResult] = useState("");
385
+
386
+ useInput((input, key) => {
387
+ if (status !== "confirm") return;
388
+ if (input === "y" || input === "Y" || key.return) {
389
+ setStatus("running");
390
+ // use revert (safe — creates a new commit, doesn't rewrite history)
391
+ const r = gitRun(`git revert --no-edit "${commit.hash}"`, repoPath);
392
+ setResult(r.out);
393
+ setStatus("done");
394
+ setTimeout(
395
+ () => onDone(r.ok ? `Reverted ${commit.shortHash}` : null),
396
+ 1200,
397
+ );
398
+ }
399
+ if (input === "n" || input === "N" || key.escape) onDone(null);
400
+ });
401
+
402
+ const w = W();
403
+ return (
404
+ <Box flexDirection="column" marginTop={1}>
405
+ <Text color="gray" dimColor>
406
+ {"─".repeat(w)}
407
+ </Text>
408
+ {status === "confirm" && (
409
+ <Box flexDirection="column" paddingX={1} gap={1}>
410
+ <Box gap={1}>
411
+ <Text color="red">!</Text>
412
+ <Text color="white">revert </Text>
413
+ <Text color={ACCENT}>{commit.shortHash}</Text>
414
+ <Text color="gray" dimColor>
415
+ — {trunc(commit.message, 50)}
416
+ </Text>
417
+ </Box>
418
+ <Text color="gray" dimColor>
419
+ {" "}
420
+ this creates a new "revert" commit — git history is preserved
421
+ </Text>
422
+ <Box gap={2} marginTop={1}>
423
+ <Text color="green">y/enter confirm</Text>
424
+ <Text color="gray" dimColor>
425
+ n/esc cancel
426
+ </Text>
427
+ </Box>
428
+ </Box>
429
+ )}
430
+ {status === "running" && (
431
+ <Box paddingX={1} gap={1}>
432
+ <Text color={ACCENT}>*</Text>
433
+ <Text color="gray" dimColor>
434
+ reverting…
435
+ </Text>
436
+ </Box>
437
+ )}
438
+ {status === "done" && (
439
+ <Box paddingX={1} gap={1}>
440
+ <Text
441
+ color={
442
+ result.startsWith("Error") || result.includes("error")
443
+ ? "red"
444
+ : "green"
445
+ }
446
+ >
447
+ {result.startsWith("Error") ? "✗" : "✓"}
448
+ </Text>
449
+ <Text color="white" wrap="wrap">
450
+ {trunc(result, W() - 6)}
451
+ </Text>
452
+ </Box>
453
+ )}
454
+ </Box>
455
+ );
456
+ }
457
+
458
+ // ── CommitPanel — stage + commit unstaged changes ─────────────────────────────
459
+
460
+ type CommitPanelState =
461
+ | { phase: "scanning" }
462
+ | { phase: "no-changes" }
463
+ | { phase: "generating"; diff: string }
464
+ | { phase: "review"; diff: string; message: string }
465
+ | { phase: "editing"; diff: string; message: string }
466
+ | { phase: "committing"; message: string }
467
+ | { phase: "done"; result: string }
468
+ | { phase: "error"; message: string };
469
+
470
+ function CommitPanel({
471
+ repoPath,
472
+ provider,
473
+ onDone,
474
+ }: {
475
+ repoPath: string;
476
+ provider: Provider;
477
+ onDone: (msg: string | null) => void;
478
+ }) {
479
+ const [state, setState] = useState<CommitPanelState>({ phase: "scanning" });
480
+
481
+ // scan + generate on mount
482
+ useEffect(() => {
483
+ const diff = getUnstagedDiff(repoPath);
484
+ if (!diff.trim() || diff === "(done)") {
485
+ setState({ phase: "no-changes" });
486
+ return;
487
+ }
488
+ setState({ phase: "generating", diff });
489
+ generateCommitMessage(provider, diff)
490
+ .then((msg) => setState({ phase: "review", diff, message: msg }))
491
+ .catch((e) => setState({ phase: "error", message: String(e) }));
492
+ }, []);
493
+
494
+ useInput((input, key) => {
495
+ if (
496
+ state.phase === "no-changes" ||
497
+ state.phase === "scanning" ||
498
+ state.phase === "generating"
499
+ ) {
500
+ if (key.escape || input === "n" || input === "N") onDone(null);
501
+ return;
502
+ }
503
+
504
+ if (state.phase === "review") {
505
+ if (input === "y" || input === "Y" || key.return) {
506
+ // commit
507
+ setState({ phase: "committing", message: state.message });
508
+ const add = gitRun("git add -A", repoPath);
509
+ if (!add.ok) {
510
+ setState({ phase: "error", message: add.out });
511
+ return;
512
+ }
513
+ const commit = gitRun(
514
+ `git commit -m ${JSON.stringify(state.message)}`,
515
+ repoPath,
516
+ );
517
+ setState({
518
+ phase: "done",
519
+ result: commit.ok ? commit.out : `Error: ${commit.out}`,
520
+ });
521
+ setTimeout(() => onDone(commit.ok ? state.message : null), 1500);
522
+ return;
523
+ }
524
+ if (input === "e" || input === "E") {
525
+ setState({
526
+ phase: "editing",
527
+ diff: state.diff,
528
+ message: state.message,
529
+ });
530
+ return;
531
+ }
532
+ if (input === "n" || input === "N" || key.escape) {
533
+ onDone(null);
534
+ return;
535
+ }
536
+ }
537
+
538
+ if (state.phase === "editing") {
539
+ if (key.escape) {
540
+ setState({ phase: "review", diff: state.diff, message: state.message });
541
+ }
542
+ // TextInput handles the rest
543
+ }
544
+
545
+ if (state.phase === "done" || state.phase === "error") {
546
+ if (key.return || key.escape) onDone(null);
547
+ }
548
+ });
549
+
550
+ const w = W();
551
+ const divider = "─".repeat(w);
552
+
553
+ return (
554
+ <Box flexDirection="column" marginTop={1}>
555
+ <Text color="gray" dimColor>
556
+ {divider}
557
+ </Text>
558
+ <Box paddingX={1} marginBottom={1} gap={2}>
559
+ <Text color={ACCENT} bold>
560
+ COMMIT CHANGES
561
+ </Text>
562
+ </Box>
563
+
564
+ {state.phase === "scanning" && (
565
+ <Box paddingX={1} gap={1}>
566
+ <Text color={ACCENT}>*</Text>
567
+ <Text color="gray" dimColor>
568
+ scanning for changes…
569
+ </Text>
570
+ </Box>
571
+ )}
572
+
573
+ {state.phase === "no-changes" && (
574
+ <Box paddingX={1} flexDirection="column" gap={1}>
575
+ <Box gap={1}>
576
+ <Text color="yellow">!</Text>
577
+ <Text color="white">no uncommitted changes found</Text>
578
+ </Box>
579
+ <Text color="gray" dimColor>
580
+ {" "}
581
+ esc to close
582
+ </Text>
583
+ </Box>
584
+ )}
585
+
586
+ {state.phase === "generating" && (
587
+ <Box paddingX={1} gap={1}>
588
+ <Text color={ACCENT}>*</Text>
589
+ <Text color="gray" dimColor>
590
+ generating commit message…
591
+ </Text>
592
+ </Box>
593
+ )}
594
+
595
+ {(state.phase === "review" || state.phase === "editing") && (
596
+ <Box paddingX={1} flexDirection="column" gap={1}>
597
+ {/* show a compact diff summary */}
598
+ <Box gap={1}>
599
+ <Text color="gray" dimColor>
600
+ diff preview:
601
+ </Text>
602
+ <Text color="gray" dimColor>
603
+ {trunc(state.diff.split("\n")[0] ?? "", w - 20)}
604
+ </Text>
605
+ </Box>
606
+ <Box gap={1} marginTop={1}>
607
+ <Text color="gray" dimColor>
608
+ message:
609
+ </Text>
610
+ </Box>
611
+
612
+ {state.phase === "review" && (
613
+ <Box paddingLeft={2} flexDirection="column">
614
+ <Text color="white" bold wrap="wrap">
615
+ {state.message}
616
+ </Text>
617
+ <Box gap={3} marginTop={1}>
618
+ <Text color="green">y/enter commit</Text>
619
+ <Text color="cyan">e edit</Text>
620
+ <Text color="gray" dimColor>
621
+ n/esc cancel
622
+ </Text>
623
+ </Box>
624
+ </Box>
625
+ )}
626
+
627
+ {state.phase === "editing" && (
628
+ <Box paddingLeft={2} flexDirection="column" gap={1}>
629
+ <TextInput
630
+ value={state.message}
631
+ onChange={(msg) =>
632
+ setState({ phase: "editing", diff: state.diff, message: msg })
633
+ }
634
+ onSubmit={(msg) =>
635
+ setState({ phase: "review", diff: state.diff, message: msg })
636
+ }
637
+ />
638
+ <Text color="gray" dimColor>
639
+ enter to confirm · esc to cancel edit
640
+ </Text>
641
+ </Box>
642
+ )}
643
+ </Box>
644
+ )}
645
+
646
+ {state.phase === "committing" && (
647
+ <Box paddingX={1} gap={1}>
648
+ <Text color={ACCENT}>*</Text>
649
+ <Text color="gray" dimColor>
650
+ committing…
651
+ </Text>
652
+ </Box>
653
+ )}
654
+
655
+ {state.phase === "done" && (
656
+ <Box paddingX={1} gap={1}>
657
+ <Text color="green">✓</Text>
658
+ <Text color="white" wrap="wrap">
659
+ {trunc(state.result, w - 6)}
660
+ </Text>
661
+ </Box>
662
+ )}
663
+
664
+ {state.phase === "error" && (
665
+ <Box paddingX={1} flexDirection="column" gap={1}>
666
+ <Box gap={1}>
667
+ <Text color="red">✗</Text>
668
+ <Text color="white" wrap="wrap">
669
+ {trunc(state.message, w - 6)}
670
+ </Text>
671
+ </Box>
672
+ <Text color="gray" dimColor>
673
+ {" "}
674
+ enter/esc to close
675
+ </Text>
676
+ </Box>
677
+ )}
678
+ </Box>
679
+ );
680
+ }
681
+
682
+ // ── AskPanel ──────────────────────────────────────────────────────────────────
683
+ // No Static here — Static always floats to the top of Ink output regardless of
684
+ // where it is placed in the tree. We use plain state arrays instead so messages
685
+ // render in document flow, below the commit list.
686
+
687
+ type ChatMsg =
688
+ | { role: "user"; content: string }
689
+ | { role: "assistant"; content: string }
690
+ | { role: "thinking" };
691
+
692
+ function AskPanel({
693
+ commits,
694
+ provider,
695
+ onCommit,
696
+ }: {
697
+ commits: Commit[];
698
+ provider: Provider;
699
+ onCommit: () => void;
700
+ }) {
701
+ const [messages, setMessages] = useState<ChatMsg[]>([]);
702
+ const [input, setInput] = useState("");
703
+ const [thinking, setThinking] = useState(false);
704
+ const [history, setHistory] = useState<
705
+ { role: "user" | "assistant"; content: string; type: "text" }[]
706
+ >([]);
707
+
708
+ // keywords that mean "commit my changes" in any language
709
+ const COMMIT_TRIGGERS = [
710
+ /commit/i,
711
+ /stage/i,
712
+ /push changes/i,
713
+ // hinglish / hindi
714
+ /commit kr/i,
715
+ /commit kar/i,
716
+ /changes commit/i,
717
+ /changes save/i,
718
+ /save changes/i,
719
+ /badlav.*commit/i,
720
+ ];
721
+
722
+ const systemPrompt = `You are a git history analyst embedded in a terminal git timeline viewer.
723
+ You can ONLY answer questions about the git history shown below.
724
+ You CANNOT run commands, execute git operations, or modify files.
725
+ If the user asks to commit, stage, push, or make any git change — reply with exactly: DELEGATE_COMMIT
726
+ Plain text answers only. No markdown. No code blocks. No backticks. Be concise.
727
+
728
+ ${summarizeTimeline(commits)}`;
729
+
730
+ const ask = async (q: string) => {
731
+ if (!q.trim() || thinking) return;
732
+
733
+ // client-side check first — catch obvious commit intents without an API call
734
+ if (COMMIT_TRIGGERS.some((re) => re.test(q))) {
735
+ setMessages((prev) => [...prev, { role: "user", content: q }]);
736
+ setInput("");
737
+ onCommit();
738
+ return;
739
+ }
740
+
741
+ const nextHistory = [
742
+ ...history,
743
+ { role: "user" as const, content: q, type: "text" as const },
744
+ ];
745
+ setMessages((prev) => [
746
+ ...prev,
747
+ { role: "user", content: q },
748
+ { role: "thinking" },
749
+ ]);
750
+ setThinking(true);
751
+ setInput("");
752
+ try {
753
+ const raw = await callChat(provider, systemPrompt, nextHistory as any);
754
+ const answer = typeof raw === "string" ? raw.trim() : "(no response)";
755
+
756
+ // model-side delegation signal
757
+ if (
758
+ answer === "DELEGATE_COMMIT" ||
759
+ answer.startsWith("DELEGATE_COMMIT")
760
+ ) {
761
+ setMessages((prev) => prev.filter((m) => m.role !== "thinking"));
762
+ setThinking(false);
763
+ onCommit();
764
+ return;
765
+ }
766
+
767
+ // strip any accidental markdown/code blocks the model snuck in
768
+ const clean = answer
769
+ .replace(/```[\s\S]*?```/g, "")
770
+ .replace(/`([^`]+)`/g, "$1")
771
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
772
+ .trim();
773
+
774
+ setMessages((prev) => [
775
+ ...prev.filter((m) => m.role !== "thinking"),
776
+ { role: "assistant", content: clean },
777
+ ]);
778
+ setHistory([
779
+ ...nextHistory,
780
+ { role: "assistant", content: clean, type: "text" },
781
+ ]);
782
+ } catch (e) {
783
+ setMessages((prev) => [
784
+ ...prev.filter((m) => m.role !== "thinking"),
785
+ { role: "assistant", content: `Error: ${String(e)}` },
786
+ ]);
787
+ } finally {
788
+ setThinking(false);
789
+ }
790
+ };
791
+
792
+ const w = W();
793
+
794
+ return (
795
+ <Box flexDirection="column" marginTop={1}>
796
+ <Text color="gray" dimColor>
797
+ {"─".repeat(w)}
798
+ </Text>
799
+
800
+ {/* plain array render — stays in document flow below the commit list */}
801
+ {messages.map((msg, i) => {
802
+ if (msg.role === "thinking")
803
+ return (
804
+ <Box key={i} paddingX={1} gap={1}>
805
+ <Text color={ACCENT}>*</Text>
806
+ <Text color="gray" dimColor>
807
+ thinking…
808
+ </Text>
809
+ </Box>
810
+ );
811
+ if (msg.role === "user")
812
+ return (
813
+ <Box key={i} paddingX={1} gap={1}>
814
+ <Text color="gray">{">"}</Text>
815
+ <Text color="white">{msg.content}</Text>
816
+ </Box>
817
+ );
818
+ return (
819
+ <Box key={i} paddingX={1} gap={1} marginBottom={1}>
820
+ <Text color={ACCENT}>{"*"}</Text>
821
+ <Text color="white" wrap="wrap">
822
+ {msg.content}
823
+ </Text>
824
+ </Box>
825
+ );
826
+ })}
827
+
828
+ {/* input always at the bottom of the panel */}
829
+ <Box paddingX={1} gap={1}>
830
+ <Text color={ACCENT}>{"?"}</Text>
831
+ {!thinking ? (
832
+ <TextInput
833
+ value={input}
834
+ onChange={setInput}
835
+ onSubmit={ask}
836
+ placeholder="ask about the history…"
837
+ />
838
+ ) : (
839
+ <Text color="gray" dimColor>
840
+ thinking…
841
+ </Text>
842
+ )}
843
+ </Box>
844
+ </Box>
845
+ );
846
+ }
847
+
848
+ // ── TimelineRunner ────────────────────────────────────────────────────────────
849
+
850
+ type UIMode =
851
+ | { type: "browse" }
852
+ | { type: "search"; query: string }
853
+ | { type: "ask" }
854
+ | { type: "revert"; commit: Commit }
855
+ | { type: "commit" };
856
+
857
+ type StatusMsg = { id: number; text: string; ok: boolean };
858
+ let sid = 0;
859
+
860
+ export function TimelineRunner({
861
+ repoPath,
862
+ onExit,
863
+ }: {
864
+ repoPath: string;
865
+ onExit?: () => void;
866
+ }) {
867
+ const [provider, setProvider] = useState<Provider | null>(null);
868
+ const [commits, setCommits] = useState<Commit[]>([]);
869
+ const [filtered, setFiltered] = useState<Commit[]>([]);
870
+ const [loading, setLoading] = useState(true);
871
+ const [error, setError] = useState<string | null>(null);
872
+
873
+ const [selectedIdx, setSelectedIdx] = useState(0);
874
+ const [scrollOffset, setScrollOffset] = useState(0);
875
+ const [showDiff, setShowDiff] = useState(false);
876
+ const [diff, setDiff] = useState<DiffFile[]>([]);
877
+ const [diffLoading, setDiffLoading] = useState(false);
878
+ const [diffScroll, setDiffScroll] = useState(0);
879
+ const [lastDiffHash, setLastDiffHash] = useState<string | null>(null);
880
+
881
+ const [mode, setMode] = useState<UIMode>({ type: "browse" });
882
+ const [statusMsgs, setStatusMsgs] = useState<StatusMsg[]>([]);
883
+
884
+ const termHeight = process.stdout.rows ?? 30;
885
+ const visibleCount = Math.max(4, termHeight - 6);
886
+
887
+ const addStatus = (text: string, ok: boolean) =>
888
+ setStatusMsgs((prev) => [...prev, { id: ++sid, text, ok }]);
889
+
890
+ const reloadCommits = () => {
891
+ const loaded = fetchCommits(repoPath, 300);
892
+ setCommits(loaded);
893
+ setFiltered(loaded);
894
+ setSelectedIdx(0);
895
+ setScrollOffset(0);
896
+ setShowDiff(false);
897
+ };
898
+
899
+ useEffect(() => {
900
+ if (!isGitRepo(repoPath)) {
901
+ setError("Not a git repository.");
902
+ setLoading(false);
903
+ return;
904
+ }
905
+ const loaded = fetchCommits(repoPath, 300);
906
+ if (!loaded.length) {
907
+ setError("No commits found.");
908
+ setLoading(false);
909
+ return;
910
+ }
911
+ setCommits(loaded);
912
+ setFiltered(loaded);
913
+ setLoading(false);
914
+ }, [repoPath]);
915
+
916
+ useEffect(() => {
917
+ if (mode.type !== "search" || !mode.query) {
918
+ setFiltered(commits);
919
+ } else {
920
+ const q = mode.query.toLowerCase();
921
+ setFiltered(
922
+ commits.filter(
923
+ (c) =>
924
+ c.message.toLowerCase().includes(q) ||
925
+ c.author.toLowerCase().includes(q) ||
926
+ c.shortHash.includes(q),
927
+ ),
928
+ );
929
+ }
930
+ setSelectedIdx(0);
931
+ setScrollOffset(0);
932
+ }, [mode, commits]);
933
+
934
+ const selected = filtered[selectedIdx] ?? null;
935
+
936
+ useEffect(() => {
937
+ if (!selected || selected.hash === lastDiffHash) return;
938
+ setDiff([]);
939
+ setDiffScroll(0);
940
+ setLastDiffHash(selected.hash);
941
+ if (showDiff) {
942
+ setDiffLoading(true);
943
+ setTimeout(() => {
944
+ setDiff(fetchDiff(repoPath, selected.hash));
945
+ setDiffLoading(false);
946
+ }, 0);
947
+ }
948
+ }, [selected?.hash]);
949
+
950
+ useEffect(() => {
951
+ if (!showDiff || !selected) return;
952
+ if (selected.hash === lastDiffHash && diff.length) return;
953
+ setDiffLoading(true);
954
+ setLastDiffHash(selected.hash);
955
+ setTimeout(() => {
956
+ setDiff(fetchDiff(repoPath, selected.hash));
957
+ setDiffLoading(false);
958
+ }, 0);
959
+ }, [showDiff]);
960
+
961
+ useInput((input, key) => {
962
+ if (key.ctrl && input === "c") {
963
+ if (onExit) onExit();
964
+ else process.exit(0);
965
+ }
966
+
967
+ // overlays consume all input except ctrl+c
968
+ if (
969
+ mode.type === "ask" ||
970
+ mode.type === "revert" ||
971
+ mode.type === "commit"
972
+ ) {
973
+ if (key.escape) setMode({ type: "browse" });
974
+ return;
975
+ }
976
+
977
+ if (mode.type === "search") {
978
+ if (key.escape) setMode({ type: "browse" });
979
+ return;
980
+ }
981
+
982
+ // diff open
983
+ if (showDiff) {
984
+ if (key.escape || input === "d") {
985
+ setShowDiff(false);
986
+ return;
987
+ }
988
+ if (key.upArrow) {
989
+ setDiffScroll((o) => Math.max(0, o - 1));
990
+ return;
991
+ }
992
+ if (key.downArrow) {
993
+ setDiffScroll((o) => o + 1);
994
+ return;
995
+ }
996
+ if (input === "x" || input === "X") {
997
+ if (selected) setMode({ type: "revert", commit: selected });
998
+ return;
999
+ }
1000
+ return;
1001
+ }
1002
+
1003
+ if (key.escape) {
1004
+ setShowDiff(false);
1005
+ return;
1006
+ }
1007
+ if ((input === "q" || input === "Q") && onExit) {
1008
+ onExit();
1009
+ return;
1010
+ }
1011
+ if (input === "/") {
1012
+ setMode({ type: "search", query: "" });
1013
+ return;
1014
+ }
1015
+ if (input === "?") {
1016
+ setMode({ type: "ask" });
1017
+ return;
1018
+ }
1019
+ if (input === "c" || input === "C") {
1020
+ setMode({ type: "commit" });
1021
+ return;
1022
+ }
1023
+
1024
+ if (key.return && selected) {
1025
+ setShowDiff(true);
1026
+ return;
1027
+ }
1028
+
1029
+ if (input === "x" || input === "X") {
1030
+ if (selected) setMode({ type: "revert", commit: selected });
1031
+ return;
1032
+ }
1033
+
1034
+ if (key.upArrow) {
1035
+ const next = Math.max(0, selectedIdx - 1);
1036
+ setSelectedIdx(next);
1037
+ setShowDiff(false);
1038
+ if (next < scrollOffset) setScrollOffset(next);
1039
+ return;
1040
+ }
1041
+
1042
+ if (key.downArrow) {
1043
+ const next = Math.min(filtered.length - 1, selectedIdx + 1);
1044
+ setSelectedIdx(next);
1045
+ setShowDiff(false);
1046
+ if (next >= scrollOffset + visibleCount)
1047
+ setScrollOffset(next - visibleCount + 1);
1048
+ return;
1049
+ }
1050
+ });
1051
+
1052
+ if (!provider) return <ProviderPicker onDone={setProvider} />;
1053
+ if (loading)
1054
+ return (
1055
+ <Box gap={1} marginTop={1}>
1056
+ <Text color={ACCENT}>*</Text>
1057
+ <Text color="gray">loading commits…</Text>
1058
+ </Box>
1059
+ );
1060
+ if (error)
1061
+ return (
1062
+ <Box gap={1} marginTop={1}>
1063
+ <Text color="red">✗</Text>
1064
+ <Text color="white">{error}</Text>
1065
+ </Box>
1066
+ );
1067
+
1068
+ const w = W();
1069
+ const isSearching = mode.type === "search";
1070
+ const isAsking = mode.type === "ask";
1071
+ const isReverting = mode.type === "revert";
1072
+ const isCommitting = mode.type === "commit";
1073
+ const searchQuery = isSearching ? mode.query : "";
1074
+ const visible = filtered.slice(scrollOffset, scrollOffset + visibleCount);
1075
+
1076
+ const shortcutHint = showDiff
1077
+ ? "↑↓ scroll · x revert · esc/d close"
1078
+ : isSearching
1079
+ ? "type to filter · enter confirm · esc cancel"
1080
+ : isAsking
1081
+ ? "type question · enter send · esc close"
1082
+ : isReverting || isCommitting
1083
+ ? "see prompt above · esc cancel"
1084
+ : `↑↓ navigate · enter diff · x revert · c commit · / search · ? ask${onExit ? " · q back" : " · ^C exit"}`;
1085
+
1086
+ return (
1087
+ <Box flexDirection="column">
1088
+ {/* header */}
1089
+ <Box gap={2} marginBottom={1}>
1090
+ <Text color={ACCENT} bold>
1091
+ ◈ TIMELINE
1092
+ </Text>
1093
+ <Text color="gray" dimColor>
1094
+ {repoPath}
1095
+ </Text>
1096
+ {isSearching && <Text color="yellow">/ {searchQuery || "…"}</Text>}
1097
+ {isSearching && filtered.length !== commits.length && (
1098
+ <Text color="gray" dimColor>
1099
+ {filtered.length} matches
1100
+ </Text>
1101
+ )}
1102
+ </Box>
1103
+
1104
+ {/* status messages (Static — no re-render) */}
1105
+ <Static items={statusMsgs}>
1106
+ {(msg) => (
1107
+ <Box key={msg.id} paddingX={1} gap={1}>
1108
+ <Text color={msg.ok ? "green" : "red"}>{msg.ok ? "✓" : "✗"}</Text>
1109
+ <Text color={msg.ok ? "white" : "red"}>{msg.text}</Text>
1110
+ </Box>
1111
+ )}
1112
+ </Static>
1113
+
1114
+ {/* search bar */}
1115
+ {isSearching && (
1116
+ <Box gap={1} marginBottom={1}>
1117
+ <Text color={ACCENT}>{"/"}</Text>
1118
+ <TextInput
1119
+ value={searchQuery}
1120
+ onChange={(q) => setMode({ type: "search", query: q })}
1121
+ onSubmit={() => setMode({ type: "browse" })}
1122
+ placeholder="filter commits…"
1123
+ />
1124
+ </Box>
1125
+ )}
1126
+
1127
+ {/* commit list */}
1128
+ {visible.map((commit, i) => {
1129
+ const absIdx = scrollOffset + i;
1130
+ const isSel = absIdx === selectedIdx;
1131
+ return (
1132
+ <CommitRow
1133
+ key={commit.hash}
1134
+ commit={commit}
1135
+ index={absIdx}
1136
+ isSelected={isSel}
1137
+ showDiff={isSel && showDiff}
1138
+ diff={isSel ? diff : []}
1139
+ diffScroll={diffScroll}
1140
+ onRevert={() => setMode({ type: "revert", commit })}
1141
+ />
1142
+ );
1143
+ })}
1144
+
1145
+ {(scrollOffset > 0 || scrollOffset + visibleCount < filtered.length) && (
1146
+ <Box gap={3} marginTop={1}>
1147
+ {scrollOffset > 0 && (
1148
+ <Text color="gray" dimColor>
1149
+ ↑ {scrollOffset} above
1150
+ </Text>
1151
+ )}
1152
+ {scrollOffset + visibleCount < filtered.length && (
1153
+ <Text color="gray" dimColor>
1154
+ ↓ {filtered.length - scrollOffset - visibleCount} below
1155
+ </Text>
1156
+ )}
1157
+ </Box>
1158
+ )}
1159
+
1160
+ {/* revert overlay */}
1161
+ {isReverting && mode.type === "revert" && (
1162
+ <RevertConfirm
1163
+ commit={mode.commit}
1164
+ repoPath={repoPath}
1165
+ onDone={(msg) => {
1166
+ setMode({ type: "browse" });
1167
+ if (msg) {
1168
+ addStatus(msg, true);
1169
+ reloadCommits();
1170
+ } else addStatus("revert cancelled", false);
1171
+ }}
1172
+ />
1173
+ )}
1174
+
1175
+ {/* commit overlay */}
1176
+ {isCommitting && provider && (
1177
+ <CommitPanel
1178
+ repoPath={repoPath}
1179
+ provider={provider}
1180
+ onDone={(msg) => {
1181
+ setMode({ type: "browse" });
1182
+ if (msg) {
1183
+ addStatus(`committed: ${trunc(msg, 60)}`, true);
1184
+ reloadCommits();
1185
+ }
1186
+ }}
1187
+ />
1188
+ )}
1189
+
1190
+ {/* ask panel */}
1191
+ {isAsking && provider && (
1192
+ <AskPanel
1193
+ commits={commits}
1194
+ provider={provider}
1195
+ onCommit={() => {
1196
+ setMode({ type: "commit" });
1197
+ }}
1198
+ />
1199
+ )}
1200
+
1201
+ {/* shortcut bar */}
1202
+ <Box marginTop={1}>
1203
+ <Text color="gray" dimColor>
1204
+ {shortcutHint}
1205
+ </Text>
1206
+ </Box>
1207
+ </Box>
1208
+ );
1209
+ }