@ridit/lens 0.3.7 → 0.3.9
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.
- package/dist/index.mjs +105368 -274002
- package/package.json +13 -19
- package/src/colors.ts +15 -15
- package/src/commands/chat.tsx +32 -23
- package/src/commands/provider.tsx +11 -238
- package/src/commands/repo.tsx +66 -120
- package/src/commands/timeline.tsx +11 -22
- package/src/components/ChatView.tsx +238 -0
- package/src/components/Message.tsx +46 -0
- package/src/components/ToolCall.tsx +67 -0
- package/src/components/chat/ChatView.tsx +550 -0
- package/src/components/chat/Message.tsx +152 -0
- package/src/components/chat/StatusBar.tsx +214 -0
- package/src/components/chat/TextArea.tsx +173 -176
- package/src/components/provider/ApiKeyStep.tsx +207 -199
- package/src/components/provider/ModelStep.tsx +90 -88
- package/src/components/provider/ProviderSetup.tsx +331 -0
- package/src/components/provider/ProviderTypeStep.tsx +53 -61
- package/src/components/repo/StepRow.tsx +68 -69
- package/src/components/timeline/TimelineView.tsx +840 -0
- package/src/components/toolcall-utils.ts +103 -0
- package/src/components/watch/RunView.tsx +497 -0
- package/src/hooks/useChatInput.ts +49 -0
- package/src/hooks/useCommandHandler.ts +117 -0
- package/src/index.tsx +386 -139
- package/src/utils/git.ts +149 -155
- package/src/utils/repo.ts +62 -69
- package/src/utils/thinking.tsx +64 -0
- package/src/utils/watch.ts +165 -307
- package/tests/message.test.ts +38 -0
- package/tests/toolcall-utils.test.ts +111 -0
- package/tsconfig.json +8 -24
- package/CLAUDE.md +0 -50
- package/LENS.md +0 -48
- package/LICENSE +0 -21
- package/README.md +0 -93
- package/addons/README.md +0 -55
- package/addons/clean-cache.js +0 -48
- package/addons/generate-readme.js +0 -67
- package/addons/git-stats.js +0 -29
- package/addons/run-tests.js +0 -127
- package/src/commands/commit.tsx +0 -668
- package/src/commands/review.tsx +0 -294
- package/src/commands/run.tsx +0 -56
- package/src/commands/task.tsx +0 -36
- package/src/components/chat/ChatMessage.tsx +0 -195
- package/src/components/chat/ChatOverlays.tsx +0 -399
- package/src/components/chat/ChatRunner.tsx +0 -517
- package/src/components/chat/hooks/useChat.ts +0 -631
- package/src/components/chat/hooks/useChatInput.ts +0 -79
- package/src/components/chat/hooks/useCommandHandlers.ts +0 -327
- package/src/components/provider/ProviderPicker.tsx +0 -76
- package/src/components/provider/RemoveProviderStep.tsx +0 -82
- package/src/components/repo/DiffViewer.tsx +0 -175
- package/src/components/repo/FileReviewer.tsx +0 -70
- package/src/components/repo/FileViewer.tsx +0 -60
- package/src/components/repo/IssueFixer.tsx +0 -666
- package/src/components/repo/LensFileMenu.tsx +0 -115
- package/src/components/repo/NoProviderPrompt.tsx +0 -28
- package/src/components/repo/PreviewRunner.tsx +0 -217
- package/src/components/repo/RepoAnalysis.tsx +0 -534
- package/src/components/task/TaskRunner.tsx +0 -396
- package/src/components/timeline/CommitDetail.tsx +0 -272
- package/src/components/timeline/CommitList.tsx +0 -162
- package/src/components/timeline/TimelineChat.tsx +0 -166
- package/src/components/timeline/TimelineRunner.tsx +0 -1285
- package/src/components/watch/RunRunner.tsx +0 -929
- package/src/prompts/fewshot.ts +0 -252
- package/src/prompts/index.ts +0 -2
- package/src/prompts/system.ts +0 -285
- package/src/tools/chart.ts +0 -202
- package/src/tools/convert-image.ts +0 -312
- package/src/tools/files.ts +0 -253
- package/src/tools/git.ts +0 -603
- package/src/tools/index.ts +0 -17
- package/src/tools/pdf.ts +0 -164
- package/src/tools/shell.ts +0 -96
- package/src/tools/view-image.ts +0 -335
- package/src/tools/web.ts +0 -212
- package/src/types/chat.ts +0 -86
- package/src/types/config.ts +0 -20
- package/src/types/repo.ts +0 -54
- package/src/utils/addons/loadAddons.ts +0 -34
- package/src/utils/ai.ts +0 -321
- package/src/utils/chat.ts +0 -326
- package/src/utils/chatHistory.ts +0 -121
- package/src/utils/config.ts +0 -61
- package/src/utils/files.ts +0 -105
- package/src/utils/intentClassifier.ts +0 -58
- package/src/utils/lensfile.ts +0 -142
- package/src/utils/llm.ts +0 -81
- package/src/utils/memory.ts +0 -209
- package/src/utils/preview.ts +0 -119
- package/src/utils/stats.ts +0 -174
- package/src/utils/tools/builtins.ts +0 -377
- package/src/utils/tools/registry.ts +0 -105
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { Box, Text, Static, useInput, useStdout } from "ink";
|
|
3
|
+
import TextInput from "ink-text-input";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
import {
|
|
6
|
+
fetchCommits,
|
|
7
|
+
fetchDiff,
|
|
8
|
+
isGitRepo,
|
|
9
|
+
summarizeTimeline,
|
|
10
|
+
} from "../../utils/git";
|
|
11
|
+
import type { Commit, DiffFile } from "../../utils/git";
|
|
12
|
+
import { TypewriterText, InputBox } from "../chat/StatusBar";
|
|
13
|
+
import { ACCENT } from "../../colors";
|
|
14
|
+
import {
|
|
15
|
+
chat,
|
|
16
|
+
createSession,
|
|
17
|
+
addMessage,
|
|
18
|
+
getMessages,
|
|
19
|
+
} from "@ridit/lens-core";
|
|
20
|
+
|
|
21
|
+
// ── git runner (for revert) ───────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function gitRun(cmd: string, cwd: string): { ok: boolean; out: string } {
|
|
24
|
+
try {
|
|
25
|
+
const out = execSync(cmd, {
|
|
26
|
+
cwd,
|
|
27
|
+
encoding: "utf-8",
|
|
28
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
29
|
+
timeout: 60_000,
|
|
30
|
+
}).trim();
|
|
31
|
+
return { ok: true, out: out || "(done)" };
|
|
32
|
+
} catch (e: any) {
|
|
33
|
+
const msg =
|
|
34
|
+
[e.stdout, e.stderr].filter(Boolean).join("\n").trim() || e.message;
|
|
35
|
+
return { ok: false, out: msg };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── thinking phrases ──────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const THINKING_PHRASES = [
|
|
42
|
+
"thinking…",
|
|
43
|
+
"reading the repo…",
|
|
44
|
+
"consulting the log…",
|
|
45
|
+
"grepping the history…",
|
|
46
|
+
"diffing the vibes…",
|
|
47
|
+
"sniffing the diff...",
|
|
48
|
+
"reading your crimes...",
|
|
49
|
+
"crafting the perfect commit message...",
|
|
50
|
+
"pretending this was intentional all along...",
|
|
51
|
+
"making it sound like a feature...",
|
|
52
|
+
"turning chaos into conventional commits...",
|
|
53
|
+
"72 chars or bust...",
|
|
54
|
+
"git log will remember this...",
|
|
55
|
+
"committing to the bit. and also the repo...",
|
|
56
|
+
"staging your changes (and your career)...",
|
|
57
|
+
"making main proud...",
|
|
58
|
+
"git blame: not it...",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
function randomPhrase() {
|
|
62
|
+
return THINKING_PHRASES[Math.floor(Math.random() * THINKING_PHRASES.length)]!;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function shortDate(d: string) {
|
|
68
|
+
try {
|
|
69
|
+
return new Date(d).toLocaleDateString("en-US", {
|
|
70
|
+
month: "short",
|
|
71
|
+
day: "numeric",
|
|
72
|
+
year: "2-digit",
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
return d.slice(0, 10);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function trunc(s: string, n: number) {
|
|
80
|
+
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bar(ins: number, del: number): string {
|
|
84
|
+
const total = ins + del;
|
|
85
|
+
if (!total) return "";
|
|
86
|
+
const w = 10;
|
|
87
|
+
const addW = Math.round((ins / total) * w);
|
|
88
|
+
return "+" + "█".repeat(addW) + "░".repeat(w - addW) + "-";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getToolLabel(tool: string, args: unknown): string {
|
|
92
|
+
if (!args || typeof args !== "object") return tool;
|
|
93
|
+
const a = args as Record<string, unknown>;
|
|
94
|
+
switch (tool) {
|
|
95
|
+
case "read": return String(a.path ?? a.file_path ?? "");
|
|
96
|
+
case "write": return String(a.path ?? a.file_path ?? a.filename ?? "");
|
|
97
|
+
case "bash": return String(a.command ?? a.cmd ?? "");
|
|
98
|
+
case "grep": {
|
|
99
|
+
const p = String(a.pattern ?? "");
|
|
100
|
+
const g = String(a.glob ?? "");
|
|
101
|
+
return g ? `${p} ${g}` : p;
|
|
102
|
+
}
|
|
103
|
+
case "ls": return String(a.path ?? ".");
|
|
104
|
+
default: return tool;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const W = () => process.stdout.columns ?? 100;
|
|
109
|
+
|
|
110
|
+
// ── CommitRow ─────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function CommitRow({
|
|
113
|
+
commit,
|
|
114
|
+
index,
|
|
115
|
+
isSelected,
|
|
116
|
+
showDiff,
|
|
117
|
+
diff,
|
|
118
|
+
diffScroll,
|
|
119
|
+
onRevert,
|
|
120
|
+
}: {
|
|
121
|
+
commit: Commit;
|
|
122
|
+
index: number;
|
|
123
|
+
isSelected: boolean;
|
|
124
|
+
showDiff: boolean;
|
|
125
|
+
diff: DiffFile[];
|
|
126
|
+
diffScroll: number;
|
|
127
|
+
onRevert: () => void;
|
|
128
|
+
}) {
|
|
129
|
+
const w = W();
|
|
130
|
+
const isMerge = commit.parents.length > 1;
|
|
131
|
+
const node = isMerge ? "⎇" : index === 0 ? "◉" : "●";
|
|
132
|
+
|
|
133
|
+
const refLabels = commit.refs
|
|
134
|
+
.split(",")
|
|
135
|
+
.map((r) => r.trim())
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.map((r) =>
|
|
138
|
+
r.startsWith("HEAD -> ")
|
|
139
|
+
? r.slice(8)
|
|
140
|
+
: r.startsWith("tag: ")
|
|
141
|
+
? `v${r.slice(5)}`
|
|
142
|
+
: r,
|
|
143
|
+
)
|
|
144
|
+
.slice(0, 2);
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<Box flexDirection="column">
|
|
148
|
+
<Box gap={1}>
|
|
149
|
+
<Text color={isSelected ? ACCENT : "gray"}>
|
|
150
|
+
{isSelected ? "▶" : " "}
|
|
151
|
+
</Text>
|
|
152
|
+
<Text color={isSelected ? ACCENT : isMerge ? "magenta" : "gray"}>
|
|
153
|
+
{node}
|
|
154
|
+
</Text>
|
|
155
|
+
<Text color="gray" dimColor={!isSelected}>
|
|
156
|
+
{commit.shortHash}
|
|
157
|
+
</Text>
|
|
158
|
+
<Text color="cyan" dimColor={!isSelected}>
|
|
159
|
+
{shortDate(commit.date)}
|
|
160
|
+
</Text>
|
|
161
|
+
{refLabels.map((r) => (
|
|
162
|
+
<Text key={r} color="yellow">
|
|
163
|
+
[{r}]
|
|
164
|
+
</Text>
|
|
165
|
+
))}
|
|
166
|
+
<Text
|
|
167
|
+
color={isSelected ? "white" : "gray"}
|
|
168
|
+
bold={isSelected}
|
|
169
|
+
wrap="truncate"
|
|
170
|
+
>
|
|
171
|
+
{trunc(commit.message, w - 36)}
|
|
172
|
+
</Text>
|
|
173
|
+
</Box>
|
|
174
|
+
|
|
175
|
+
{isSelected && !showDiff && (
|
|
176
|
+
<Box flexDirection="column" marginLeft={4} marginBottom={1}>
|
|
177
|
+
<Box gap={2}>
|
|
178
|
+
<Text color="gray" dimColor>
|
|
179
|
+
{commit.author}
|
|
180
|
+
</Text>
|
|
181
|
+
<Text color="gray" dimColor>
|
|
182
|
+
{commit.relativeDate}
|
|
183
|
+
</Text>
|
|
184
|
+
{commit.filesChanged > 0 && (
|
|
185
|
+
<>
|
|
186
|
+
<Text color="green">+{commit.insertions}</Text>
|
|
187
|
+
<Text color="red">-{commit.deletions}</Text>
|
|
188
|
+
<Text color="gray" dimColor>
|
|
189
|
+
{commit.filesChanged} file{commit.filesChanged !== 1 ? "s" : ""}
|
|
190
|
+
</Text>
|
|
191
|
+
<Text color="gray" dimColor>
|
|
192
|
+
{bar(commit.insertions, commit.deletions)}
|
|
193
|
+
</Text>
|
|
194
|
+
</>
|
|
195
|
+
)}
|
|
196
|
+
</Box>
|
|
197
|
+
{commit.body ? (
|
|
198
|
+
<Text color="gray" dimColor wrap="wrap">
|
|
199
|
+
{trunc(commit.body, w - 8)}
|
|
200
|
+
</Text>
|
|
201
|
+
) : null}
|
|
202
|
+
<Box gap={3} marginTop={1}>
|
|
203
|
+
<Text color="gray" dimColor>enter diff</Text>
|
|
204
|
+
<Text color="red" dimColor>x revert</Text>
|
|
205
|
+
</Box>
|
|
206
|
+
</Box>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{isSelected && showDiff && (
|
|
210
|
+
<Box flexDirection="column" marginLeft={2} marginBottom={1}>
|
|
211
|
+
<Box gap={3} marginBottom={1}>
|
|
212
|
+
<Text color={ACCENT} bold>DIFF</Text>
|
|
213
|
+
<Text color="gray" dimColor>
|
|
214
|
+
{commit.shortHash} — {trunc(commit.message, 50)}
|
|
215
|
+
</Text>
|
|
216
|
+
<Text color="red" dimColor>x revert</Text>
|
|
217
|
+
<Text color="gray" dimColor>esc close</Text>
|
|
218
|
+
</Box>
|
|
219
|
+
<DiffPanel
|
|
220
|
+
files={diff}
|
|
221
|
+
scrollOffset={diffScroll}
|
|
222
|
+
maxLines={Math.max(8, (process.stdout.rows ?? 30) - 12)}
|
|
223
|
+
/>
|
|
224
|
+
<Text color="gray" dimColor>↑↓ scroll · esc close</Text>
|
|
225
|
+
</Box>
|
|
226
|
+
)}
|
|
227
|
+
</Box>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── DiffPanel ─────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
function DiffPanel({
|
|
234
|
+
files,
|
|
235
|
+
scrollOffset,
|
|
236
|
+
maxLines,
|
|
237
|
+
}: {
|
|
238
|
+
files: DiffFile[];
|
|
239
|
+
scrollOffset: number;
|
|
240
|
+
maxLines: number;
|
|
241
|
+
}) {
|
|
242
|
+
const w = W() - 6;
|
|
243
|
+
|
|
244
|
+
type RLine =
|
|
245
|
+
| { k: "file"; path: string; ins: number; del: number; status: DiffFile["status"] }
|
|
246
|
+
| { k: "hunk" | "add" | "rem" | "ctx"; content: string };
|
|
247
|
+
|
|
248
|
+
const all: RLine[] = [];
|
|
249
|
+
for (const f of files) {
|
|
250
|
+
const icon =
|
|
251
|
+
f.status === "added" ? "+" :
|
|
252
|
+
f.status === "deleted" ? "-" :
|
|
253
|
+
f.status === "renamed" ? "→" : "~";
|
|
254
|
+
all.push({ k: "file", path: `${icon} ${f.path}`, ins: f.insertions, del: f.deletions, status: f.status });
|
|
255
|
+
for (const l of f.lines) {
|
|
256
|
+
if (l.type === "header") all.push({ k: "hunk", content: l.content });
|
|
257
|
+
else if (l.type === "add") all.push({ k: "add", content: l.content });
|
|
258
|
+
else if (l.type === "remove") all.push({ k: "rem", content: l.content });
|
|
259
|
+
else all.push({ k: "ctx", content: l.content });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!all.length)
|
|
264
|
+
return <Text color="gray" dimColor>{" "}no diff available</Text>;
|
|
265
|
+
|
|
266
|
+
const visible = all.slice(scrollOffset, scrollOffset + maxLines);
|
|
267
|
+
const hasMore = all.length > scrollOffset + maxLines;
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<Box flexDirection="column">
|
|
271
|
+
{visible.map((line, i) => {
|
|
272
|
+
if (line.k === "file") {
|
|
273
|
+
const color =
|
|
274
|
+
line.status === "added" ? "green" :
|
|
275
|
+
line.status === "deleted" ? "red" :
|
|
276
|
+
line.status === "renamed" ? "yellow" : "cyan";
|
|
277
|
+
return (
|
|
278
|
+
<Box key={i} gap={2} marginTop={i > 0 ? 1 : 0}>
|
|
279
|
+
<Text color={color} bold>{trunc(line.path, w)}</Text>
|
|
280
|
+
<Text color="green">+{line.ins}</Text>
|
|
281
|
+
<Text color="red">-{line.del}</Text>
|
|
282
|
+
</Box>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (line.k === "hunk")
|
|
286
|
+
return <Text key={i} color="cyan" dimColor>{trunc(line.content, w)}</Text>;
|
|
287
|
+
if (line.k === "add")
|
|
288
|
+
return <Text key={i} color="green">{"+"}{trunc(line.content, w - 1)}</Text>;
|
|
289
|
+
if (line.k === "rem")
|
|
290
|
+
return <Text key={i} color="red">{"-"}{trunc(line.content, w - 1)}</Text>;
|
|
291
|
+
return <Text key={i} color="gray" dimColor>{" "}{trunc(line.content, w - 1)}</Text>;
|
|
292
|
+
})}
|
|
293
|
+
{hasMore && (
|
|
294
|
+
<Text color="gray" dimColor>
|
|
295
|
+
{" "}… {all.length - scrollOffset - maxLines} more lines
|
|
296
|
+
</Text>
|
|
297
|
+
)}
|
|
298
|
+
</Box>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── RevertConfirm ─────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
function RevertConfirm({
|
|
305
|
+
commit,
|
|
306
|
+
repoPath,
|
|
307
|
+
onDone,
|
|
308
|
+
}: {
|
|
309
|
+
commit: Commit;
|
|
310
|
+
repoPath: string;
|
|
311
|
+
onDone: (msg: string | null) => void;
|
|
312
|
+
}) {
|
|
313
|
+
const [status, setStatus] = useState<"confirm" | "running" | "done">("confirm");
|
|
314
|
+
const [result, setResult] = useState("");
|
|
315
|
+
|
|
316
|
+
useInput((input, key) => {
|
|
317
|
+
if (status !== "confirm") return;
|
|
318
|
+
if (input === "y" || input === "Y" || key.return) {
|
|
319
|
+
setStatus("running");
|
|
320
|
+
const r = gitRun(`git revert --no-edit "${commit.hash}"`, repoPath);
|
|
321
|
+
setResult(r.out);
|
|
322
|
+
setStatus("done");
|
|
323
|
+
setTimeout(() => onDone(r.ok ? `Reverted ${commit.shortHash}` : null), 1200);
|
|
324
|
+
}
|
|
325
|
+
if (input === "n" || input === "N" || key.escape) onDone(null);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const w = W();
|
|
329
|
+
return (
|
|
330
|
+
<Box flexDirection="column" marginTop={1}>
|
|
331
|
+
<Text color="gray" dimColor>{"─".repeat(w)}</Text>
|
|
332
|
+
{status === "confirm" && (
|
|
333
|
+
<Box flexDirection="column" paddingX={1} gap={1}>
|
|
334
|
+
<Box gap={1}>
|
|
335
|
+
<Text color="red">!</Text>
|
|
336
|
+
<Text color="white">revert </Text>
|
|
337
|
+
<Text color={ACCENT}>{commit.shortHash}</Text>
|
|
338
|
+
<Text color="gray" dimColor>— {trunc(commit.message, 50)}</Text>
|
|
339
|
+
</Box>
|
|
340
|
+
<Text color="gray" dimColor> this creates a new "revert" commit — git history is preserved</Text>
|
|
341
|
+
<Box gap={2} marginTop={1}>
|
|
342
|
+
<Text color="green">y/enter confirm</Text>
|
|
343
|
+
<Text color="gray" dimColor>n/esc cancel</Text>
|
|
344
|
+
</Box>
|
|
345
|
+
</Box>
|
|
346
|
+
)}
|
|
347
|
+
{status === "running" && (
|
|
348
|
+
<Box paddingX={1} gap={1}>
|
|
349
|
+
<Text color={ACCENT}>*</Text>
|
|
350
|
+
<Text color="gray" dimColor>reverting…</Text>
|
|
351
|
+
</Box>
|
|
352
|
+
)}
|
|
353
|
+
{status === "done" && (
|
|
354
|
+
<Box paddingX={1} gap={1}>
|
|
355
|
+
<Text color={result.startsWith("Error") ? "red" : "green"}>
|
|
356
|
+
{result.startsWith("Error") ? "✗" : "✓"}
|
|
357
|
+
</Text>
|
|
358
|
+
<Text color="white" wrap="wrap">{trunc(result, W() - 6)}</Text>
|
|
359
|
+
</Box>
|
|
360
|
+
)}
|
|
361
|
+
</Box>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── AskPanel (powered by chat() from core) ────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
type AskMsg =
|
|
368
|
+
| { kind: "user"; content: string }
|
|
369
|
+
| { kind: "assistant"; content: string }
|
|
370
|
+
| { kind: "tool"; toolName: string; label: string; result?: string; approved?: boolean };
|
|
371
|
+
|
|
372
|
+
function AskPanel({
|
|
373
|
+
commits,
|
|
374
|
+
repoPath,
|
|
375
|
+
onReload,
|
|
376
|
+
}: {
|
|
377
|
+
commits: Commit[];
|
|
378
|
+
repoPath: string;
|
|
379
|
+
onReload: () => void;
|
|
380
|
+
}) {
|
|
381
|
+
const [messages, setMessages] = useState<AskMsg[]>([]);
|
|
382
|
+
const [input, setInput] = useState("");
|
|
383
|
+
const [thinking, setThinking] = useState(false);
|
|
384
|
+
const [currentChunk, setCurrentChunk] = useState("");
|
|
385
|
+
const [phrase, setPhrase] = useState(randomPhrase);
|
|
386
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
387
|
+
const sessionRef = useRef(createSession(repoPath));
|
|
388
|
+
const pendingToolRef = useRef<{ tool: string; args: unknown } | null>(null);
|
|
389
|
+
const { stdout } = useStdout();
|
|
390
|
+
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
if (!thinking) return;
|
|
393
|
+
setPhrase(randomPhrase());
|
|
394
|
+
const id = setInterval(() => setPhrase(randomPhrase()), 3200);
|
|
395
|
+
return () => clearInterval(id);
|
|
396
|
+
}, [thinking]);
|
|
397
|
+
|
|
398
|
+
const systemPrompt = `You are a git assistant embedded in a terminal timeline viewer.
|
|
399
|
+
Repository: ${repoPath}
|
|
400
|
+
|
|
401
|
+
You have access to tools to answer questions and perform git operations.
|
|
402
|
+
Use the bash tool to run git commands when you need live data.
|
|
403
|
+
|
|
404
|
+
Rules:
|
|
405
|
+
- Use read tools freely to answer questions requiring live data
|
|
406
|
+
- For write operations briefly explain what you are about to do
|
|
407
|
+
- Be concise, plain text only
|
|
408
|
+
|
|
409
|
+
Timeline summary (last 300 commits):
|
|
410
|
+
${summarizeTimeline(commits)}`;
|
|
411
|
+
|
|
412
|
+
const ask = async (q: string) => {
|
|
413
|
+
if (!q.trim() || thinking) return;
|
|
414
|
+
|
|
415
|
+
setThinking(true);
|
|
416
|
+
setCurrentChunk("");
|
|
417
|
+
setMessages((prev) => [...prev, { kind: "user", content: q }]);
|
|
418
|
+
sessionRef.current = addMessage(sessionRef.current, "user", q);
|
|
419
|
+
|
|
420
|
+
const abort = new AbortController();
|
|
421
|
+
abortRef.current = abort;
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
await chat({
|
|
425
|
+
messages: getMessages(sessionRef.current),
|
|
426
|
+
system: systemPrompt,
|
|
427
|
+
onChunk: (chunk) => {
|
|
428
|
+
if (!abort.signal.aborted) {
|
|
429
|
+
setCurrentChunk((prev) => prev + chunk);
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
onToolCall: (tool, args) => {
|
|
433
|
+
if (!abort.signal.aborted) {
|
|
434
|
+
pendingToolRef.current = { tool, args };
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
onToolResult: (tool, result) => {
|
|
438
|
+
if (!abort.signal.aborted && pendingToolRef.current) {
|
|
439
|
+
const { tool: t, args } = pendingToolRef.current;
|
|
440
|
+
const label = getToolLabel(t, args);
|
|
441
|
+
const resultStr = typeof result === "string" ? result : JSON.stringify(result);
|
|
442
|
+
setMessages((prev) => [
|
|
443
|
+
...prev,
|
|
444
|
+
{ kind: "tool", toolName: t, label, result: resultStr, approved: true },
|
|
445
|
+
]);
|
|
446
|
+
pendingToolRef.current = null;
|
|
447
|
+
onReload();
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
onFinish: (text) => {
|
|
451
|
+
if (!abort.signal.aborted) {
|
|
452
|
+
setMessages((prev) => [...prev, { kind: "assistant", content: text }]);
|
|
453
|
+
sessionRef.current = addMessage(sessionRef.current, "assistant", text);
|
|
454
|
+
}
|
|
455
|
+
setCurrentChunk("");
|
|
456
|
+
setThinking(false);
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
} catch (err) {
|
|
460
|
+
if (!abort.signal.aborted) {
|
|
461
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
462
|
+
setMessages((prev) => [...prev, { kind: "assistant", content: `Error: ${msg}` }]);
|
|
463
|
+
}
|
|
464
|
+
setCurrentChunk("");
|
|
465
|
+
setThinking(false);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
useInput((inp, key) => {
|
|
470
|
+
if (key.escape && thinking) {
|
|
471
|
+
abortRef.current?.abort();
|
|
472
|
+
setCurrentChunk("");
|
|
473
|
+
setThinking(false);
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const w = W();
|
|
478
|
+
const isBusy = thinking;
|
|
479
|
+
|
|
480
|
+
return (
|
|
481
|
+
<Box flexDirection="column" marginTop={1}>
|
|
482
|
+
<Text color="gray" dimColor>{"─".repeat(w)}</Text>
|
|
483
|
+
|
|
484
|
+
<Box paddingX={1} marginBottom={1} gap={2}>
|
|
485
|
+
<Text color={ACCENT} bold>ASK</Text>
|
|
486
|
+
<Text color="gray" dimColor>git tools available · esc cancel</Text>
|
|
487
|
+
</Box>
|
|
488
|
+
|
|
489
|
+
{messages.map((msg, i) => {
|
|
490
|
+
if (msg.kind === "tool") {
|
|
491
|
+
const isError = msg.result?.startsWith("Error");
|
|
492
|
+
return (
|
|
493
|
+
<Box key={i} flexDirection="column" marginBottom={1}>
|
|
494
|
+
<Box gap={1}>
|
|
495
|
+
<Text color={ACCENT}>$</Text>
|
|
496
|
+
<Text color="gray" dimColor>{trunc(msg.label, w - 4)}</Text>
|
|
497
|
+
</Box>
|
|
498
|
+
{msg.result && (
|
|
499
|
+
<Box marginLeft={2}>
|
|
500
|
+
<Text color={isError ? "red" : "gray"} dimColor={!isError}>
|
|
501
|
+
{trunc(msg.result.split("\n")[0]!, w - 6)}
|
|
502
|
+
</Text>
|
|
503
|
+
</Box>
|
|
504
|
+
)}
|
|
505
|
+
</Box>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (msg.kind === "user") {
|
|
510
|
+
return (
|
|
511
|
+
<Box key={i} marginBottom={1} gap={1} paddingLeft={1} paddingRight={2}>
|
|
512
|
+
<Text color="gray">{">"}</Text>
|
|
513
|
+
<Text color="white" bold>{msg.content}</Text>
|
|
514
|
+
</Box>
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return (
|
|
519
|
+
<Box key={i} marginBottom={1} gap={1}>
|
|
520
|
+
<Text color={ACCENT}>●</Text>
|
|
521
|
+
<Text color="white" wrap="wrap">{msg.content}</Text>
|
|
522
|
+
</Box>
|
|
523
|
+
);
|
|
524
|
+
})}
|
|
525
|
+
|
|
526
|
+
{thinking && (
|
|
527
|
+
<Box gap={1} marginBottom={1}>
|
|
528
|
+
<Text color={ACCENT}>●</Text>
|
|
529
|
+
{currentChunk ? (
|
|
530
|
+
<Text color="white" wrap="wrap">{currentChunk}</Text>
|
|
531
|
+
) : (
|
|
532
|
+
<TypewriterText key={phrase} text={phrase} />
|
|
533
|
+
)}
|
|
534
|
+
</Box>
|
|
535
|
+
)}
|
|
536
|
+
|
|
537
|
+
<InputBox
|
|
538
|
+
value={input}
|
|
539
|
+
onChange={setInput}
|
|
540
|
+
onSubmit={(v) => {
|
|
541
|
+
if (v.trim()) ask(v.trim());
|
|
542
|
+
setInput("");
|
|
543
|
+
}}
|
|
544
|
+
inputKey={isBusy ? 1 : 0}
|
|
545
|
+
/>
|
|
546
|
+
</Box>
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ── TimelineRunner ────────────────────────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
type UIMode =
|
|
553
|
+
| { type: "browse" }
|
|
554
|
+
| { type: "search"; query: string }
|
|
555
|
+
| { type: "ask" }
|
|
556
|
+
| { type: "revert"; commit: Commit };
|
|
557
|
+
|
|
558
|
+
type StatusMsg = { id: number; text: string; ok: boolean };
|
|
559
|
+
let sid = 0;
|
|
560
|
+
|
|
561
|
+
export function TimelineRunner({
|
|
562
|
+
repoPath,
|
|
563
|
+
onExit,
|
|
564
|
+
}: {
|
|
565
|
+
repoPath: string;
|
|
566
|
+
onExit?: () => void;
|
|
567
|
+
}) {
|
|
568
|
+
const [commits, setCommits] = useState<Commit[]>([]);
|
|
569
|
+
const [filtered, setFiltered] = useState<Commit[]>([]);
|
|
570
|
+
const [loading, setLoading] = useState(true);
|
|
571
|
+
const [error, setError] = useState<string | null>(null);
|
|
572
|
+
|
|
573
|
+
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
574
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
575
|
+
const [showDiff, setShowDiff] = useState(false);
|
|
576
|
+
const [diff, setDiff] = useState<DiffFile[]>([]);
|
|
577
|
+
const [diffLoading, setDiffLoading] = useState(false);
|
|
578
|
+
const [diffScroll, setDiffScroll] = useState(0);
|
|
579
|
+
const [lastDiffHash, setLastDiffHash] = useState<string | null>(null);
|
|
580
|
+
|
|
581
|
+
const [mode, setMode] = useState<UIMode>({ type: "browse" });
|
|
582
|
+
const [statusMsgs, setStatusMsgs] = useState<StatusMsg[]>([]);
|
|
583
|
+
|
|
584
|
+
const termHeight = process.stdout.rows ?? 30;
|
|
585
|
+
const visibleCount = Math.max(4, termHeight - 6);
|
|
586
|
+
|
|
587
|
+
const addStatus = (text: string, ok: boolean) =>
|
|
588
|
+
setStatusMsgs((prev) => [...prev, { id: ++sid, text, ok }]);
|
|
589
|
+
|
|
590
|
+
const reloadCommits = () => {
|
|
591
|
+
const loaded = fetchCommits(repoPath, 300);
|
|
592
|
+
setCommits(loaded);
|
|
593
|
+
setFiltered(loaded);
|
|
594
|
+
setSelectedIdx(0);
|
|
595
|
+
setScrollOffset(0);
|
|
596
|
+
setShowDiff(false);
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
useEffect(() => {
|
|
600
|
+
if (!isGitRepo(repoPath)) {
|
|
601
|
+
setError("Not a git repository.");
|
|
602
|
+
setLoading(false);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const loaded = fetchCommits(repoPath, 300);
|
|
606
|
+
if (!loaded.length) {
|
|
607
|
+
setError("No commits found.");
|
|
608
|
+
setLoading(false);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
setCommits(loaded);
|
|
612
|
+
setFiltered(loaded);
|
|
613
|
+
setLoading(false);
|
|
614
|
+
}, [repoPath]);
|
|
615
|
+
|
|
616
|
+
useEffect(() => {
|
|
617
|
+
if (mode.type !== "search" || !mode.query) {
|
|
618
|
+
setFiltered(commits);
|
|
619
|
+
} else {
|
|
620
|
+
const q = mode.query.toLowerCase();
|
|
621
|
+
setFiltered(
|
|
622
|
+
commits.filter(
|
|
623
|
+
(c) =>
|
|
624
|
+
c.message.toLowerCase().includes(q) ||
|
|
625
|
+
c.author.toLowerCase().includes(q) ||
|
|
626
|
+
c.shortHash.includes(q),
|
|
627
|
+
),
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
setSelectedIdx(0);
|
|
631
|
+
setScrollOffset(0);
|
|
632
|
+
}, [mode, commits]);
|
|
633
|
+
|
|
634
|
+
const selected = filtered[selectedIdx] ?? null;
|
|
635
|
+
|
|
636
|
+
useEffect(() => {
|
|
637
|
+
if (!selected || selected.hash === lastDiffHash) return;
|
|
638
|
+
setDiff([]);
|
|
639
|
+
setDiffScroll(0);
|
|
640
|
+
setLastDiffHash(selected.hash);
|
|
641
|
+
if (showDiff) {
|
|
642
|
+
setDiffLoading(true);
|
|
643
|
+
setTimeout(() => {
|
|
644
|
+
setDiff(fetchDiff(repoPath, selected.hash));
|
|
645
|
+
setDiffLoading(false);
|
|
646
|
+
}, 0);
|
|
647
|
+
}
|
|
648
|
+
}, [selected?.hash]);
|
|
649
|
+
|
|
650
|
+
useEffect(() => {
|
|
651
|
+
if (!showDiff || !selected) return;
|
|
652
|
+
if (selected.hash === lastDiffHash && diff.length) return;
|
|
653
|
+
setDiffLoading(true);
|
|
654
|
+
setLastDiffHash(selected.hash);
|
|
655
|
+
setTimeout(() => {
|
|
656
|
+
setDiff(fetchDiff(repoPath, selected.hash));
|
|
657
|
+
setDiffLoading(false);
|
|
658
|
+
}, 0);
|
|
659
|
+
}, [showDiff]);
|
|
660
|
+
|
|
661
|
+
useInput((input, key) => {
|
|
662
|
+
if (key.ctrl && input === "c") {
|
|
663
|
+
if (onExit) onExit();
|
|
664
|
+
else process.exit(0);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (mode.type === "ask" || mode.type === "revert") {
|
|
668
|
+
if (key.escape) setMode({ type: "browse" });
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (mode.type === "search") {
|
|
673
|
+
if (key.escape) setMode({ type: "browse" });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (showDiff) {
|
|
678
|
+
if (key.escape || input === "d") { setShowDiff(false); return; }
|
|
679
|
+
if (key.upArrow) { setDiffScroll((o) => Math.max(0, o - 1)); return; }
|
|
680
|
+
if (key.downArrow) { setDiffScroll((o) => o + 1); return; }
|
|
681
|
+
if (input === "x" || input === "X") {
|
|
682
|
+
if (selected) setMode({ type: "revert", commit: selected });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (key.escape) { setShowDiff(false); return; }
|
|
689
|
+
if ((input === "q" || input === "Q") && onExit) { onExit(); return; }
|
|
690
|
+
if (input === "/") { setMode({ type: "search", query: "" }); return; }
|
|
691
|
+
if (input === "?" || input === "a" || input === "A") { setMode({ type: "ask" }); return; }
|
|
692
|
+
if (key.return && selected) { setShowDiff(true); return; }
|
|
693
|
+
if (input === "x" || input === "X") {
|
|
694
|
+
if (selected) setMode({ type: "revert", commit: selected });
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (key.upArrow) {
|
|
698
|
+
const next = Math.max(0, selectedIdx - 1);
|
|
699
|
+
setSelectedIdx(next);
|
|
700
|
+
setShowDiff(false);
|
|
701
|
+
if (next < scrollOffset) setScrollOffset(next);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (key.downArrow) {
|
|
705
|
+
const next = Math.min(filtered.length - 1, selectedIdx + 1);
|
|
706
|
+
setSelectedIdx(next);
|
|
707
|
+
setShowDiff(false);
|
|
708
|
+
if (next >= scrollOffset + visibleCount) setScrollOffset(next - visibleCount + 1);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
if (loading)
|
|
714
|
+
return (
|
|
715
|
+
<Box gap={1} marginTop={1}>
|
|
716
|
+
<Text color={ACCENT}>*</Text>
|
|
717
|
+
<Text color="gray">loading commits…</Text>
|
|
718
|
+
</Box>
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
if (error)
|
|
722
|
+
return (
|
|
723
|
+
<Box gap={1} marginTop={1}>
|
|
724
|
+
<Text color="red">✗</Text>
|
|
725
|
+
<Text color="white">{error}</Text>
|
|
726
|
+
</Box>
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
const w = W();
|
|
730
|
+
const isSearching = mode.type === "search";
|
|
731
|
+
const isAsking = mode.type === "ask";
|
|
732
|
+
const isReverting = mode.type === "revert";
|
|
733
|
+
const searchQuery = isSearching ? (mode as { type: "search"; query: string }).query : "";
|
|
734
|
+
const visible = filtered.slice(scrollOffset, scrollOffset + visibleCount);
|
|
735
|
+
|
|
736
|
+
const shortcutHint = showDiff
|
|
737
|
+
? "↑↓ scroll · x revert · esc/d close"
|
|
738
|
+
: isSearching
|
|
739
|
+
? "type to filter · enter confirm · esc cancel"
|
|
740
|
+
: isAsking
|
|
741
|
+
? "ask anything · git tools available · esc back"
|
|
742
|
+
: isReverting
|
|
743
|
+
? "y confirm · n/esc cancel"
|
|
744
|
+
: `↑↓ navigate · enter diff · x revert · a ask · / search${onExit ? " · q back" : " · ^C exit"}`;
|
|
745
|
+
|
|
746
|
+
return (
|
|
747
|
+
<Box flexDirection="column">
|
|
748
|
+
<Box gap={2} marginBottom={1}>
|
|
749
|
+
<Text color={ACCENT} bold>◈ TIMELINE</Text>
|
|
750
|
+
<Text color="gray" dimColor>{repoPath}</Text>
|
|
751
|
+
{isSearching && <Text color="yellow">/ {searchQuery || "…"}</Text>}
|
|
752
|
+
{isSearching && filtered.length !== commits.length && (
|
|
753
|
+
<Text color="gray" dimColor>{filtered.length} matches</Text>
|
|
754
|
+
)}
|
|
755
|
+
</Box>
|
|
756
|
+
|
|
757
|
+
<Static items={statusMsgs}>
|
|
758
|
+
{(msg) => (
|
|
759
|
+
<Box key={msg.id} paddingX={1} gap={1}>
|
|
760
|
+
<Text color={msg.ok ? "green" : "red"}>{msg.ok ? "✓" : "✗"}</Text>
|
|
761
|
+
<Text color={msg.ok ? "white" : "red"}>{msg.text}</Text>
|
|
762
|
+
</Box>
|
|
763
|
+
)}
|
|
764
|
+
</Static>
|
|
765
|
+
|
|
766
|
+
{isSearching && (
|
|
767
|
+
<Box gap={1} marginBottom={1}>
|
|
768
|
+
<Text color={ACCENT}>{"/"}</Text>
|
|
769
|
+
<TextInput
|
|
770
|
+
value={searchQuery}
|
|
771
|
+
onChange={(q) => setMode({ type: "search", query: q })}
|
|
772
|
+
onSubmit={() => setMode({ type: "browse" })}
|
|
773
|
+
placeholder="filter commits…"
|
|
774
|
+
/>
|
|
775
|
+
</Box>
|
|
776
|
+
)}
|
|
777
|
+
|
|
778
|
+
{visible.map((commit, i) => {
|
|
779
|
+
const absIdx = scrollOffset + i;
|
|
780
|
+
const isSel = absIdx === selectedIdx;
|
|
781
|
+
return (
|
|
782
|
+
<CommitRow
|
|
783
|
+
key={commit.hash}
|
|
784
|
+
commit={commit}
|
|
785
|
+
index={absIdx}
|
|
786
|
+
isSelected={isSel}
|
|
787
|
+
showDiff={isSel && showDiff}
|
|
788
|
+
diff={isSel ? diff : []}
|
|
789
|
+
diffScroll={diffScroll}
|
|
790
|
+
onRevert={() => setMode({ type: "revert", commit })}
|
|
791
|
+
/>
|
|
792
|
+
);
|
|
793
|
+
})}
|
|
794
|
+
|
|
795
|
+
{(scrollOffset > 0 || scrollOffset + visibleCount < filtered.length) && (
|
|
796
|
+
<Box gap={3} marginTop={1}>
|
|
797
|
+
{scrollOffset > 0 && (
|
|
798
|
+
<Text color="gray" dimColor>↑ {scrollOffset} above</Text>
|
|
799
|
+
)}
|
|
800
|
+
{scrollOffset + visibleCount < filtered.length && (
|
|
801
|
+
<Text color="gray" dimColor>
|
|
802
|
+
↓ {filtered.length - scrollOffset - visibleCount} below
|
|
803
|
+
</Text>
|
|
804
|
+
)}
|
|
805
|
+
</Box>
|
|
806
|
+
)}
|
|
807
|
+
|
|
808
|
+
{isReverting && mode.type === "revert" && (
|
|
809
|
+
<RevertConfirm
|
|
810
|
+
commit={mode.commit}
|
|
811
|
+
repoPath={repoPath}
|
|
812
|
+
onDone={(msg) => {
|
|
813
|
+
setMode({ type: "browse" });
|
|
814
|
+
if (msg) {
|
|
815
|
+
addStatus(msg, true);
|
|
816
|
+
reloadCommits();
|
|
817
|
+
} else {
|
|
818
|
+
addStatus("revert cancelled", false);
|
|
819
|
+
}
|
|
820
|
+
}}
|
|
821
|
+
/>
|
|
822
|
+
)}
|
|
823
|
+
|
|
824
|
+
{isAsking && (
|
|
825
|
+
<AskPanel
|
|
826
|
+
commits={commits}
|
|
827
|
+
repoPath={repoPath}
|
|
828
|
+
onReload={() => {
|
|
829
|
+
reloadCommits();
|
|
830
|
+
addStatus("commits reloaded", true);
|
|
831
|
+
}}
|
|
832
|
+
/>
|
|
833
|
+
)}
|
|
834
|
+
|
|
835
|
+
<Box marginTop={1}>
|
|
836
|
+
<Text color="gray" dimColor>{shortcutHint}</Text>
|
|
837
|
+
</Box>
|
|
838
|
+
</Box>
|
|
839
|
+
);
|
|
840
|
+
}
|