@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,550 @@
|
|
|
1
|
+
import React, { useState, useRef } from "react";
|
|
2
|
+
import { Box, Text, Static, useInput } from "ink";
|
|
3
|
+
import { ACCENT, GREEN, RED } from "../../colors";
|
|
4
|
+
import { AppHeader, InputBox, ShortcutBar, TypewriterText } from "./StatusBar";
|
|
5
|
+
import { StaticMessage } from "./Message";
|
|
6
|
+
import { MessageBody } from "@ridit/ink-ui";
|
|
7
|
+
import type { UIMessage } from "./Message";
|
|
8
|
+
import { ProviderSetup } from "../provider/ProviderSetup";
|
|
9
|
+
import {
|
|
10
|
+
useThinkingPhrase,
|
|
11
|
+
useThinkingTip,
|
|
12
|
+
useThinkingTimer,
|
|
13
|
+
} from "../../utils/thinking";
|
|
14
|
+
import {
|
|
15
|
+
chat,
|
|
16
|
+
createSession,
|
|
17
|
+
createSessionWithId,
|
|
18
|
+
addMessage,
|
|
19
|
+
appendMessages,
|
|
20
|
+
getMessages,
|
|
21
|
+
getSystemPrompt,
|
|
22
|
+
saveSession,
|
|
23
|
+
loadSession,
|
|
24
|
+
getLatestSession,
|
|
25
|
+
getActiveModelName,
|
|
26
|
+
} from "@ridit/lens-core";
|
|
27
|
+
import { useChatInput } from "../../hooks/useChatInput";
|
|
28
|
+
import { handleCommand } from "../../hooks/useCommandHandler";
|
|
29
|
+
import Spinner from "ink-spinner";
|
|
30
|
+
|
|
31
|
+
// ── Static header (renders once, stays pinned) ────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const HEADER_ITEMS = [{ type: "header" as const }];
|
|
34
|
+
|
|
35
|
+
// ── Commands ──────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export const COMMANDS = [
|
|
38
|
+
{ cmd: "/auto", desc: "toggle auto-approve for read/search tools" },
|
|
39
|
+
{
|
|
40
|
+
cmd: "/auto --force-all",
|
|
41
|
+
desc: "auto-approve ALL tools including shell and writes (⚠ dangerous)",
|
|
42
|
+
},
|
|
43
|
+
{ cmd: "/clear history", desc: "wipe session memory for this repo" },
|
|
44
|
+
{ cmd: "/memory", desc: "memory commands" },
|
|
45
|
+
{ cmd: "/memory list", desc: "list all memories for this repo" },
|
|
46
|
+
{ cmd: "/memory add", desc: "add a memory" },
|
|
47
|
+
{ cmd: "/memory delete", desc: "delete a memory by id" },
|
|
48
|
+
{ cmd: "/memory clear", desc: "clear all memories" },
|
|
49
|
+
{ cmd: "/provider", desc: "configure AI provider" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
// ── Tool helpers ──────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const TOOL_ICONS: Record<string, string> = {
|
|
55
|
+
bash: "$",
|
|
56
|
+
read: "r",
|
|
57
|
+
write: "w",
|
|
58
|
+
grep: "/",
|
|
59
|
+
ls: "d",
|
|
60
|
+
remember: "·",
|
|
61
|
+
search: "?",
|
|
62
|
+
scrape: "↓",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const SAFE_TOOLS = new Set(["read", "grep", "ls", "remember", "search", "scrape"]);
|
|
66
|
+
|
|
67
|
+
function getToolLabel(tool: string, args: unknown): string {
|
|
68
|
+
if (!args || typeof args !== "object") return tool;
|
|
69
|
+
const a = args as Record<string, unknown>;
|
|
70
|
+
switch (tool) {
|
|
71
|
+
case "read":
|
|
72
|
+
return String(a.path ?? a.file_path ?? "");
|
|
73
|
+
case "write":
|
|
74
|
+
return String(a.path ?? a.file_path ?? a.filename ?? "");
|
|
75
|
+
case "bash":
|
|
76
|
+
return String(a.command ?? a.cmd ?? "");
|
|
77
|
+
case "grep": {
|
|
78
|
+
const p = String(a.pattern ?? "");
|
|
79
|
+
const g = String(a.glob ?? "");
|
|
80
|
+
return g ? `${p} ${g}` : p;
|
|
81
|
+
}
|
|
82
|
+
case "ls":
|
|
83
|
+
return String(a.path ?? ".");
|
|
84
|
+
case "remember": {
|
|
85
|
+
const c = String(a.content ?? "");
|
|
86
|
+
return c.length > 80 ? c.slice(0, 80) + "…" : c;
|
|
87
|
+
}
|
|
88
|
+
default:
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function summarizeResult(result: string): string {
|
|
94
|
+
const first = result.split("\n")[0] ?? "";
|
|
95
|
+
return first.length > 120 ? first.slice(0, 120) + "…" : first;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Command palette ───────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
function CommandPalette({ query }: { query: string }) {
|
|
101
|
+
const q = query.toLowerCase();
|
|
102
|
+
const matches = COMMANDS.filter((c) => c.cmd.startsWith(q));
|
|
103
|
+
if (!matches.length) return null;
|
|
104
|
+
return (
|
|
105
|
+
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
|
|
106
|
+
{matches.map((c, i) => (
|
|
107
|
+
<Box key={i} gap={2}>
|
|
108
|
+
<Text
|
|
109
|
+
color={c.cmd === query ? ACCENT : "white"}
|
|
110
|
+
bold={c.cmd === query}
|
|
111
|
+
>
|
|
112
|
+
{c.cmd}
|
|
113
|
+
</Text>
|
|
114
|
+
<Text color="gray" dimColor>
|
|
115
|
+
{c.desc}
|
|
116
|
+
</Text>
|
|
117
|
+
</Box>
|
|
118
|
+
))}
|
|
119
|
+
</Box>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Main runner ───────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
export function ChatRunner({
|
|
126
|
+
repoPath,
|
|
127
|
+
autoForce = false,
|
|
128
|
+
initialMessage,
|
|
129
|
+
dev = false,
|
|
130
|
+
single = false,
|
|
131
|
+
sessionId,
|
|
132
|
+
}: {
|
|
133
|
+
repoPath: string;
|
|
134
|
+
autoForce?: boolean;
|
|
135
|
+
initialMessage?: string;
|
|
136
|
+
dev?: boolean;
|
|
137
|
+
single?: boolean;
|
|
138
|
+
sessionId?: string;
|
|
139
|
+
}) {
|
|
140
|
+
const [stage, setStage] = useState<"idle" | "thinking">("idle");
|
|
141
|
+
const [showProvider, setShowProvider] = useState(false);
|
|
142
|
+
const [committed, setCommitted] = useState<UIMessage[]>([]);
|
|
143
|
+
const [currentChunk, setCurrentChunk] = useState("");
|
|
144
|
+
const [autoApprove, setAutoApprove] = useState(autoForce);
|
|
145
|
+
const [forceApprove, setForceApprove] = useState(autoForce);
|
|
146
|
+
const forceApproveRef = useRef(autoForce);
|
|
147
|
+
const [approvalRequest, setApprovalRequest] = useState<{
|
|
148
|
+
tool: string;
|
|
149
|
+
args: unknown;
|
|
150
|
+
label: string;
|
|
151
|
+
} | null>(null);
|
|
152
|
+
const approvalResolveRef = useRef<((approved: boolean) => void) | null>(null);
|
|
153
|
+
|
|
154
|
+
// session:
|
|
155
|
+
// --session <id> → resume if exists, else create with that exact id
|
|
156
|
+
// --single → resume latest session for repo (or fresh)
|
|
157
|
+
// default → fresh session
|
|
158
|
+
const sessionRef = useRef(
|
|
159
|
+
sessionId
|
|
160
|
+
? (loadSession(sessionId) ?? createSessionWithId(sessionId, repoPath))
|
|
161
|
+
: single
|
|
162
|
+
? (getLatestSession(repoPath) ?? createSession(repoPath))
|
|
163
|
+
: createSession(repoPath),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
167
|
+
const pendingToolRef = useRef<{ tool: string; args: unknown } | null>(null);
|
|
168
|
+
|
|
169
|
+
const isThinking = stage === "thinking";
|
|
170
|
+
const thinkingPhrase = useThinkingPhrase(isThinking);
|
|
171
|
+
const thinkingTip = useThinkingTip(isThinking);
|
|
172
|
+
const thinkingTimer = useThinkingTimer(isThinking);
|
|
173
|
+
|
|
174
|
+
const {
|
|
175
|
+
inputValue,
|
|
176
|
+
setInputValue,
|
|
177
|
+
inputKey,
|
|
178
|
+
pushHistory,
|
|
179
|
+
historyUp,
|
|
180
|
+
historyDown,
|
|
181
|
+
clear,
|
|
182
|
+
} = useChatInput(initialMessage);
|
|
183
|
+
|
|
184
|
+
const pushMsg = (msg: UIMessage) => setCommitted((prev) => [...prev, msg]);
|
|
185
|
+
|
|
186
|
+
// ── Keyboard ───────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
useInput((input, key) => {
|
|
189
|
+
if (key.ctrl && input === "c") process.exit(0);
|
|
190
|
+
|
|
191
|
+
if (approvalRequest) {
|
|
192
|
+
if (input === "y") {
|
|
193
|
+
approvalResolveRef.current?.(true);
|
|
194
|
+
approvalResolveRef.current = null;
|
|
195
|
+
setApprovalRequest(null);
|
|
196
|
+
} else if (input === "n") {
|
|
197
|
+
approvalResolveRef.current?.(false);
|
|
198
|
+
approvalResolveRef.current = null;
|
|
199
|
+
setApprovalRequest(null);
|
|
200
|
+
} else if (input === "a") {
|
|
201
|
+
forceApproveRef.current = true;
|
|
202
|
+
setForceApprove(true);
|
|
203
|
+
setAutoApprove(true);
|
|
204
|
+
approvalResolveRef.current?.(true);
|
|
205
|
+
approvalResolveRef.current = null;
|
|
206
|
+
setApprovalRequest(null);
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (key.ctrl && input === "f" && stage === "idle") {
|
|
212
|
+
if (forceApprove) {
|
|
213
|
+
forceApproveRef.current = false;
|
|
214
|
+
setForceApprove(false);
|
|
215
|
+
setAutoApprove(false);
|
|
216
|
+
pushMsg({
|
|
217
|
+
role: "assistant",
|
|
218
|
+
type: "text",
|
|
219
|
+
content: "Force-all mode OFF — tools will ask for permission again.",
|
|
220
|
+
});
|
|
221
|
+
} else {
|
|
222
|
+
forceApproveRef.current = true;
|
|
223
|
+
setForceApprove(true);
|
|
224
|
+
setAutoApprove(true);
|
|
225
|
+
pushMsg({
|
|
226
|
+
role: "assistant",
|
|
227
|
+
type: "text",
|
|
228
|
+
content:
|
|
229
|
+
"⚡⚡ Force-all mode ON (dangerous) — ALL tools auto-approved including shell and writes. Type /auto --force-all again to disable.",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (stage === "thinking" && key.escape) {
|
|
236
|
+
abortRef.current?.abort();
|
|
237
|
+
abortRef.current = null;
|
|
238
|
+
setCurrentChunk("");
|
|
239
|
+
setStage("idle");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (stage === "idle") {
|
|
244
|
+
if (key.upArrow) {
|
|
245
|
+
historyUp();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (key.downArrow) {
|
|
249
|
+
historyDown();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (key.tab && inputValue.startsWith("/")) {
|
|
253
|
+
const match = COMMANDS.find((c) =>
|
|
254
|
+
c.cmd.startsWith(inputValue.toLowerCase()),
|
|
255
|
+
);
|
|
256
|
+
if (match) setInputValue(match.cmd);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ── Send message ───────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
const sendMessage = async (text: string) => {
|
|
265
|
+
if (!text.trim() || stage !== "idle") return;
|
|
266
|
+
|
|
267
|
+
pushHistory(text);
|
|
268
|
+
|
|
269
|
+
if (text.startsWith("/")) {
|
|
270
|
+
if (
|
|
271
|
+
handleCommand(text, {
|
|
272
|
+
repoPath,
|
|
273
|
+
autoApprove,
|
|
274
|
+
forceApprove,
|
|
275
|
+
setAutoApprove,
|
|
276
|
+
setForceApprove,
|
|
277
|
+
pushMsg,
|
|
278
|
+
resetSession: () => {
|
|
279
|
+
sessionRef.current = createSession(repoPath);
|
|
280
|
+
},
|
|
281
|
+
openProvider: () => setShowProvider(true),
|
|
282
|
+
})
|
|
283
|
+
)
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// dev mode — output structured JSON to stdout and exit
|
|
288
|
+
if (dev) {
|
|
289
|
+
sessionRef.current = addMessage(sessionRef.current, "user", text);
|
|
290
|
+
|
|
291
|
+
const devTools: { tool: string; args: unknown; result: unknown }[] = [];
|
|
292
|
+
try {
|
|
293
|
+
await chat({
|
|
294
|
+
messages: getMessages(sessionRef.current),
|
|
295
|
+
system: getSystemPrompt(repoPath),
|
|
296
|
+
onChunk: () => {},
|
|
297
|
+
onToolCall: (tool, args) => {
|
|
298
|
+
devTools.push({ tool, args, result: null });
|
|
299
|
+
},
|
|
300
|
+
onToolResult: (tool, result) => {
|
|
301
|
+
const entry = [...devTools]
|
|
302
|
+
.reverse()
|
|
303
|
+
.find((t) => t.tool === tool && t.result === null);
|
|
304
|
+
if (entry) entry.result = result;
|
|
305
|
+
},
|
|
306
|
+
onFinish: (fullText, responseMessages, model) => {
|
|
307
|
+
if (!single) {
|
|
308
|
+
sessionRef.current = appendMessages(
|
|
309
|
+
sessionRef.current,
|
|
310
|
+
responseMessages,
|
|
311
|
+
);
|
|
312
|
+
saveSession(sessionRef.current);
|
|
313
|
+
}
|
|
314
|
+
process.stdout.write(
|
|
315
|
+
JSON.stringify({
|
|
316
|
+
message: fullText,
|
|
317
|
+
model,
|
|
318
|
+
sessionId: sessionRef.current.id,
|
|
319
|
+
tools: devTools,
|
|
320
|
+
}) + "\n",
|
|
321
|
+
);
|
|
322
|
+
process.exit(0);
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
} catch (err) {
|
|
326
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
327
|
+
process.stdout.write(JSON.stringify({ error: msg }) + "\n");
|
|
328
|
+
process.exit(1);
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
pushMsg({ role: "user", type: "text", content: text });
|
|
334
|
+
sessionRef.current = addMessage(sessionRef.current, "user", text);
|
|
335
|
+
|
|
336
|
+
setStage("thinking");
|
|
337
|
+
setCurrentChunk("");
|
|
338
|
+
|
|
339
|
+
const abort = new AbortController();
|
|
340
|
+
abortRef.current = abort;
|
|
341
|
+
|
|
342
|
+
abort.signal.addEventListener("abort", () => {
|
|
343
|
+
if (approvalResolveRef.current) {
|
|
344
|
+
approvalResolveRef.current(false);
|
|
345
|
+
approvalResolveRef.current = null;
|
|
346
|
+
setApprovalRequest(null);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
await chat({
|
|
352
|
+
messages: getMessages(sessionRef.current),
|
|
353
|
+
system: getSystemPrompt(repoPath),
|
|
354
|
+
onBeforeToolCall: (tool, args) => {
|
|
355
|
+
if (forceApproveRef.current || SAFE_TOOLS.has(tool))
|
|
356
|
+
return Promise.resolve(true);
|
|
357
|
+
const label = getToolLabel(tool, args);
|
|
358
|
+
return new Promise((resolve) => {
|
|
359
|
+
setApprovalRequest({ tool, args, label });
|
|
360
|
+
approvalResolveRef.current = resolve;
|
|
361
|
+
});
|
|
362
|
+
},
|
|
363
|
+
onChunk: (chunk) => {
|
|
364
|
+
if (!abort.signal.aborted) setCurrentChunk((prev) => prev + chunk);
|
|
365
|
+
},
|
|
366
|
+
onToolCall: (tool, args) => {
|
|
367
|
+
if (!abort.signal.aborted) pendingToolRef.current = { tool, args };
|
|
368
|
+
},
|
|
369
|
+
onToolResult: (tool, result) => {
|
|
370
|
+
if (!abort.signal.aborted && pendingToolRef.current) {
|
|
371
|
+
const { tool: t, args } = pendingToolRef.current;
|
|
372
|
+
const label = (getToolLabel(t, args) || TOOL_ICONS[t]) ?? "·";
|
|
373
|
+
const a = args as Record<string, unknown>;
|
|
374
|
+
|
|
375
|
+
let resultStr: string;
|
|
376
|
+
let diff: { prev: string; next: string } | undefined;
|
|
377
|
+
|
|
378
|
+
if (t === "write" && result && typeof result === "object") {
|
|
379
|
+
const r = result as { ok: boolean; prevContent: string | null };
|
|
380
|
+
resultStr = r.ok ? "ok" : "error";
|
|
381
|
+
if (r.ok && typeof a.content === "string") {
|
|
382
|
+
diff = { prev: r.prevContent ?? "", next: a.content };
|
|
383
|
+
}
|
|
384
|
+
} else {
|
|
385
|
+
resultStr = summarizeResult(
|
|
386
|
+
typeof result === "string" ? result : JSON.stringify(result),
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
pushMsg({
|
|
391
|
+
role: "assistant",
|
|
392
|
+
type: "tool",
|
|
393
|
+
toolName: t,
|
|
394
|
+
content: label,
|
|
395
|
+
result: resultStr,
|
|
396
|
+
approved: true,
|
|
397
|
+
diff,
|
|
398
|
+
});
|
|
399
|
+
pendingToolRef.current = null;
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
onFinish: (fullText, responseMessages) => {
|
|
403
|
+
if (!abort.signal.aborted) {
|
|
404
|
+
// always save full response messages (includes tool calls) for context
|
|
405
|
+
sessionRef.current = appendMessages(
|
|
406
|
+
sessionRef.current,
|
|
407
|
+
responseMessages,
|
|
408
|
+
);
|
|
409
|
+
if (!single) saveSession(sessionRef.current);
|
|
410
|
+
|
|
411
|
+
if (fullText.trim()) {
|
|
412
|
+
pushMsg({ role: "assistant", type: "text", content: fullText });
|
|
413
|
+
}
|
|
414
|
+
if (single) process.exit(0);
|
|
415
|
+
}
|
|
416
|
+
setCurrentChunk("");
|
|
417
|
+
setStage("idle");
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
} catch (err) {
|
|
421
|
+
if (!abort.signal.aborted) {
|
|
422
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
423
|
+
pushMsg({ role: "assistant", type: "text", content: `Error: ${msg}` });
|
|
424
|
+
}
|
|
425
|
+
setCurrentChunk("");
|
|
426
|
+
setStage("idle");
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// ── Auto-send initial message ──────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
const didAutoSend = useRef(false);
|
|
433
|
+
React.useEffect(() => {
|
|
434
|
+
if (initialMessage && !didAutoSend.current) {
|
|
435
|
+
didAutoSend.current = true;
|
|
436
|
+
sendMessage(initialMessage);
|
|
437
|
+
}
|
|
438
|
+
}, []);
|
|
439
|
+
|
|
440
|
+
// ── Render ─────────────────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<Box flexDirection="column">
|
|
444
|
+
<Static items={HEADER_ITEMS}>
|
|
445
|
+
{(_, i) => (
|
|
446
|
+
<AppHeader key={i} model={getActiveModelName()} repoPath={repoPath} />
|
|
447
|
+
)}
|
|
448
|
+
</Static>
|
|
449
|
+
<Static items={committed}>
|
|
450
|
+
{(msg, i) => <StaticMessage key={i} msg={msg} />}
|
|
451
|
+
</Static>
|
|
452
|
+
|
|
453
|
+
{stage === "thinking" && (
|
|
454
|
+
<Box flexDirection="column">
|
|
455
|
+
{currentChunk ? (
|
|
456
|
+
<Box gap={1}>
|
|
457
|
+
<Text color={ACCENT}>●</Text>
|
|
458
|
+
<MessageBody content={currentChunk} />
|
|
459
|
+
</Box>
|
|
460
|
+
) : (
|
|
461
|
+
<>
|
|
462
|
+
<Box gap={1}>
|
|
463
|
+
<Text color={ACCENT}>
|
|
464
|
+
<Spinner type="star"></Spinner>
|
|
465
|
+
</Text>
|
|
466
|
+
<Text color={ACCENT}>{thinkingPhrase}</Text>
|
|
467
|
+
</Box>
|
|
468
|
+
<Box marginLeft={2} marginBottom={1}>
|
|
469
|
+
<Text color="gray" dimColor>
|
|
470
|
+
└ tip: {thinkingTip}
|
|
471
|
+
</Text>
|
|
472
|
+
</Box>
|
|
473
|
+
</>
|
|
474
|
+
)}
|
|
475
|
+
</Box>
|
|
476
|
+
)}
|
|
477
|
+
|
|
478
|
+
{approvalRequest && (
|
|
479
|
+
<Box flexDirection="column" marginTop={1} marginLeft={2} gap={0}>
|
|
480
|
+
<Box gap={1}>
|
|
481
|
+
<Text color="yellow">?</Text>
|
|
482
|
+
<Text color={ACCENT}>
|
|
483
|
+
{TOOL_ICONS[approvalRequest.tool] ?? "·"}
|
|
484
|
+
</Text>
|
|
485
|
+
<Text color="white">
|
|
486
|
+
{approvalRequest.label || approvalRequest.tool}
|
|
487
|
+
</Text>
|
|
488
|
+
</Box>
|
|
489
|
+
<Box gap={1} marginLeft={2}>
|
|
490
|
+
<Text color="gray" dimColor>
|
|
491
|
+
allow?
|
|
492
|
+
</Text>
|
|
493
|
+
<Text color={GREEN}>y</Text>
|
|
494
|
+
<Text color="gray" dimColor>
|
|
495
|
+
{" "}
|
|
496
|
+
yes ·{" "}
|
|
497
|
+
</Text>
|
|
498
|
+
<Text color={RED}>n</Text>
|
|
499
|
+
<Text color="gray" dimColor>
|
|
500
|
+
{" "}
|
|
501
|
+
no ·{" "}
|
|
502
|
+
</Text>
|
|
503
|
+
<Text color={ACCENT}>a</Text>
|
|
504
|
+
<Text color="gray" dimColor>
|
|
505
|
+
{" "}
|
|
506
|
+
allow all
|
|
507
|
+
</Text>
|
|
508
|
+
</Box>
|
|
509
|
+
</Box>
|
|
510
|
+
)}
|
|
511
|
+
|
|
512
|
+
{showProvider && (
|
|
513
|
+
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
|
514
|
+
<ProviderSetup
|
|
515
|
+
onDone={() => {
|
|
516
|
+
setShowProvider(false);
|
|
517
|
+
pushMsg({
|
|
518
|
+
role: "assistant",
|
|
519
|
+
type: "text",
|
|
520
|
+
content: `Provider updated. Now using **${getActiveModelName()}**.`,
|
|
521
|
+
});
|
|
522
|
+
}}
|
|
523
|
+
/>
|
|
524
|
+
</Box>
|
|
525
|
+
)}
|
|
526
|
+
|
|
527
|
+
{stage === "idle" && !showProvider && (
|
|
528
|
+
<Box flexDirection="column">
|
|
529
|
+
{inputValue.startsWith("/") && <CommandPalette query={inputValue} />}
|
|
530
|
+
<InputBox
|
|
531
|
+
value={inputValue}
|
|
532
|
+
onChange={(v) => setInputValue(v)}
|
|
533
|
+
onSubmit={(val) => {
|
|
534
|
+
if (val.trim()) sendMessage(val.trim());
|
|
535
|
+
clear();
|
|
536
|
+
}}
|
|
537
|
+
inputKey={inputKey}
|
|
538
|
+
/>
|
|
539
|
+
</Box>
|
|
540
|
+
)}
|
|
541
|
+
|
|
542
|
+
<ShortcutBar
|
|
543
|
+
autoApprove={autoApprove}
|
|
544
|
+
forceApprove={forceApprove}
|
|
545
|
+
isThinking={isThinking}
|
|
546
|
+
model={getActiveModelName()}
|
|
547
|
+
/>
|
|
548
|
+
</Box>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { MessageBody } from "@ridit/ink-ui";
|
|
4
|
+
import { ACCENT, GREEN, RED } from "../../colors";
|
|
5
|
+
|
|
6
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export type UIMessage =
|
|
9
|
+
| { role: "user" | "assistant"; type: "text"; content: string }
|
|
10
|
+
| {
|
|
11
|
+
role: "assistant";
|
|
12
|
+
type: "tool";
|
|
13
|
+
toolName: string;
|
|
14
|
+
content: string;
|
|
15
|
+
result: string;
|
|
16
|
+
approved: boolean;
|
|
17
|
+
diff?: { prev: string; next: string };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// ── Diff ──────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
type DiffLine = { type: "add" | "remove" | "context"; content: string };
|
|
23
|
+
|
|
24
|
+
function computeDiff(prev: string, next: string, context = 2): DiffLine[] {
|
|
25
|
+
const a = prev.split("\n");
|
|
26
|
+
const b = next.split("\n");
|
|
27
|
+
if (a.length > 400 || b.length > 400) {
|
|
28
|
+
return b.map((content) => ({ type: "add" as const, content }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const m = a.length, n = b.length;
|
|
32
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0) as number[]);
|
|
33
|
+
for (let i = 1; i <= m; i++)
|
|
34
|
+
for (let j = 1; j <= n; j++)
|
|
35
|
+
dp[i]![j] = a[i - 1] === b[j - 1] ? dp[i - 1]![j - 1]! + 1 : Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!);
|
|
36
|
+
|
|
37
|
+
const edits: DiffLine[] = [];
|
|
38
|
+
let i = m, j = n;
|
|
39
|
+
while (i > 0 || j > 0) {
|
|
40
|
+
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
|
41
|
+
edits.unshift({ type: "context", content: a[i - 1]! });
|
|
42
|
+
i--; j--;
|
|
43
|
+
} else if (j > 0 && (i === 0 || dp[i]![j - 1]! >= dp[i - 1]![j]!)) {
|
|
44
|
+
edits.unshift({ type: "add", content: b[j - 1]! });
|
|
45
|
+
j--;
|
|
46
|
+
} else {
|
|
47
|
+
edits.unshift({ type: "remove", content: a[i - 1]! });
|
|
48
|
+
i--;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const keep = new Set<number>();
|
|
53
|
+
edits.forEach((e, idx) => {
|
|
54
|
+
if (e.type !== "context") {
|
|
55
|
+
for (let k = Math.max(0, idx - context); k <= Math.min(edits.length - 1, idx + context); k++)
|
|
56
|
+
keep.add(k);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return edits.filter((_, idx) => keep.has(idx));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Tool icons ────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const TOOL_ICONS: Record<string, string> = {
|
|
66
|
+
bash: "$",
|
|
67
|
+
read: "r",
|
|
68
|
+
write: "w",
|
|
69
|
+
grep: "/",
|
|
70
|
+
ls: "d",
|
|
71
|
+
remember: "·",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ── Static message renderer ───────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export function StaticMessage({ msg }: { msg: UIMessage }) {
|
|
77
|
+
if (msg.role === "user") {
|
|
78
|
+
return (
|
|
79
|
+
<Box marginBottom={1} gap={1}>
|
|
80
|
+
<Text color={ACCENT}>{">"}</Text>
|
|
81
|
+
<Text color="white" bold>
|
|
82
|
+
{msg.content}
|
|
83
|
+
</Text>
|
|
84
|
+
</Box>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (msg.type === "tool") {
|
|
89
|
+
const icon = TOOL_ICONS[msg.toolName] ?? "·";
|
|
90
|
+
|
|
91
|
+
if (msg.toolName === "write" && msg.diff) {
|
|
92
|
+
const lines = computeDiff(msg.diff.prev, msg.diff.next);
|
|
93
|
+
const additions = lines.filter((l) => l.type === "add").length;
|
|
94
|
+
const deletions = lines.filter((l) => l.type === "remove").length;
|
|
95
|
+
return (
|
|
96
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
97
|
+
<Box gap={1}>
|
|
98
|
+
<Text color={ACCENT}>{icon}</Text>
|
|
99
|
+
<Text color="gray">{msg.content}</Text>
|
|
100
|
+
{lines.length > 0 && (
|
|
101
|
+
<>
|
|
102
|
+
<Text color={GREEN} dimColor>+{additions}</Text>
|
|
103
|
+
<Text color={RED} dimColor>-{deletions}</Text>
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
</Box>
|
|
107
|
+
<Box flexDirection="column" marginLeft={2}>
|
|
108
|
+
{lines.map((line, i) => (
|
|
109
|
+
<Text
|
|
110
|
+
key={i}
|
|
111
|
+
color={line.type === "add" ? GREEN : line.type === "remove" ? RED : "gray"}
|
|
112
|
+
dimColor={line.type === "context"}
|
|
113
|
+
>
|
|
114
|
+
{line.type === "add" ? "+ " : line.type === "remove" ? "- " : " "}
|
|
115
|
+
{line.content}
|
|
116
|
+
</Text>
|
|
117
|
+
))}
|
|
118
|
+
</Box>
|
|
119
|
+
</Box>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
125
|
+
<Box gap={1}>
|
|
126
|
+
<Text color={msg.approved ? ACCENT : RED}>{icon}</Text>
|
|
127
|
+
<Text color={msg.approved ? "gray" : RED} dimColor={!msg.approved}>
|
|
128
|
+
{msg.content}
|
|
129
|
+
</Text>
|
|
130
|
+
{!msg.approved && <Text color={RED}>denied</Text>}
|
|
131
|
+
</Box>
|
|
132
|
+
{msg.approved && msg.result && (
|
|
133
|
+
<Box gap={1} marginLeft={2}>
|
|
134
|
+
<Text color="gray" dimColor>{"└"}</Text>
|
|
135
|
+
<Text color="gray" dimColor>
|
|
136
|
+
{msg.result.split("\n")[0]?.slice(0, 120)}
|
|
137
|
+
{(msg.result.split("\n")[0]?.length ?? 0) > 120 ? "…" : ""}
|
|
138
|
+
</Text>
|
|
139
|
+
</Box>
|
|
140
|
+
)}
|
|
141
|
+
</Box>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// assistant text
|
|
146
|
+
return (
|
|
147
|
+
<Box marginBottom={1} gap={1}>
|
|
148
|
+
<Text color={ACCENT}>●</Text>
|
|
149
|
+
<MessageBody content={msg.content} />
|
|
150
|
+
</Box>
|
|
151
|
+
);
|
|
152
|
+
}
|