@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
|
@@ -1,1285 +0,0 @@
|
|
|
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 { ProviderPicker } from "../provider/ProviderPicker";
|
|
6
|
-
import {
|
|
7
|
-
fetchCommits,
|
|
8
|
-
fetchDiff,
|
|
9
|
-
isGitRepo,
|
|
10
|
-
summarizeTimeline,
|
|
11
|
-
} from "../../utils/git";
|
|
12
|
-
import { callChat, parseResponse } from "../../utils/chat";
|
|
13
|
-
import { registry } from "../../utils/tools/registry";
|
|
14
|
-
import { buildGitToolsPromptSection } from "../../tools/git";
|
|
15
|
-
import type { Commit, DiffFile } from "../../utils/git";
|
|
16
|
-
import type { Provider } from "../../types/config";
|
|
17
|
-
import type { Message } from "../../types/chat";
|
|
18
|
-
import { TypewriterText, InputBox } from "../chat/ChatOverlays";
|
|
19
|
-
import { ACCENT } from "../../colors";
|
|
20
|
-
|
|
21
|
-
const W = () => process.stdout.columns ?? 100;
|
|
22
|
-
|
|
23
|
-
// ── git runner (only used by RevertConfirm) ───────────────────────────────────
|
|
24
|
-
|
|
25
|
-
function gitRun(cmd: string, cwd: string): { ok: boolean; out: string } {
|
|
26
|
-
try {
|
|
27
|
-
const out = execSync(cmd, {
|
|
28
|
-
cwd,
|
|
29
|
-
encoding: "utf-8",
|
|
30
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
31
|
-
timeout: 60_000,
|
|
32
|
-
}).trim();
|
|
33
|
-
return { ok: true, out: out || "(done)" };
|
|
34
|
-
} catch (e: any) {
|
|
35
|
-
const msg =
|
|
36
|
-
[e.stdout, e.stderr].filter(Boolean).join("\n").trim() || e.message;
|
|
37
|
-
return { ok: false, out: msg };
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// ── thinking phrases ──────────────────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
const THINKING_PHRASES = [
|
|
44
|
-
"thinking…",
|
|
45
|
-
"reading the repo…",
|
|
46
|
-
"consulting the log…",
|
|
47
|
-
"grepping the history…",
|
|
48
|
-
"diffing the vibes…",
|
|
49
|
-
"sniffing the diff...",
|
|
50
|
-
"reading your crimes...",
|
|
51
|
-
"crafting the perfect commit message...",
|
|
52
|
-
"pretending this was intentional all along...",
|
|
53
|
-
"making it sound like a feature...",
|
|
54
|
-
"turning chaos into conventional commits...",
|
|
55
|
-
"72 chars or bust...",
|
|
56
|
-
"git log will remember this...",
|
|
57
|
-
"committing to the bit. and also the repo...",
|
|
58
|
-
"staging your changes (and your career)...",
|
|
59
|
-
"making main proud...",
|
|
60
|
-
"git blame: not it...",
|
|
61
|
-
];
|
|
62
|
-
|
|
63
|
-
function randomPhrase() {
|
|
64
|
-
return THINKING_PHRASES[Math.floor(Math.random() * THINKING_PHRASES.length)]!;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ── tiny helpers ──────────────────────────────────────────────────────────────
|
|
68
|
-
|
|
69
|
-
function shortDate(d: string) {
|
|
70
|
-
try {
|
|
71
|
-
return new Date(d).toLocaleDateString("en-US", {
|
|
72
|
-
month: "short",
|
|
73
|
-
day: "numeric",
|
|
74
|
-
year: "2-digit",
|
|
75
|
-
});
|
|
76
|
-
} catch {
|
|
77
|
-
return d.slice(0, 10);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function trunc(s: string, n: number) {
|
|
82
|
-
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function bar(ins: number, del: number): string {
|
|
86
|
-
const total = ins + del;
|
|
87
|
-
if (!total) return "";
|
|
88
|
-
const w = 10;
|
|
89
|
-
const addW = Math.round((ins / total) * w);
|
|
90
|
-
return "+" + "█".repeat(addW) + "░".repeat(w - addW) + "-";
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ── CommitRow ─────────────────────────────────────────────────────────────────
|
|
94
|
-
|
|
95
|
-
function CommitRow({
|
|
96
|
-
commit,
|
|
97
|
-
index,
|
|
98
|
-
isSelected,
|
|
99
|
-
showDiff,
|
|
100
|
-
diff,
|
|
101
|
-
diffScroll,
|
|
102
|
-
onRevert,
|
|
103
|
-
}: {
|
|
104
|
-
commit: Commit;
|
|
105
|
-
index: number;
|
|
106
|
-
isSelected: boolean;
|
|
107
|
-
showDiff: boolean;
|
|
108
|
-
diff: DiffFile[];
|
|
109
|
-
diffScroll: number;
|
|
110
|
-
onRevert: () => void;
|
|
111
|
-
}) {
|
|
112
|
-
const w = W();
|
|
113
|
-
const isMerge = commit.parents.length > 1;
|
|
114
|
-
const node = isMerge ? "⎇" : index === 0 ? "◉" : "●";
|
|
115
|
-
|
|
116
|
-
const refLabels = commit.refs
|
|
117
|
-
.split(",")
|
|
118
|
-
.map((r) => r.trim())
|
|
119
|
-
.filter(Boolean)
|
|
120
|
-
.map((r) =>
|
|
121
|
-
r.startsWith("HEAD -> ")
|
|
122
|
-
? r.slice(8)
|
|
123
|
-
: r.startsWith("tag: ")
|
|
124
|
-
? `v${r.slice(5)}`
|
|
125
|
-
: r,
|
|
126
|
-
)
|
|
127
|
-
.slice(0, 2);
|
|
128
|
-
|
|
129
|
-
return (
|
|
130
|
-
<Box flexDirection="column">
|
|
131
|
-
<Box gap={1}>
|
|
132
|
-
<Text color={isSelected ? ACCENT : "gray"}>
|
|
133
|
-
{isSelected ? "▶" : " "}
|
|
134
|
-
</Text>
|
|
135
|
-
<Text color={isSelected ? ACCENT : isMerge ? "magenta" : "gray"}>
|
|
136
|
-
{node}
|
|
137
|
-
</Text>
|
|
138
|
-
<Text color="gray" dimColor={!isSelected}>
|
|
139
|
-
{commit.shortHash}
|
|
140
|
-
</Text>
|
|
141
|
-
<Text color="cyan" dimColor={!isSelected}>
|
|
142
|
-
{shortDate(commit.date)}
|
|
143
|
-
</Text>
|
|
144
|
-
{refLabels.map((r) => (
|
|
145
|
-
<Text key={r} color="yellow">
|
|
146
|
-
[{r}]
|
|
147
|
-
</Text>
|
|
148
|
-
))}
|
|
149
|
-
<Text
|
|
150
|
-
color={isSelected ? "white" : "gray"}
|
|
151
|
-
bold={isSelected}
|
|
152
|
-
wrap="truncate"
|
|
153
|
-
>
|
|
154
|
-
{trunc(commit.message, w - 36)}
|
|
155
|
-
</Text>
|
|
156
|
-
</Box>
|
|
157
|
-
|
|
158
|
-
{isSelected && !showDiff && (
|
|
159
|
-
<Box flexDirection="column" marginLeft={4} marginBottom={1}>
|
|
160
|
-
<Box gap={2}>
|
|
161
|
-
<Text color="gray" dimColor>
|
|
162
|
-
{commit.author}
|
|
163
|
-
</Text>
|
|
164
|
-
<Text color="gray" dimColor>
|
|
165
|
-
{commit.relativeDate}
|
|
166
|
-
</Text>
|
|
167
|
-
{commit.filesChanged > 0 && (
|
|
168
|
-
<>
|
|
169
|
-
<Text color="green">+{commit.insertions}</Text>
|
|
170
|
-
<Text color="red">-{commit.deletions}</Text>
|
|
171
|
-
<Text color="gray" dimColor>
|
|
172
|
-
{commit.filesChanged} file
|
|
173
|
-
{commit.filesChanged !== 1 ? "s" : ""}
|
|
174
|
-
</Text>
|
|
175
|
-
<Text color="gray" dimColor>
|
|
176
|
-
{bar(commit.insertions, commit.deletions)}
|
|
177
|
-
</Text>
|
|
178
|
-
</>
|
|
179
|
-
)}
|
|
180
|
-
</Box>
|
|
181
|
-
{commit.body ? (
|
|
182
|
-
<Text color="gray" dimColor wrap="wrap">
|
|
183
|
-
{trunc(commit.body, w - 8)}
|
|
184
|
-
</Text>
|
|
185
|
-
) : null}
|
|
186
|
-
<Box gap={3} marginTop={1}>
|
|
187
|
-
<Text color="gray" dimColor>
|
|
188
|
-
enter diff
|
|
189
|
-
</Text>
|
|
190
|
-
<Text color="red" dimColor>
|
|
191
|
-
x revert
|
|
192
|
-
</Text>
|
|
193
|
-
</Box>
|
|
194
|
-
</Box>
|
|
195
|
-
)}
|
|
196
|
-
|
|
197
|
-
{isSelected && showDiff && (
|
|
198
|
-
<Box flexDirection="column" marginLeft={2} marginBottom={1}>
|
|
199
|
-
<Box gap={3} marginBottom={1}>
|
|
200
|
-
<Text color={ACCENT} bold>
|
|
201
|
-
DIFF
|
|
202
|
-
</Text>
|
|
203
|
-
<Text color="gray" dimColor>
|
|
204
|
-
{commit.shortHash} — {trunc(commit.message, 50)}
|
|
205
|
-
</Text>
|
|
206
|
-
<Text color="red" dimColor>
|
|
207
|
-
x revert
|
|
208
|
-
</Text>
|
|
209
|
-
<Text color="gray" dimColor>
|
|
210
|
-
esc close
|
|
211
|
-
</Text>
|
|
212
|
-
</Box>
|
|
213
|
-
<DiffPanel
|
|
214
|
-
files={diff}
|
|
215
|
-
scrollOffset={diffScroll}
|
|
216
|
-
maxLines={Math.max(8, (process.stdout.rows ?? 30) - 12)}
|
|
217
|
-
/>
|
|
218
|
-
<Text color="gray" dimColor>
|
|
219
|
-
↑↓ scroll · esc close
|
|
220
|
-
</Text>
|
|
221
|
-
</Box>
|
|
222
|
-
)}
|
|
223
|
-
</Box>
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// ── DiffPanel ─────────────────────────────────────────────────────────────────
|
|
228
|
-
|
|
229
|
-
function DiffPanel({
|
|
230
|
-
files,
|
|
231
|
-
scrollOffset,
|
|
232
|
-
maxLines,
|
|
233
|
-
}: {
|
|
234
|
-
files: DiffFile[];
|
|
235
|
-
scrollOffset: number;
|
|
236
|
-
maxLines: number;
|
|
237
|
-
}) {
|
|
238
|
-
const w = W() - 6;
|
|
239
|
-
|
|
240
|
-
type RLine =
|
|
241
|
-
| {
|
|
242
|
-
k: "file";
|
|
243
|
-
path: string;
|
|
244
|
-
ins: number;
|
|
245
|
-
del: number;
|
|
246
|
-
status: DiffFile["status"];
|
|
247
|
-
}
|
|
248
|
-
| { k: "hunk" | "add" | "rem" | "ctx"; content: string };
|
|
249
|
-
|
|
250
|
-
const all: RLine[] = [];
|
|
251
|
-
for (const f of files) {
|
|
252
|
-
const icon =
|
|
253
|
-
f.status === "added"
|
|
254
|
-
? "+"
|
|
255
|
-
: f.status === "deleted"
|
|
256
|
-
? "-"
|
|
257
|
-
: f.status === "renamed"
|
|
258
|
-
? "→"
|
|
259
|
-
: "~";
|
|
260
|
-
all.push({
|
|
261
|
-
k: "file",
|
|
262
|
-
path: `${icon} ${f.path}`,
|
|
263
|
-
ins: f.insertions,
|
|
264
|
-
del: f.deletions,
|
|
265
|
-
status: f.status,
|
|
266
|
-
});
|
|
267
|
-
for (const l of f.lines) {
|
|
268
|
-
if (l.type === "header") all.push({ k: "hunk", content: l.content });
|
|
269
|
-
else if (l.type === "add") all.push({ k: "add", content: l.content });
|
|
270
|
-
else if (l.type === "remove") all.push({ k: "rem", content: l.content });
|
|
271
|
-
else all.push({ k: "ctx", content: l.content });
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
if (!all.length)
|
|
276
|
-
return (
|
|
277
|
-
<Text color="gray" dimColor>
|
|
278
|
-
{" "}
|
|
279
|
-
no diff available
|
|
280
|
-
</Text>
|
|
281
|
-
);
|
|
282
|
-
|
|
283
|
-
const visible = all.slice(scrollOffset, scrollOffset + maxLines);
|
|
284
|
-
const hasMore = all.length > scrollOffset + maxLines;
|
|
285
|
-
|
|
286
|
-
return (
|
|
287
|
-
<Box flexDirection="column">
|
|
288
|
-
{visible.map((line, i) => {
|
|
289
|
-
if (line.k === "file") {
|
|
290
|
-
const color =
|
|
291
|
-
line.status === "added"
|
|
292
|
-
? "green"
|
|
293
|
-
: line.status === "deleted"
|
|
294
|
-
? "red"
|
|
295
|
-
: line.status === "renamed"
|
|
296
|
-
? "yellow"
|
|
297
|
-
: "cyan";
|
|
298
|
-
return (
|
|
299
|
-
<Box key={i} gap={2} marginTop={i > 0 ? 1 : 0}>
|
|
300
|
-
<Text color={color} bold>
|
|
301
|
-
{trunc(line.path, w)}
|
|
302
|
-
</Text>
|
|
303
|
-
<Text color="green">+{line.ins}</Text>
|
|
304
|
-
<Text color="red">-{line.del}</Text>
|
|
305
|
-
</Box>
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
if (line.k === "hunk")
|
|
309
|
-
return (
|
|
310
|
-
<Text key={i} color="cyan" dimColor>
|
|
311
|
-
{trunc(line.content, w)}
|
|
312
|
-
</Text>
|
|
313
|
-
);
|
|
314
|
-
if (line.k === "add")
|
|
315
|
-
return (
|
|
316
|
-
<Text key={i} color="green">
|
|
317
|
-
{"+"}
|
|
318
|
-
{trunc(line.content, w - 1)}
|
|
319
|
-
</Text>
|
|
320
|
-
);
|
|
321
|
-
if (line.k === "rem")
|
|
322
|
-
return (
|
|
323
|
-
<Text key={i} color="red">
|
|
324
|
-
{"-"}
|
|
325
|
-
{trunc(line.content, w - 1)}
|
|
326
|
-
</Text>
|
|
327
|
-
);
|
|
328
|
-
return (
|
|
329
|
-
<Text key={i} color="gray" dimColor>
|
|
330
|
-
{" "}
|
|
331
|
-
{trunc(line.content, w - 1)}
|
|
332
|
-
</Text>
|
|
333
|
-
);
|
|
334
|
-
})}
|
|
335
|
-
{hasMore && (
|
|
336
|
-
<Text color="gray" dimColor>
|
|
337
|
-
{" "}
|
|
338
|
-
… {all.length - scrollOffset - maxLines} more lines
|
|
339
|
-
</Text>
|
|
340
|
-
)}
|
|
341
|
-
</Box>
|
|
342
|
-
);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// ── RevertConfirm overlay ─────────────────────────────────────────────────────
|
|
346
|
-
|
|
347
|
-
function RevertConfirm({
|
|
348
|
-
commit,
|
|
349
|
-
repoPath,
|
|
350
|
-
onDone,
|
|
351
|
-
}: {
|
|
352
|
-
commit: Commit;
|
|
353
|
-
repoPath: string;
|
|
354
|
-
onDone: (msg: string | null) => void;
|
|
355
|
-
}) {
|
|
356
|
-
const [status, setStatus] = useState<"confirm" | "running" | "done">(
|
|
357
|
-
"confirm",
|
|
358
|
-
);
|
|
359
|
-
const [result, setResult] = useState("");
|
|
360
|
-
|
|
361
|
-
useInput((input, key) => {
|
|
362
|
-
if (status !== "confirm") return;
|
|
363
|
-
if (input === "y" || input === "Y" || key.return) {
|
|
364
|
-
setStatus("running");
|
|
365
|
-
const r = gitRun(`git revert --no-edit "${commit.hash}"`, repoPath);
|
|
366
|
-
setResult(r.out);
|
|
367
|
-
setStatus("done");
|
|
368
|
-
setTimeout(
|
|
369
|
-
() => onDone(r.ok ? `Reverted ${commit.shortHash}` : null),
|
|
370
|
-
1200,
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
if (input === "n" || input === "N" || key.escape) onDone(null);
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
const w = W();
|
|
377
|
-
return (
|
|
378
|
-
<Box flexDirection="column" marginTop={1}>
|
|
379
|
-
<Text color="gray" dimColor>
|
|
380
|
-
{"─".repeat(w)}
|
|
381
|
-
</Text>
|
|
382
|
-
{status === "confirm" && (
|
|
383
|
-
<Box flexDirection="column" paddingX={1} gap={1}>
|
|
384
|
-
<Box gap={1}>
|
|
385
|
-
<Text color="red">!</Text>
|
|
386
|
-
<Text color="white">revert </Text>
|
|
387
|
-
<Text color={ACCENT}>{commit.shortHash}</Text>
|
|
388
|
-
<Text color="gray" dimColor>
|
|
389
|
-
— {trunc(commit.message, 50)}
|
|
390
|
-
</Text>
|
|
391
|
-
</Box>
|
|
392
|
-
<Text color="gray" dimColor>
|
|
393
|
-
{" "}
|
|
394
|
-
this creates a new "revert" commit — git history is preserved
|
|
395
|
-
</Text>
|
|
396
|
-
<Box gap={2} marginTop={1}>
|
|
397
|
-
<Text color="green">y/enter confirm</Text>
|
|
398
|
-
<Text color="gray" dimColor>
|
|
399
|
-
n/esc cancel
|
|
400
|
-
</Text>
|
|
401
|
-
</Box>
|
|
402
|
-
</Box>
|
|
403
|
-
)}
|
|
404
|
-
{status === "running" && (
|
|
405
|
-
<Box paddingX={1} gap={1}>
|
|
406
|
-
<Text color={ACCENT}>*</Text>
|
|
407
|
-
<Text color="gray" dimColor>
|
|
408
|
-
reverting…
|
|
409
|
-
</Text>
|
|
410
|
-
</Box>
|
|
411
|
-
)}
|
|
412
|
-
{status === "done" && (
|
|
413
|
-
<Box paddingX={1} gap={1}>
|
|
414
|
-
<Text
|
|
415
|
-
color={
|
|
416
|
-
result.startsWith("Error") || result.includes("error")
|
|
417
|
-
? "red"
|
|
418
|
-
: "green"
|
|
419
|
-
}
|
|
420
|
-
>
|
|
421
|
-
{result.startsWith("Error") ? "✗" : "✓"}
|
|
422
|
-
</Text>
|
|
423
|
-
<Text color="white" wrap="wrap">
|
|
424
|
-
{trunc(result, W() - 6)}
|
|
425
|
-
</Text>
|
|
426
|
-
</Box>
|
|
427
|
-
)}
|
|
428
|
-
</Box>
|
|
429
|
-
);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// ── MsgBody ───────────────────────────────────────────────────────────────────
|
|
433
|
-
// Mirrors MessageBody from ChatMessage.tsx — inline code, bold, lists, code blocks.
|
|
434
|
-
|
|
435
|
-
function InlineText({ text }: { text: string }) {
|
|
436
|
-
const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*)/g);
|
|
437
|
-
return (
|
|
438
|
-
<>
|
|
439
|
-
{parts.map((part, i) => {
|
|
440
|
-
if (part.startsWith("`") && part.endsWith("`"))
|
|
441
|
-
return (
|
|
442
|
-
<Text key={i} color={ACCENT}>
|
|
443
|
-
{part.slice(1, -1)}
|
|
444
|
-
</Text>
|
|
445
|
-
);
|
|
446
|
-
if (part.startsWith("**") && part.endsWith("**"))
|
|
447
|
-
return (
|
|
448
|
-
<Text key={i} bold color="white">
|
|
449
|
-
{part.slice(2, -2)}
|
|
450
|
-
</Text>
|
|
451
|
-
);
|
|
452
|
-
return (
|
|
453
|
-
<Text key={i} color="white">
|
|
454
|
-
{part}
|
|
455
|
-
</Text>
|
|
456
|
-
);
|
|
457
|
-
})}
|
|
458
|
-
</>
|
|
459
|
-
);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
function MsgBody({ content }: { content: string }) {
|
|
463
|
-
const segments = content.split(/(```[\s\S]*?```)/g);
|
|
464
|
-
return (
|
|
465
|
-
<Box flexDirection="column">
|
|
466
|
-
{segments.map((seg, si) => {
|
|
467
|
-
if (seg.startsWith("```")) {
|
|
468
|
-
const lines = seg.slice(3).split("\n");
|
|
469
|
-
const code = lines
|
|
470
|
-
.slice(1)
|
|
471
|
-
.join("\n")
|
|
472
|
-
.replace(/```\s*$/, "")
|
|
473
|
-
.trimEnd();
|
|
474
|
-
return (
|
|
475
|
-
<Box key={si} flexDirection="column">
|
|
476
|
-
{code.split("\n").map((line, li) => (
|
|
477
|
-
<Text key={li} color={ACCENT}>
|
|
478
|
-
{" "}
|
|
479
|
-
{line}
|
|
480
|
-
</Text>
|
|
481
|
-
))}
|
|
482
|
-
</Box>
|
|
483
|
-
);
|
|
484
|
-
}
|
|
485
|
-
const lines = seg.split("\n").filter((l) => l.trim() !== "");
|
|
486
|
-
return (
|
|
487
|
-
<Box key={si} flexDirection="column">
|
|
488
|
-
{lines.map((line, li) => {
|
|
489
|
-
if (line.match(/^[-*•]\s/))
|
|
490
|
-
return (
|
|
491
|
-
<Box key={li} gap={1}>
|
|
492
|
-
<Text color={ACCENT}>*</Text>
|
|
493
|
-
<InlineText text={line.slice(2).trim()} />
|
|
494
|
-
</Box>
|
|
495
|
-
);
|
|
496
|
-
if (line.match(/^\d+\.\s/)) {
|
|
497
|
-
const num = line.match(/^(\d+)\.\s/)![1];
|
|
498
|
-
return (
|
|
499
|
-
<Box key={li} gap={1}>
|
|
500
|
-
<Text color="gray">{num}.</Text>
|
|
501
|
-
<InlineText text={line.replace(/^\d+\.\s/, "").trim()} />
|
|
502
|
-
</Box>
|
|
503
|
-
);
|
|
504
|
-
}
|
|
505
|
-
return (
|
|
506
|
-
<Box key={li}>
|
|
507
|
-
<InlineText text={line} />
|
|
508
|
-
</Box>
|
|
509
|
-
);
|
|
510
|
-
})}
|
|
511
|
-
</Box>
|
|
512
|
-
);
|
|
513
|
-
})}
|
|
514
|
-
</Box>
|
|
515
|
-
);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// ── AskPanel ──────────────────────────────────────────────────────────────────
|
|
519
|
-
//
|
|
520
|
-
// Uses the global registry + parseResponse — identical execution path to
|
|
521
|
-
// ChatRunner.processResponse. Git tools come from tools/git.ts which registers
|
|
522
|
-
// them into the registry at startup. No local tool definitions here.
|
|
523
|
-
|
|
524
|
-
type AskMsg =
|
|
525
|
-
| { kind: "user"; content: string }
|
|
526
|
-
| { kind: "assistant"; content: string }
|
|
527
|
-
| { kind: "thinking" }
|
|
528
|
-
| { kind: "image"; ansi: string }
|
|
529
|
-
| {
|
|
530
|
-
kind: "tool";
|
|
531
|
-
toolName: string;
|
|
532
|
-
label: string;
|
|
533
|
-
result?: string;
|
|
534
|
-
approved?: boolean;
|
|
535
|
-
};
|
|
536
|
-
|
|
537
|
-
type PendingTool = {
|
|
538
|
-
toolName: string;
|
|
539
|
-
input: unknown;
|
|
540
|
-
rawInput: string;
|
|
541
|
-
remainder: string | undefined;
|
|
542
|
-
history: Message[];
|
|
543
|
-
};
|
|
544
|
-
|
|
545
|
-
function AskPanel({
|
|
546
|
-
commits,
|
|
547
|
-
repoPath,
|
|
548
|
-
provider,
|
|
549
|
-
onReload,
|
|
550
|
-
}: {
|
|
551
|
-
commits: Commit[];
|
|
552
|
-
repoPath: string;
|
|
553
|
-
provider: Provider;
|
|
554
|
-
onReload: () => void;
|
|
555
|
-
}) {
|
|
556
|
-
const [messages, setMessages] = useState<AskMsg[]>([]);
|
|
557
|
-
const [apiHistory, setApiHistory] = useState<Message[]>([]);
|
|
558
|
-
const [input, setInput] = useState("");
|
|
559
|
-
const [thinking, setThinking] = useState(false);
|
|
560
|
-
const [phrase, setPhrase] = useState(randomPhrase);
|
|
561
|
-
const [pending, setPending] = useState<PendingTool | null>(null);
|
|
562
|
-
const abortRef = useRef<AbortController | null>(null);
|
|
563
|
-
const { stdout } = useStdout();
|
|
564
|
-
|
|
565
|
-
// Rotate thinking phrase while busy
|
|
566
|
-
useEffect(() => {
|
|
567
|
-
if (!thinking) return;
|
|
568
|
-
setPhrase(randomPhrase());
|
|
569
|
-
const id = setInterval(() => setPhrase(randomPhrase()), 3200);
|
|
570
|
-
return () => clearInterval(id);
|
|
571
|
-
}, [thinking]);
|
|
572
|
-
|
|
573
|
-
const systemPrompt = `You are a git assistant embedded in a terminal timeline viewer.
|
|
574
|
-
Repository: ${repoPath}
|
|
575
|
-
|
|
576
|
-
You have access to git tools to answer questions and perform git operations.
|
|
577
|
-
${buildGitToolsPromptSection()}
|
|
578
|
-
|
|
579
|
-
Rules:
|
|
580
|
-
- Use read tools freely to answer questions requiring live data
|
|
581
|
-
- For write operations briefly explain what you are about to do before emitting the tag
|
|
582
|
-
- After a tool result is returned, continue your response naturally
|
|
583
|
-
- Plain text only — no markdown headers
|
|
584
|
-
- Be concise
|
|
585
|
-
|
|
586
|
-
Timeline summary (last 300 commits):
|
|
587
|
-
${summarizeTimeline(commits)}`;
|
|
588
|
-
|
|
589
|
-
// ── core process loop — mirrors ChatRunner.processResponse ─────────────────
|
|
590
|
-
|
|
591
|
-
const processResponse = (
|
|
592
|
-
raw: string,
|
|
593
|
-
currentHistory: Message[],
|
|
594
|
-
signal: AbortSignal,
|
|
595
|
-
) => {
|
|
596
|
-
if (signal.aborted) {
|
|
597
|
-
setThinking(false);
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
const parsed = parseResponse(raw);
|
|
602
|
-
|
|
603
|
-
// plain text
|
|
604
|
-
if (parsed.kind === "text") {
|
|
605
|
-
const clean = parsed.content.replace(/\*\*([^*]+)\*\*/g, "$1").trim();
|
|
606
|
-
setMessages((prev) => [
|
|
607
|
-
...prev.filter((m) => m.kind !== "thinking"),
|
|
608
|
-
{ kind: "assistant", content: clean },
|
|
609
|
-
]);
|
|
610
|
-
setApiHistory([
|
|
611
|
-
...currentHistory,
|
|
612
|
-
{ role: "assistant", content: clean, type: "text" },
|
|
613
|
-
]);
|
|
614
|
-
setThinking(false);
|
|
615
|
-
return;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// tool call
|
|
619
|
-
if (parsed.kind === "tool") {
|
|
620
|
-
const tool = registry.get(parsed.toolName);
|
|
621
|
-
if (!tool) {
|
|
622
|
-
setThinking(false);
|
|
623
|
-
return;
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
const label = tool.summariseInput
|
|
627
|
-
? String(tool.summariseInput(parsed.input))
|
|
628
|
-
: parsed.rawInput;
|
|
629
|
-
|
|
630
|
-
if (tool.safe) {
|
|
631
|
-
// Auto-approve — keep thinking true the whole time so input stays locked.
|
|
632
|
-
// Replace the thinking bubble with preamble (if any) + tool row + new thinking bubble.
|
|
633
|
-
setMessages((prev) => [
|
|
634
|
-
...prev.filter((m) => m.kind !== "thinking"),
|
|
635
|
-
...(parsed.content
|
|
636
|
-
? [{ kind: "assistant" as const, content: parsed.content }]
|
|
637
|
-
: []),
|
|
638
|
-
{
|
|
639
|
-
kind: "tool" as const,
|
|
640
|
-
toolName: parsed.toolName,
|
|
641
|
-
label,
|
|
642
|
-
approved: true,
|
|
643
|
-
},
|
|
644
|
-
{ kind: "thinking" as const },
|
|
645
|
-
]);
|
|
646
|
-
executeAndContinue(
|
|
647
|
-
{
|
|
648
|
-
toolName: parsed.toolName,
|
|
649
|
-
input: parsed.input,
|
|
650
|
-
rawInput: parsed.rawInput,
|
|
651
|
-
remainder: parsed.remainder,
|
|
652
|
-
history: currentHistory,
|
|
653
|
-
},
|
|
654
|
-
true,
|
|
655
|
-
signal,
|
|
656
|
-
);
|
|
657
|
-
} else {
|
|
658
|
-
// Write tool — stop thinking, show permission prompt, block input via pending.
|
|
659
|
-
setThinking(false);
|
|
660
|
-
setMessages((prev) => [
|
|
661
|
-
...prev.filter((m) => m.kind !== "thinking"),
|
|
662
|
-
...(parsed.content
|
|
663
|
-
? [{ kind: "assistant" as const, content: parsed.content }]
|
|
664
|
-
: []),
|
|
665
|
-
{ kind: "tool" as const, toolName: parsed.toolName, label },
|
|
666
|
-
]);
|
|
667
|
-
setPending({
|
|
668
|
-
toolName: parsed.toolName,
|
|
669
|
-
input: parsed.input,
|
|
670
|
-
rawInput: parsed.rawInput,
|
|
671
|
-
remainder: parsed.remainder,
|
|
672
|
-
history: currentHistory,
|
|
673
|
-
});
|
|
674
|
-
}
|
|
675
|
-
return;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
// anything else (changes, clone) — show as text in this context
|
|
679
|
-
setMessages((prev) => [
|
|
680
|
-
...prev.filter((m) => m.kind !== "thinking"),
|
|
681
|
-
{ kind: "assistant", content: raw.trim() },
|
|
682
|
-
]);
|
|
683
|
-
setThinking(false);
|
|
684
|
-
};
|
|
685
|
-
|
|
686
|
-
const executeAndContinue = async (
|
|
687
|
-
p: PendingTool,
|
|
688
|
-
approved: boolean,
|
|
689
|
-
signal: AbortSignal,
|
|
690
|
-
) => {
|
|
691
|
-
const tool = registry.get(p.toolName);
|
|
692
|
-
if (!tool) return;
|
|
693
|
-
|
|
694
|
-
let result = "(denied by user)";
|
|
695
|
-
let resultKind: string = "text";
|
|
696
|
-
|
|
697
|
-
if (approved) {
|
|
698
|
-
try {
|
|
699
|
-
const toolResult = await tool.execute(p.input, {
|
|
700
|
-
repoPath,
|
|
701
|
-
messages: p.history,
|
|
702
|
-
});
|
|
703
|
-
result = toolResult.value;
|
|
704
|
-
resultKind = (toolResult as any).kind ?? "text";
|
|
705
|
-
} catch (e: any) {
|
|
706
|
-
result = `Error: ${e.message}`;
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
// Image result — write ANSI directly to stdout (bypasses Ink's renderer)
|
|
711
|
-
// and inject an image message into the list instead of a text result.
|
|
712
|
-
if (resultKind === "image" && approved) {
|
|
713
|
-
setMessages((prev) => {
|
|
714
|
-
const next = prev
|
|
715
|
-
.map((m) =>
|
|
716
|
-
m.kind === "tool" &&
|
|
717
|
-
m.toolName === p.toolName &&
|
|
718
|
-
m.result === undefined
|
|
719
|
-
? { ...m, result: "(image)", approved }
|
|
720
|
-
: m,
|
|
721
|
-
)
|
|
722
|
-
.filter((m) => m.kind !== "thinking");
|
|
723
|
-
return [...next, { kind: "image" as const, ansi: result }];
|
|
724
|
-
});
|
|
725
|
-
stdout.write(result + "\n");
|
|
726
|
-
} else {
|
|
727
|
-
// Stamp result onto the tool bubble and remove the trailing thinking bubble
|
|
728
|
-
// in one atomic update — no intermediate render with a dangling spinner.
|
|
729
|
-
setMessages((prev) => {
|
|
730
|
-
const next = prev
|
|
731
|
-
.map((m) =>
|
|
732
|
-
m.kind === "tool" &&
|
|
733
|
-
m.toolName === p.toolName &&
|
|
734
|
-
m.result === undefined
|
|
735
|
-
? { ...m, result, approved }
|
|
736
|
-
: m,
|
|
737
|
-
)
|
|
738
|
-
.filter((m) => m.kind !== "thinking");
|
|
739
|
-
return next;
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
// reload commit list if a write succeeded
|
|
744
|
-
if (
|
|
745
|
-
approved &&
|
|
746
|
-
!result.startsWith("Error") &&
|
|
747
|
-
!result.startsWith("(denied")
|
|
748
|
-
) {
|
|
749
|
-
onReload();
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
const nextHistory: Message[] = [
|
|
753
|
-
...p.history,
|
|
754
|
-
{
|
|
755
|
-
role: "user" as const,
|
|
756
|
-
content: approved
|
|
757
|
-
? `Tool result for <${p.toolName}>:\n${result}`
|
|
758
|
-
: `Tool <${p.toolName}> was denied by the user.`,
|
|
759
|
-
type: "text" as const,
|
|
760
|
-
},
|
|
761
|
-
];
|
|
762
|
-
setApiHistory(nextHistory);
|
|
763
|
-
|
|
764
|
-
// if the model already wrote a remainder, process it inline
|
|
765
|
-
if (approved && p.remainder) {
|
|
766
|
-
processResponse(p.remainder, nextHistory, signal);
|
|
767
|
-
return;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// no remainder — follow-up API call.
|
|
771
|
-
// Set thinking BEFORE the stamp so isBusy never drops to false between
|
|
772
|
-
// the tool completing and the next runChat starting.
|
|
773
|
-
setThinking(true);
|
|
774
|
-
setMessages((prev) => [...prev, { kind: "thinking" }]);
|
|
775
|
-
runChat(nextHistory, signal);
|
|
776
|
-
};
|
|
777
|
-
|
|
778
|
-
const runChat = async (history: Message[], signal: AbortSignal) => {
|
|
779
|
-
try {
|
|
780
|
-
const result = await callChat(provider, systemPrompt, history, signal);
|
|
781
|
-
if (signal.aborted) return;
|
|
782
|
-
processResponse(result.text, history, signal);
|
|
783
|
-
} catch (e: any) {
|
|
784
|
-
if (e?.name === "AbortError") return;
|
|
785
|
-
setMessages((prev) => [
|
|
786
|
-
...prev.filter((m) => m.kind !== "thinking"),
|
|
787
|
-
{ kind: "assistant", content: `Error: ${String(e)}` },
|
|
788
|
-
]);
|
|
789
|
-
setThinking(false);
|
|
790
|
-
}
|
|
791
|
-
};
|
|
792
|
-
|
|
793
|
-
const ask = async (q: string) => {
|
|
794
|
-
if (!q.trim() || thinking || pending !== null) return;
|
|
795
|
-
|
|
796
|
-
const userMsg: Message = { role: "user", content: q, type: "text" };
|
|
797
|
-
const nextHistory = [...apiHistory, userMsg];
|
|
798
|
-
|
|
799
|
-
// Set thinking true FIRST so isBusy blocks input before the next render
|
|
800
|
-
setThinking(true);
|
|
801
|
-
setMessages((prev) => [
|
|
802
|
-
...prev,
|
|
803
|
-
{ kind: "user", content: q },
|
|
804
|
-
{ kind: "thinking" },
|
|
805
|
-
]);
|
|
806
|
-
setApiHistory(nextHistory);
|
|
807
|
-
setInput("");
|
|
808
|
-
|
|
809
|
-
const abort = new AbortController();
|
|
810
|
-
abortRef.current = abort;
|
|
811
|
-
await runChat(nextHistory, abort.signal);
|
|
812
|
-
};
|
|
813
|
-
|
|
814
|
-
// permission y/n — only fires when pending !== null
|
|
815
|
-
useInput((inp, key) => {
|
|
816
|
-
if (!pending) return;
|
|
817
|
-
if (inp === "y" || inp === "Y" || key.return) {
|
|
818
|
-
const p = pending;
|
|
819
|
-
setPending(null);
|
|
820
|
-
const abort = abortRef.current ?? new AbortController();
|
|
821
|
-
executeAndContinue(p, true, abort.signal);
|
|
822
|
-
} else if (inp === "n" || inp === "N" || key.escape) {
|
|
823
|
-
const p = pending;
|
|
824
|
-
setPending(null);
|
|
825
|
-
const abort = abortRef.current ?? new AbortController();
|
|
826
|
-
executeAndContinue(p, false, abort.signal);
|
|
827
|
-
}
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
const w = W();
|
|
831
|
-
const isBusy = thinking || pending !== null;
|
|
832
|
-
const hasThinking = messages.some((m) => m.kind === "thinking");
|
|
833
|
-
|
|
834
|
-
return (
|
|
835
|
-
<Box flexDirection="column" marginTop={1}>
|
|
836
|
-
<Text color="gray" dimColor>
|
|
837
|
-
{"─".repeat(w)}
|
|
838
|
-
</Text>
|
|
839
|
-
|
|
840
|
-
<Box paddingX={1} marginBottom={1} gap={2}>
|
|
841
|
-
<Text color={ACCENT} bold>
|
|
842
|
-
ASK
|
|
843
|
-
</Text>
|
|
844
|
-
<Text color="gray" dimColor>
|
|
845
|
-
git tools available · y/n for writes · esc back
|
|
846
|
-
</Text>
|
|
847
|
-
</Box>
|
|
848
|
-
|
|
849
|
-
{messages.map((msg, i) => {
|
|
850
|
-
// ── thinking ────────────────────────────────────────────────────
|
|
851
|
-
if (msg.kind === "thinking") {
|
|
852
|
-
// Only render the last thinking bubble; use phrase state directly
|
|
853
|
-
const lastIdx = messages.map((m) => m.kind).lastIndexOf("thinking");
|
|
854
|
-
if (i !== lastIdx) return null;
|
|
855
|
-
return (
|
|
856
|
-
<Box key="thinking" gap={1} marginBottom={1}>
|
|
857
|
-
<Text color={ACCENT}>●</Text>
|
|
858
|
-
<TypewriterText key={phrase} text={phrase} />
|
|
859
|
-
</Box>
|
|
860
|
-
);
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
// ── user ────────────────────────────────────────────────────────
|
|
864
|
-
if (msg.kind === "user") {
|
|
865
|
-
return (
|
|
866
|
-
<Box
|
|
867
|
-
key={i}
|
|
868
|
-
marginBottom={1}
|
|
869
|
-
gap={1}
|
|
870
|
-
backgroundColor="#1a1a1a"
|
|
871
|
-
paddingLeft={1}
|
|
872
|
-
paddingRight={2}
|
|
873
|
-
>
|
|
874
|
-
<Text color="gray">{">"}</Text>
|
|
875
|
-
<Text color="white" bold>
|
|
876
|
-
{msg.content}
|
|
877
|
-
</Text>
|
|
878
|
-
</Box>
|
|
879
|
-
);
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
// ── tool ────────────────────────────────────────────────────────
|
|
883
|
-
if (msg.kind === "tool") {
|
|
884
|
-
const isDone = msg.result !== undefined;
|
|
885
|
-
const denied = msg.approved === false;
|
|
886
|
-
const isError = msg.result?.startsWith("Error") || denied;
|
|
887
|
-
const tool = registry.get(msg.toolName);
|
|
888
|
-
const isWrite = tool && !tool.safe;
|
|
889
|
-
return (
|
|
890
|
-
<Box key={i} flexDirection="column" marginBottom={1}>
|
|
891
|
-
<Box gap={1}>
|
|
892
|
-
<Text color={denied ? "red" : ACCENT}>$</Text>
|
|
893
|
-
<Text color={denied ? "red" : "gray"} dimColor={!denied}>
|
|
894
|
-
{trunc(msg.label, w - 4)}
|
|
895
|
-
</Text>
|
|
896
|
-
{denied && <Text color="red">denied</Text>}
|
|
897
|
-
</Box>
|
|
898
|
-
{!isDone && isWrite && (
|
|
899
|
-
<Box marginLeft={2} gap={1}>
|
|
900
|
-
<Text color="gray">y/enter allow · n/esc deny</Text>
|
|
901
|
-
</Box>
|
|
902
|
-
)}
|
|
903
|
-
{isDone && msg.result && (
|
|
904
|
-
<Box marginLeft={2}>
|
|
905
|
-
<Text color={isError ? "red" : "gray"} dimColor={!isError}>
|
|
906
|
-
{trunc(msg.result.split("\n")[0]!, w - 6)}
|
|
907
|
-
{(msg.result.split("\n")[0]?.length ?? 0) > w - 6
|
|
908
|
-
? "…"
|
|
909
|
-
: ""}
|
|
910
|
-
</Text>
|
|
911
|
-
</Box>
|
|
912
|
-
)}
|
|
913
|
-
</Box>
|
|
914
|
-
);
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
// ── image ────────────────────────────────────────────────────────
|
|
918
|
-
// Already written to stdout raw — just show a placeholder label so
|
|
919
|
-
// the message list stays coherent and the image appears above it.
|
|
920
|
-
if (msg.kind === "image") {
|
|
921
|
-
return (
|
|
922
|
-
<Box key={i} gap={1} marginBottom={1}>
|
|
923
|
-
<Text color={ACCENT}>◎</Text>
|
|
924
|
-
<Text color="gray" dimColor>
|
|
925
|
-
image rendered above
|
|
926
|
-
</Text>
|
|
927
|
-
</Box>
|
|
928
|
-
);
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
// ── assistant ───────────────────────────────────────────────────
|
|
932
|
-
return (
|
|
933
|
-
<Box key={i} marginBottom={1} gap={1}>
|
|
934
|
-
<Text color={ACCENT}>●</Text>
|
|
935
|
-
<MsgBody content={msg.content} />
|
|
936
|
-
</Box>
|
|
937
|
-
);
|
|
938
|
-
})}
|
|
939
|
-
|
|
940
|
-
{pending && (
|
|
941
|
-
<Box marginLeft={2} gap={1} marginBottom={1}>
|
|
942
|
-
<Text color="gray">y/enter allow · n/esc deny</Text>
|
|
943
|
-
</Box>
|
|
944
|
-
)}
|
|
945
|
-
|
|
946
|
-
<InputBox
|
|
947
|
-
value={input}
|
|
948
|
-
onChange={setInput}
|
|
949
|
-
onSubmit={(v) => {
|
|
950
|
-
if (v.trim()) ask(v.trim());
|
|
951
|
-
}}
|
|
952
|
-
inputKey={isBusy ? 1 : 0}
|
|
953
|
-
/>
|
|
954
|
-
</Box>
|
|
955
|
-
);
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
// ── TimelineRunner ────────────────────────────────────────────────────────────
|
|
959
|
-
|
|
960
|
-
type UIMode =
|
|
961
|
-
| { type: "browse" }
|
|
962
|
-
| { type: "search"; query: string }
|
|
963
|
-
| { type: "ask" }
|
|
964
|
-
| { type: "revert"; commit: Commit };
|
|
965
|
-
|
|
966
|
-
type StatusMsg = { id: number; text: string; ok: boolean };
|
|
967
|
-
let sid = 0;
|
|
968
|
-
|
|
969
|
-
export function TimelineRunner({
|
|
970
|
-
repoPath,
|
|
971
|
-
onExit,
|
|
972
|
-
}: {
|
|
973
|
-
repoPath: string;
|
|
974
|
-
onExit?: () => void;
|
|
975
|
-
}) {
|
|
976
|
-
const [provider, setProvider] = useState<Provider | null>(null);
|
|
977
|
-
const [commits, setCommits] = useState<Commit[]>([]);
|
|
978
|
-
const [filtered, setFiltered] = useState<Commit[]>([]);
|
|
979
|
-
const [loading, setLoading] = useState(true);
|
|
980
|
-
const [error, setError] = useState<string | null>(null);
|
|
981
|
-
|
|
982
|
-
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
983
|
-
const [scrollOffset, setScrollOffset] = useState(0);
|
|
984
|
-
const [showDiff, setShowDiff] = useState(false);
|
|
985
|
-
const [diff, setDiff] = useState<DiffFile[]>([]);
|
|
986
|
-
const [diffLoading, setDiffLoading] = useState(false);
|
|
987
|
-
const [diffScroll, setDiffScroll] = useState(0);
|
|
988
|
-
const [lastDiffHash, setLastDiffHash] = useState<string | null>(null);
|
|
989
|
-
|
|
990
|
-
const [mode, setMode] = useState<UIMode>({ type: "browse" });
|
|
991
|
-
const [statusMsgs, setStatusMsgs] = useState<StatusMsg[]>([]);
|
|
992
|
-
|
|
993
|
-
const termHeight = process.stdout.rows ?? 30;
|
|
994
|
-
const visibleCount = Math.max(4, termHeight - 6);
|
|
995
|
-
|
|
996
|
-
const addStatus = (text: string, ok: boolean) =>
|
|
997
|
-
setStatusMsgs((prev) => [...prev, { id: ++sid, text, ok }]);
|
|
998
|
-
|
|
999
|
-
const reloadCommits = () => {
|
|
1000
|
-
const loaded = fetchCommits(repoPath, 300);
|
|
1001
|
-
setCommits(loaded);
|
|
1002
|
-
setFiltered(loaded);
|
|
1003
|
-
setSelectedIdx(0);
|
|
1004
|
-
setScrollOffset(0);
|
|
1005
|
-
setShowDiff(false);
|
|
1006
|
-
};
|
|
1007
|
-
|
|
1008
|
-
useEffect(() => {
|
|
1009
|
-
if (!isGitRepo(repoPath)) {
|
|
1010
|
-
setError("Not a git repository.");
|
|
1011
|
-
setLoading(false);
|
|
1012
|
-
return;
|
|
1013
|
-
}
|
|
1014
|
-
const loaded = fetchCommits(repoPath, 300);
|
|
1015
|
-
if (!loaded.length) {
|
|
1016
|
-
setError("No commits found.");
|
|
1017
|
-
setLoading(false);
|
|
1018
|
-
return;
|
|
1019
|
-
}
|
|
1020
|
-
setCommits(loaded);
|
|
1021
|
-
setFiltered(loaded);
|
|
1022
|
-
setLoading(false);
|
|
1023
|
-
}, [repoPath]);
|
|
1024
|
-
|
|
1025
|
-
useEffect(() => {
|
|
1026
|
-
if (mode.type !== "search" || !mode.query) {
|
|
1027
|
-
setFiltered(commits);
|
|
1028
|
-
} else {
|
|
1029
|
-
const q = mode.query.toLowerCase();
|
|
1030
|
-
setFiltered(
|
|
1031
|
-
commits.filter(
|
|
1032
|
-
(c) =>
|
|
1033
|
-
c.message.toLowerCase().includes(q) ||
|
|
1034
|
-
c.author.toLowerCase().includes(q) ||
|
|
1035
|
-
c.shortHash.includes(q),
|
|
1036
|
-
),
|
|
1037
|
-
);
|
|
1038
|
-
}
|
|
1039
|
-
setSelectedIdx(0);
|
|
1040
|
-
setScrollOffset(0);
|
|
1041
|
-
}, [mode, commits]);
|
|
1042
|
-
|
|
1043
|
-
const selected = filtered[selectedIdx] ?? null;
|
|
1044
|
-
|
|
1045
|
-
useEffect(() => {
|
|
1046
|
-
if (!selected || selected.hash === lastDiffHash) return;
|
|
1047
|
-
setDiff([]);
|
|
1048
|
-
setDiffScroll(0);
|
|
1049
|
-
setLastDiffHash(selected.hash);
|
|
1050
|
-
if (showDiff) {
|
|
1051
|
-
setDiffLoading(true);
|
|
1052
|
-
setTimeout(() => {
|
|
1053
|
-
setDiff(fetchDiff(repoPath, selected.hash));
|
|
1054
|
-
setDiffLoading(false);
|
|
1055
|
-
}, 0);
|
|
1056
|
-
}
|
|
1057
|
-
}, [selected?.hash]);
|
|
1058
|
-
|
|
1059
|
-
useEffect(() => {
|
|
1060
|
-
if (!showDiff || !selected) return;
|
|
1061
|
-
if (selected.hash === lastDiffHash && diff.length) return;
|
|
1062
|
-
setDiffLoading(true);
|
|
1063
|
-
setLastDiffHash(selected.hash);
|
|
1064
|
-
setTimeout(() => {
|
|
1065
|
-
setDiff(fetchDiff(repoPath, selected.hash));
|
|
1066
|
-
setDiffLoading(false);
|
|
1067
|
-
}, 0);
|
|
1068
|
-
}, [showDiff]);
|
|
1069
|
-
|
|
1070
|
-
useInput((input, key) => {
|
|
1071
|
-
if (key.ctrl && input === "c") {
|
|
1072
|
-
if (onExit) onExit();
|
|
1073
|
-
else process.exit(0);
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
if (mode.type === "ask" || mode.type === "revert") {
|
|
1077
|
-
if (key.escape) setMode({ type: "browse" });
|
|
1078
|
-
return;
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
if (mode.type === "search") {
|
|
1082
|
-
if (key.escape) setMode({ type: "browse" });
|
|
1083
|
-
return;
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
if (showDiff) {
|
|
1087
|
-
if (key.escape || input === "d") {
|
|
1088
|
-
setShowDiff(false);
|
|
1089
|
-
return;
|
|
1090
|
-
}
|
|
1091
|
-
if (key.upArrow) {
|
|
1092
|
-
setDiffScroll((o) => Math.max(0, o - 1));
|
|
1093
|
-
return;
|
|
1094
|
-
}
|
|
1095
|
-
if (key.downArrow) {
|
|
1096
|
-
setDiffScroll((o) => o + 1);
|
|
1097
|
-
return;
|
|
1098
|
-
}
|
|
1099
|
-
if (input === "x" || input === "X") {
|
|
1100
|
-
if (selected) setMode({ type: "revert", commit: selected });
|
|
1101
|
-
return;
|
|
1102
|
-
}
|
|
1103
|
-
return;
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
if (key.escape) {
|
|
1107
|
-
setShowDiff(false);
|
|
1108
|
-
return;
|
|
1109
|
-
}
|
|
1110
|
-
if ((input === "q" || input === "Q") && onExit) {
|
|
1111
|
-
onExit();
|
|
1112
|
-
return;
|
|
1113
|
-
}
|
|
1114
|
-
if (input === "/") {
|
|
1115
|
-
setMode({ type: "search", query: "" });
|
|
1116
|
-
return;
|
|
1117
|
-
}
|
|
1118
|
-
if (input === "?" || input === "a" || input === "A") {
|
|
1119
|
-
setMode({ type: "ask" });
|
|
1120
|
-
return;
|
|
1121
|
-
}
|
|
1122
|
-
if (key.return && selected) {
|
|
1123
|
-
setShowDiff(true);
|
|
1124
|
-
return;
|
|
1125
|
-
}
|
|
1126
|
-
if (input === "x" || input === "X") {
|
|
1127
|
-
if (selected) setMode({ type: "revert", commit: selected });
|
|
1128
|
-
return;
|
|
1129
|
-
}
|
|
1130
|
-
if (key.upArrow) {
|
|
1131
|
-
const next = Math.max(0, selectedIdx - 1);
|
|
1132
|
-
setSelectedIdx(next);
|
|
1133
|
-
setShowDiff(false);
|
|
1134
|
-
if (next < scrollOffset) setScrollOffset(next);
|
|
1135
|
-
return;
|
|
1136
|
-
}
|
|
1137
|
-
if (key.downArrow) {
|
|
1138
|
-
const next = Math.min(filtered.length - 1, selectedIdx + 1);
|
|
1139
|
-
setSelectedIdx(next);
|
|
1140
|
-
setShowDiff(false);
|
|
1141
|
-
if (next >= scrollOffset + visibleCount)
|
|
1142
|
-
setScrollOffset(next - visibleCount + 1);
|
|
1143
|
-
return;
|
|
1144
|
-
}
|
|
1145
|
-
});
|
|
1146
|
-
|
|
1147
|
-
if (!provider) return <ProviderPicker onDone={setProvider} />;
|
|
1148
|
-
if (loading)
|
|
1149
|
-
return (
|
|
1150
|
-
<Box gap={1} marginTop={1}>
|
|
1151
|
-
<Text color={ACCENT}>*</Text>
|
|
1152
|
-
<Text color="gray">loading commits…</Text>
|
|
1153
|
-
</Box>
|
|
1154
|
-
);
|
|
1155
|
-
if (error)
|
|
1156
|
-
return (
|
|
1157
|
-
<Box gap={1} marginTop={1}>
|
|
1158
|
-
<Text color="red">✗</Text>
|
|
1159
|
-
<Text color="white">{error}</Text>
|
|
1160
|
-
</Box>
|
|
1161
|
-
);
|
|
1162
|
-
|
|
1163
|
-
const w = W();
|
|
1164
|
-
const isSearching = mode.type === "search";
|
|
1165
|
-
const isAsking = mode.type === "ask";
|
|
1166
|
-
const isReverting = mode.type === "revert";
|
|
1167
|
-
const searchQuery = isSearching ? mode.query : "";
|
|
1168
|
-
const visible = filtered.slice(scrollOffset, scrollOffset + visibleCount);
|
|
1169
|
-
|
|
1170
|
-
const shortcutHint = showDiff
|
|
1171
|
-
? "↑↓ scroll · x revert · esc/d close"
|
|
1172
|
-
: isSearching
|
|
1173
|
-
? "type to filter · enter confirm · esc cancel"
|
|
1174
|
-
: isAsking
|
|
1175
|
-
? "ask anything · git tools available · esc back"
|
|
1176
|
-
: isReverting
|
|
1177
|
-
? "y confirm · n/esc cancel"
|
|
1178
|
-
: `↑↓ navigate · enter diff · x revert · a ask · / search${onExit ? " · q back" : " · ^C exit"}`;
|
|
1179
|
-
|
|
1180
|
-
return (
|
|
1181
|
-
<Box flexDirection="column">
|
|
1182
|
-
<Box gap={2} marginBottom={1}>
|
|
1183
|
-
<Text color={ACCENT} bold>
|
|
1184
|
-
◈ TIMELINE
|
|
1185
|
-
</Text>
|
|
1186
|
-
<Text color="gray" dimColor>
|
|
1187
|
-
{repoPath}
|
|
1188
|
-
</Text>
|
|
1189
|
-
{isSearching && <Text color="yellow">/ {searchQuery || "…"}</Text>}
|
|
1190
|
-
{isSearching && filtered.length !== commits.length && (
|
|
1191
|
-
<Text color="gray" dimColor>
|
|
1192
|
-
{filtered.length} matches
|
|
1193
|
-
</Text>
|
|
1194
|
-
)}
|
|
1195
|
-
</Box>
|
|
1196
|
-
|
|
1197
|
-
<Static items={statusMsgs}>
|
|
1198
|
-
{(msg) => (
|
|
1199
|
-
<Box key={msg.id} paddingX={1} gap={1}>
|
|
1200
|
-
<Text color={msg.ok ? "green" : "red"}>{msg.ok ? "✓" : "✗"}</Text>
|
|
1201
|
-
<Text color={msg.ok ? "white" : "red"}>{msg.text}</Text>
|
|
1202
|
-
</Box>
|
|
1203
|
-
)}
|
|
1204
|
-
</Static>
|
|
1205
|
-
|
|
1206
|
-
{isSearching && (
|
|
1207
|
-
<Box gap={1} marginBottom={1}>
|
|
1208
|
-
<Text color={ACCENT}>{"/"}</Text>
|
|
1209
|
-
<TextInput
|
|
1210
|
-
value={searchQuery}
|
|
1211
|
-
onChange={(q) => setMode({ type: "search", query: q })}
|
|
1212
|
-
onSubmit={() => setMode({ type: "browse" })}
|
|
1213
|
-
placeholder="filter commits…"
|
|
1214
|
-
/>
|
|
1215
|
-
</Box>
|
|
1216
|
-
)}
|
|
1217
|
-
|
|
1218
|
-
{visible.map((commit, i) => {
|
|
1219
|
-
const absIdx = scrollOffset + i;
|
|
1220
|
-
const isSel = absIdx === selectedIdx;
|
|
1221
|
-
return (
|
|
1222
|
-
<CommitRow
|
|
1223
|
-
key={commit.hash}
|
|
1224
|
-
commit={commit}
|
|
1225
|
-
index={absIdx}
|
|
1226
|
-
isSelected={isSel}
|
|
1227
|
-
showDiff={isSel && showDiff}
|
|
1228
|
-
diff={isSel ? diff : []}
|
|
1229
|
-
diffScroll={diffScroll}
|
|
1230
|
-
onRevert={() => setMode({ type: "revert", commit })}
|
|
1231
|
-
/>
|
|
1232
|
-
);
|
|
1233
|
-
})}
|
|
1234
|
-
|
|
1235
|
-
{(scrollOffset > 0 || scrollOffset + visibleCount < filtered.length) && (
|
|
1236
|
-
<Box gap={3} marginTop={1}>
|
|
1237
|
-
{scrollOffset > 0 && (
|
|
1238
|
-
<Text color="gray" dimColor>
|
|
1239
|
-
↑ {scrollOffset} above
|
|
1240
|
-
</Text>
|
|
1241
|
-
)}
|
|
1242
|
-
{scrollOffset + visibleCount < filtered.length && (
|
|
1243
|
-
<Text color="gray" dimColor>
|
|
1244
|
-
↓ {filtered.length - scrollOffset - visibleCount} below
|
|
1245
|
-
</Text>
|
|
1246
|
-
)}
|
|
1247
|
-
</Box>
|
|
1248
|
-
)}
|
|
1249
|
-
|
|
1250
|
-
{isReverting && mode.type === "revert" && (
|
|
1251
|
-
<RevertConfirm
|
|
1252
|
-
commit={mode.commit}
|
|
1253
|
-
repoPath={repoPath}
|
|
1254
|
-
onDone={(msg) => {
|
|
1255
|
-
setMode({ type: "browse" });
|
|
1256
|
-
if (msg) {
|
|
1257
|
-
addStatus(msg, true);
|
|
1258
|
-
reloadCommits();
|
|
1259
|
-
} else {
|
|
1260
|
-
addStatus("revert cancelled", false);
|
|
1261
|
-
}
|
|
1262
|
-
}}
|
|
1263
|
-
/>
|
|
1264
|
-
)}
|
|
1265
|
-
|
|
1266
|
-
{isAsking && provider && (
|
|
1267
|
-
<AskPanel
|
|
1268
|
-
commits={commits}
|
|
1269
|
-
repoPath={repoPath}
|
|
1270
|
-
provider={provider}
|
|
1271
|
-
onReload={() => {
|
|
1272
|
-
reloadCommits();
|
|
1273
|
-
addStatus("commits reloaded", true);
|
|
1274
|
-
}}
|
|
1275
|
-
/>
|
|
1276
|
-
)}
|
|
1277
|
-
|
|
1278
|
-
<Box marginTop={1}>
|
|
1279
|
-
<Text color="gray" dimColor>
|
|
1280
|
-
{shortcutHint}
|
|
1281
|
-
</Text>
|
|
1282
|
-
</Box>
|
|
1283
|
-
</Box>
|
|
1284
|
-
);
|
|
1285
|
-
}
|