@ridit/lens 0.2.0 → 0.2.2
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 +2075 -1493
- package/package.json +1 -1
- package/src/components/chat/ChatMessage.tsx +15 -1
- package/src/components/chat/ChatOverlays.tsx +81 -47
- package/src/components/chat/ChatRunner.tsx +553 -369
- package/src/index.tsx +3 -0
- package/src/prompts/fewshot.ts +377 -0
- package/src/prompts/index.ts +2 -0
- package/src/prompts/system.ts +167 -0
- package/src/tools/files.ts +261 -0
- package/src/tools/index.ts +13 -0
- package/src/tools/pdf.ts +106 -0
- package/src/tools/shell.ts +96 -0
- package/src/tools/web.ts +216 -0
- package/src/utils/chat.ts +114 -1463
- package/src/utils/chatHistory.ts +121 -0
- package/src/utils/files.ts +1 -0
- package/src/utils/memory.ts +137 -0
- package/src/utils/tools/builtins.ts +324 -0
- package/src/utils/tools/registry.ts +119 -0
- package/src/utils/history.ts +0 -86
|
@@ -17,21 +17,17 @@ import {
|
|
|
17
17
|
extractGithubUrl,
|
|
18
18
|
toCloneUrl,
|
|
19
19
|
parseCloneTag,
|
|
20
|
-
runShell,
|
|
21
|
-
fetchUrl,
|
|
22
|
-
readFile,
|
|
23
|
-
readFolder,
|
|
24
|
-
grepFiles,
|
|
25
|
-
deleteFile,
|
|
26
|
-
deleteFolder,
|
|
27
|
-
openUrl,
|
|
28
|
-
generatePdf,
|
|
29
|
-
writeFile,
|
|
30
20
|
buildSystemPrompt,
|
|
31
21
|
parseResponse,
|
|
32
22
|
callChat,
|
|
33
|
-
searchWeb,
|
|
34
23
|
} from "../../utils/chat";
|
|
24
|
+
import {
|
|
25
|
+
saveChat,
|
|
26
|
+
loadChat,
|
|
27
|
+
listChats,
|
|
28
|
+
deleteChat,
|
|
29
|
+
getChatNameSuggestions,
|
|
30
|
+
} from "../../utils/chatHistory";
|
|
35
31
|
import { StaticMessage } from "./ChatMessage";
|
|
36
32
|
import {
|
|
37
33
|
PermissionPrompt,
|
|
@@ -50,31 +46,56 @@ import { TimelineRunner } from "../timeline/TimelineRunner";
|
|
|
50
46
|
import type { Provider } from "../../types/config";
|
|
51
47
|
import type { Message, ChatStage } from "../../types/chat";
|
|
52
48
|
import {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
appendMemory,
|
|
50
|
+
buildMemorySummary,
|
|
51
|
+
clearRepoMemory,
|
|
52
|
+
addMemory,
|
|
53
|
+
deleteMemory,
|
|
54
|
+
listMemories,
|
|
55
|
+
} from "../../utils/memory";
|
|
57
56
|
import { readLensFile } from "../../utils/lensfile";
|
|
58
57
|
import { ReviewCommand } from "../../commands/review";
|
|
58
|
+
import { registry } from "../../utils/tools/registry";
|
|
59
59
|
|
|
60
60
|
const COMMANDS = [
|
|
61
61
|
{ cmd: "/timeline", desc: "browse commit history" },
|
|
62
62
|
{ cmd: "/clear history", desc: "wipe session memory for this repo" },
|
|
63
|
-
{ cmd: "/review", desc: "review current
|
|
63
|
+
{ cmd: "/review", desc: "review current codebase" },
|
|
64
64
|
{ cmd: "/auto", desc: "toggle auto-approve for read/search tools" },
|
|
65
|
+
{ cmd: "/chat", desc: "chat history commands" },
|
|
66
|
+
{ cmd: "/chat list", desc: "list saved chats for this repo" },
|
|
67
|
+
{ cmd: "/chat load", desc: "load a saved chat by name" },
|
|
68
|
+
{ cmd: "/chat rename", desc: "rename the current chat" },
|
|
69
|
+
{ cmd: "/chat delete", desc: "delete a saved chat by name" },
|
|
70
|
+
{ cmd: "/memory", desc: "memory commands" },
|
|
71
|
+
{ cmd: "/memory list", desc: "list all memories for this repo" },
|
|
72
|
+
{ cmd: "/memory add", desc: "add a memory" },
|
|
73
|
+
{ cmd: "/memory delete", desc: "delete a memory by id" },
|
|
74
|
+
{ cmd: "/memory clear", desc: "clear all memories for this repo" },
|
|
65
75
|
];
|
|
66
76
|
|
|
67
77
|
function CommandPalette({
|
|
68
78
|
query,
|
|
69
79
|
onSelect,
|
|
80
|
+
recentChats,
|
|
70
81
|
}: {
|
|
71
82
|
query: string;
|
|
72
83
|
onSelect: (cmd: string) => void;
|
|
84
|
+
recentChats: string[];
|
|
73
85
|
}) {
|
|
74
86
|
const q = query.toLowerCase();
|
|
87
|
+
const isChatLoad = q.startsWith("/chat load") || q.startsWith("/chat delete");
|
|
88
|
+
const chatFilter = isChatLoad
|
|
89
|
+
? q.startsWith("/chat load")
|
|
90
|
+
? q.slice("/chat load".length).trim()
|
|
91
|
+
: q.slice("/chat delete".length).trim()
|
|
92
|
+
: "";
|
|
93
|
+
const filteredChats = chatFilter
|
|
94
|
+
? recentChats.filter((n) => n.toLowerCase().includes(chatFilter))
|
|
95
|
+
: recentChats;
|
|
75
96
|
const matches = COMMANDS.filter((c) => c.cmd.startsWith(q));
|
|
76
|
-
if (!matches.length) return null;
|
|
77
|
-
|
|
97
|
+
if (!matches.length && !isChatLoad) return null;
|
|
98
|
+
if (!matches.length && isChatLoad && filteredChats.length === 0) return null;
|
|
78
99
|
return (
|
|
79
100
|
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
|
|
80
101
|
{matches.map((c, i) => {
|
|
@@ -90,6 +111,19 @@ function CommandPalette({
|
|
|
90
111
|
</Box>
|
|
91
112
|
);
|
|
92
113
|
})}
|
|
114
|
+
{isChatLoad && filteredChats.length > 0 && (
|
|
115
|
+
<Box flexDirection="column" marginTop={matches.length ? 1 : 0}>
|
|
116
|
+
<Text color="gray" dimColor>
|
|
117
|
+
{chatFilter ? `matching "${chatFilter}":` : "recent chats:"}
|
|
118
|
+
</Text>
|
|
119
|
+
{filteredChats.map((name, i) => (
|
|
120
|
+
<Box key={i} gap={1} marginLeft={2}>
|
|
121
|
+
<Text color={ACCENT}>·</Text>
|
|
122
|
+
<Text color="white">{name}</Text>
|
|
123
|
+
</Box>
|
|
124
|
+
))}
|
|
125
|
+
</Box>
|
|
126
|
+
)}
|
|
93
127
|
</Box>
|
|
94
128
|
);
|
|
95
129
|
}
|
|
@@ -106,36 +140,42 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
106
140
|
const [showTimeline, setShowTimeline] = useState(false);
|
|
107
141
|
const [showReview, setShowReview] = useState(false);
|
|
108
142
|
const [autoApprove, setAutoApprove] = useState(false);
|
|
143
|
+
const [chatName, setChatName] = useState<string | null>(null);
|
|
144
|
+
const chatNameRef = useRef<string | null>(null);
|
|
145
|
+
const [recentChats, setRecentChats] = useState<string[]>([]);
|
|
146
|
+
const inputHistoryRef = useRef<string[]>([]);
|
|
147
|
+
const historyIndexRef = useRef<number>(-1);
|
|
148
|
+
const [inputKey, setInputKey] = useState(0);
|
|
149
|
+
|
|
150
|
+
const updateChatName = (name: string) => {
|
|
151
|
+
chatNameRef.current = name;
|
|
152
|
+
setChatName(name);
|
|
153
|
+
};
|
|
109
154
|
|
|
110
|
-
// Abort controller for the currently in-flight API call.
|
|
111
|
-
// Pressing ESC while thinking aborts the request and drops the response.
|
|
112
155
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
113
|
-
|
|
114
|
-
// Cache of tool results within a single conversation turn to prevent
|
|
115
|
-
// the model from re-calling tools it already ran with the same args
|
|
116
156
|
const toolResultCache = useRef<Map<string, string>>(new Map());
|
|
117
157
|
|
|
118
|
-
|
|
119
|
-
|
|
158
|
+
// When the user approves a tool that has chained remainder calls, we
|
|
159
|
+
// automatically approve subsequent tools in the same chain so the user
|
|
160
|
+
// doesn't have to press y for every file in a 10-file scaffold.
|
|
161
|
+
// This ref is set to true on the first approval and cleared when the chain ends.
|
|
162
|
+
const batchApprovedRef = useRef(false);
|
|
163
|
+
|
|
120
164
|
const thinkingPhrase = useThinkingPhrase(stage.type === "thinking");
|
|
121
165
|
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
setInputValue((v) => v + buf);
|
|
127
|
-
};
|
|
166
|
+
React.useEffect(() => {
|
|
167
|
+
const chats = listChats(repoPath);
|
|
168
|
+
setRecentChats(chats.slice(0, 10).map((c) => c.name));
|
|
169
|
+
}, [repoPath]);
|
|
128
170
|
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}, 16);
|
|
135
|
-
};
|
|
171
|
+
React.useEffect(() => {
|
|
172
|
+
if (chatNameRef.current && allMessages.length > 1) {
|
|
173
|
+
saveChat(chatNameRef.current, repoPath, allMessages);
|
|
174
|
+
}
|
|
175
|
+
}, [allMessages]);
|
|
136
176
|
|
|
137
177
|
const handleError = (currentAll: Message[]) => (err: unknown) => {
|
|
138
|
-
|
|
178
|
+
batchApprovedRef.current = false;
|
|
139
179
|
if (err instanceof Error && err.name === "AbortError") {
|
|
140
180
|
setStage({ type: "idle" });
|
|
141
181
|
return;
|
|
@@ -155,15 +195,38 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
155
195
|
currentAll: Message[],
|
|
156
196
|
signal: AbortSignal,
|
|
157
197
|
) => {
|
|
158
|
-
// If ESC was pressed before we got here, silently drop the response
|
|
159
198
|
if (signal.aborted) {
|
|
199
|
+
batchApprovedRef.current = false;
|
|
160
200
|
setStage({ type: "idle" });
|
|
161
201
|
return;
|
|
162
202
|
}
|
|
163
203
|
|
|
164
|
-
|
|
204
|
+
// Handle inline memory operations
|
|
205
|
+
const memAddMatches = [
|
|
206
|
+
...raw.matchAll(/<memory-add>([\s\S]*?)<\/memory-add>/g),
|
|
207
|
+
];
|
|
208
|
+
const memDelMatches = [
|
|
209
|
+
...raw.matchAll(/<memory-delete>([\s\S]*?)<\/memory-delete>/g),
|
|
210
|
+
];
|
|
211
|
+
for (const match of memAddMatches) {
|
|
212
|
+
const content = match[1]!.trim();
|
|
213
|
+
if (content) addMemory(content, repoPath);
|
|
214
|
+
}
|
|
215
|
+
for (const match of memDelMatches) {
|
|
216
|
+
const id = match[1]!.trim();
|
|
217
|
+
if (id) deleteMemory(id, repoPath);
|
|
218
|
+
}
|
|
219
|
+
const cleanRaw = raw
|
|
220
|
+
.replace(/<memory-add>[\s\S]*?<\/memory-add>/g, "")
|
|
221
|
+
.replace(/<memory-delete>[\s\S]*?<\/memory-delete>/g, "")
|
|
222
|
+
.trim();
|
|
223
|
+
|
|
224
|
+
const parsed = parseResponse(cleanRaw);
|
|
225
|
+
|
|
226
|
+
// ── changes (diff preview UI) ──────────────────────────────────────────
|
|
165
227
|
|
|
166
228
|
if (parsed.kind === "changes") {
|
|
229
|
+
batchApprovedRef.current = false;
|
|
167
230
|
if (parsed.patches.length === 0) {
|
|
168
231
|
const msg: Message = {
|
|
169
232
|
role: "assistant",
|
|
@@ -196,52 +259,10 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
196
259
|
return;
|
|
197
260
|
}
|
|
198
261
|
|
|
199
|
-
|
|
200
|
-
parsed.kind === "shell" ||
|
|
201
|
-
parsed.kind === "fetch" ||
|
|
202
|
-
parsed.kind === "read-file" ||
|
|
203
|
-
parsed.kind === "read-folder" ||
|
|
204
|
-
parsed.kind === "grep" ||
|
|
205
|
-
parsed.kind === "write-file" ||
|
|
206
|
-
parsed.kind === "delete-file" ||
|
|
207
|
-
parsed.kind === "delete-folder" ||
|
|
208
|
-
parsed.kind === "open-url" ||
|
|
209
|
-
parsed.kind === "generate-pdf" ||
|
|
210
|
-
parsed.kind === "search"
|
|
211
|
-
) {
|
|
212
|
-
let tool: Parameters<typeof PermissionPrompt>[0]["tool"];
|
|
213
|
-
if (parsed.kind === "shell") {
|
|
214
|
-
tool = { type: "shell", command: parsed.command };
|
|
215
|
-
} else if (parsed.kind === "fetch") {
|
|
216
|
-
tool = { type: "fetch", url: parsed.url };
|
|
217
|
-
} else if (parsed.kind === "read-file") {
|
|
218
|
-
tool = { type: "read-file", filePath: parsed.filePath };
|
|
219
|
-
} else if (parsed.kind === "read-folder") {
|
|
220
|
-
tool = { type: "read-folder", folderPath: parsed.folderPath };
|
|
221
|
-
} else if (parsed.kind === "grep") {
|
|
222
|
-
tool = { type: "grep", pattern: parsed.pattern, glob: parsed.glob };
|
|
223
|
-
} else if (parsed.kind === "delete-file") {
|
|
224
|
-
tool = { type: "delete-file", filePath: parsed.filePath };
|
|
225
|
-
} else if (parsed.kind === "delete-folder") {
|
|
226
|
-
tool = { type: "delete-folder", folderPath: parsed.folderPath };
|
|
227
|
-
} else if (parsed.kind === "open-url") {
|
|
228
|
-
tool = { type: "open-url", url: parsed.url };
|
|
229
|
-
} else if (parsed.kind === "generate-pdf") {
|
|
230
|
-
tool = {
|
|
231
|
-
type: "generate-pdf",
|
|
232
|
-
filePath: parsed.filePath,
|
|
233
|
-
content: parsed.pdfContent,
|
|
234
|
-
};
|
|
235
|
-
} else if (parsed.kind === "search") {
|
|
236
|
-
tool = { type: "search", query: parsed.query };
|
|
237
|
-
} else {
|
|
238
|
-
tool = {
|
|
239
|
-
type: "write-file",
|
|
240
|
-
filePath: parsed.filePath,
|
|
241
|
-
fileContent: parsed.fileContent,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
262
|
+
// ── clone (git clone UI flow) ──────────────────────────────────────────
|
|
244
263
|
|
|
264
|
+
if (parsed.kind === "clone") {
|
|
265
|
+
batchApprovedRef.current = false;
|
|
245
266
|
if (parsed.content) {
|
|
246
267
|
const preambleMsg: Message = {
|
|
247
268
|
role: "assistant",
|
|
@@ -251,238 +272,166 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
251
272
|
setAllMessages([...currentAll, preambleMsg]);
|
|
252
273
|
setCommitted((prev) => [...prev, preambleMsg]);
|
|
253
274
|
}
|
|
275
|
+
setStage({
|
|
276
|
+
type: "clone-offer",
|
|
277
|
+
repoUrl: parsed.repoUrl,
|
|
278
|
+
launchAnalysis: true,
|
|
279
|
+
});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
254
282
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
parsed.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
283
|
+
// ── text ──────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
if (parsed.kind === "text") {
|
|
286
|
+
batchApprovedRef.current = false;
|
|
287
|
+
const msg: Message = {
|
|
288
|
+
role: "assistant",
|
|
289
|
+
content: parsed.content,
|
|
290
|
+
type: "text",
|
|
291
|
+
};
|
|
292
|
+
const withMsg = [...currentAll, msg];
|
|
293
|
+
setAllMessages(withMsg);
|
|
294
|
+
setCommitted((prev) => [...prev, msg]);
|
|
295
|
+
const lastUserMsg = [...currentAll]
|
|
296
|
+
.reverse()
|
|
297
|
+
.find((m) => m.role === "user");
|
|
298
|
+
const githubUrl = lastUserMsg
|
|
299
|
+
? extractGithubUrl(lastUserMsg.content)
|
|
300
|
+
: null;
|
|
301
|
+
if (githubUrl && !clonedUrls.has(githubUrl)) {
|
|
302
|
+
setTimeout(
|
|
303
|
+
() => setStage({ type: "clone-offer", repoUrl: githubUrl }),
|
|
304
|
+
80,
|
|
305
|
+
);
|
|
306
|
+
} else {
|
|
307
|
+
setStage({ type: "idle" });
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── generic tool ──────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
const tool = registry.get(parsed.toolName);
|
|
315
|
+
if (!tool) {
|
|
316
|
+
batchApprovedRef.current = false;
|
|
317
|
+
setStage({ type: "idle" });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (parsed.content) {
|
|
322
|
+
const preambleMsg: Message = {
|
|
323
|
+
role: "assistant",
|
|
324
|
+
content: parsed.content,
|
|
325
|
+
type: "text",
|
|
326
|
+
};
|
|
327
|
+
setAllMessages([...currentAll, preambleMsg]);
|
|
328
|
+
setCommitted((prev) => [...prev, preambleMsg]);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const remainder = parsed.remainder;
|
|
332
|
+
const isSafe = tool.safe ?? false;
|
|
333
|
+
|
|
334
|
+
const executeAndContinue = async (approved: boolean) => {
|
|
335
|
+
// If the user approved this tool and there are more in the chain,
|
|
336
|
+
// mark the batch as approved so subsequent tools skip the prompt.
|
|
337
|
+
if (approved && remainder) {
|
|
338
|
+
batchApprovedRef.current = true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let result = "(denied by user)";
|
|
342
|
+
|
|
343
|
+
if (approved) {
|
|
344
|
+
const cacheKey = isSafe
|
|
345
|
+
? `${parsed.toolName}:${parsed.rawInput}`
|
|
346
|
+
: null;
|
|
347
|
+
if (cacheKey && toolResultCache.current.has(cacheKey)) {
|
|
348
|
+
result =
|
|
349
|
+
toolResultCache.current.get(cacheKey)! +
|
|
350
|
+
"\n\n[NOTE: This result was already retrieved earlier. Do not request it again.]";
|
|
351
|
+
} else {
|
|
352
|
+
try {
|
|
353
|
+
setStage({ type: "thinking" });
|
|
354
|
+
const toolResult = await tool.execute(parsed.input, {
|
|
355
|
+
repoPath,
|
|
356
|
+
messages: currentAll,
|
|
357
|
+
});
|
|
358
|
+
result = toolResult.value;
|
|
359
|
+
if (cacheKey && toolResult.kind === "text") {
|
|
360
|
+
toolResultCache.current.set(cacheKey, result);
|
|
319
361
|
}
|
|
362
|
+
} catch (err: unknown) {
|
|
363
|
+
result = `Error: ${err instanceof Error ? err.message : "failed"}`;
|
|
320
364
|
}
|
|
321
365
|
}
|
|
366
|
+
}
|
|
322
367
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
"generate-pdf": "file-written",
|
|
334
|
-
"write-file": "file-written",
|
|
335
|
-
search: "url-fetched",
|
|
336
|
-
} as const;
|
|
337
|
-
appendHistory({
|
|
338
|
-
kind: kindMap[parsed.kind as keyof typeof kindMap] ?? "shell-run",
|
|
339
|
-
detail:
|
|
340
|
-
parsed.kind === "shell"
|
|
341
|
-
? parsed.command
|
|
342
|
-
: parsed.kind === "fetch"
|
|
343
|
-
? parsed.url
|
|
344
|
-
: parsed.kind === "search"
|
|
345
|
-
? parsed.query
|
|
346
|
-
: parsed.kind === "read-folder"
|
|
347
|
-
? parsed.folderPath
|
|
348
|
-
: parsed.kind === "grep"
|
|
349
|
-
? `${parsed.pattern} ${parsed.glob}`
|
|
350
|
-
: parsed.kind === "delete-file"
|
|
351
|
-
? parsed.filePath
|
|
352
|
-
: parsed.kind === "delete-folder"
|
|
353
|
-
? parsed.folderPath
|
|
354
|
-
: parsed.kind === "open-url"
|
|
355
|
-
? parsed.url
|
|
356
|
-
: parsed.kind === "generate-pdf"
|
|
357
|
-
? parsed.filePath
|
|
358
|
-
: parsed.filePath,
|
|
359
|
-
summary: result.split("\n")[0]?.slice(0, 120) ?? "",
|
|
360
|
-
repoPath,
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const toolName =
|
|
365
|
-
parsed.kind === "shell"
|
|
366
|
-
? "shell"
|
|
367
|
-
: parsed.kind === "fetch"
|
|
368
|
-
? "fetch"
|
|
369
|
-
: parsed.kind === "read-file"
|
|
370
|
-
? "read-file"
|
|
371
|
-
: parsed.kind === "read-folder"
|
|
372
|
-
? "read-folder"
|
|
373
|
-
: parsed.kind === "grep"
|
|
374
|
-
? "grep"
|
|
375
|
-
: parsed.kind === "delete-file"
|
|
376
|
-
? "delete-file"
|
|
377
|
-
: parsed.kind === "delete-folder"
|
|
378
|
-
? "delete-folder"
|
|
379
|
-
: parsed.kind === "open-url"
|
|
380
|
-
? "open-url"
|
|
381
|
-
: parsed.kind === "generate-pdf"
|
|
382
|
-
? "generate-pdf"
|
|
383
|
-
: parsed.kind === "search"
|
|
384
|
-
? "search"
|
|
385
|
-
: "write-file";
|
|
386
|
-
|
|
387
|
-
const toolContent =
|
|
388
|
-
parsed.kind === "shell"
|
|
389
|
-
? parsed.command
|
|
390
|
-
: parsed.kind === "fetch"
|
|
391
|
-
? parsed.url
|
|
392
|
-
: parsed.kind === "search"
|
|
393
|
-
? parsed.query
|
|
394
|
-
: parsed.kind === "read-folder"
|
|
395
|
-
? parsed.folderPath
|
|
396
|
-
: parsed.kind === "grep"
|
|
397
|
-
? `${parsed.pattern} — ${parsed.glob}`
|
|
398
|
-
: parsed.kind === "delete-file"
|
|
399
|
-
? parsed.filePath
|
|
400
|
-
: parsed.kind === "delete-folder"
|
|
401
|
-
? parsed.folderPath
|
|
402
|
-
: parsed.kind === "open-url"
|
|
403
|
-
? parsed.url
|
|
404
|
-
: parsed.kind === "generate-pdf"
|
|
405
|
-
? parsed.filePath
|
|
406
|
-
: parsed.filePath;
|
|
407
|
-
|
|
408
|
-
const toolMsg: Message = {
|
|
409
|
-
role: "assistant",
|
|
410
|
-
type: "tool",
|
|
411
|
-
toolName,
|
|
412
|
-
content: toolContent,
|
|
413
|
-
result,
|
|
414
|
-
approved,
|
|
415
|
-
};
|
|
416
|
-
|
|
417
|
-
const withTool = [...currentAll, toolMsg];
|
|
418
|
-
setAllMessages(withTool);
|
|
419
|
-
setCommitted((prev) => [...prev, toolMsg]);
|
|
368
|
+
if (approved && !result.startsWith("Error:")) {
|
|
369
|
+
appendMemory({
|
|
370
|
+
kind: "shell-run",
|
|
371
|
+
detail: tool.summariseInput
|
|
372
|
+
? String(tool.summariseInput(parsed.input))
|
|
373
|
+
: parsed.rawInput,
|
|
374
|
+
summary: result.split("\n")[0]?.slice(0, 120) ?? "",
|
|
375
|
+
repoPath,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
420
378
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
379
|
+
const displayContent = tool.summariseInput
|
|
380
|
+
? String(tool.summariseInput(parsed.input))
|
|
381
|
+
: parsed.rawInput;
|
|
424
382
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
383
|
+
const toolMsg: Message = {
|
|
384
|
+
role: "assistant",
|
|
385
|
+
type: "tool",
|
|
386
|
+
toolName: parsed.toolName as any,
|
|
387
|
+
content: displayContent,
|
|
388
|
+
result,
|
|
389
|
+
approved,
|
|
429
390
|
};
|
|
430
391
|
|
|
431
|
-
|
|
432
|
-
|
|
392
|
+
const withTool = [...currentAll, toolMsg];
|
|
393
|
+
setAllMessages(withTool);
|
|
394
|
+
setCommitted((prev) => [...prev, toolMsg]);
|
|
395
|
+
|
|
396
|
+
// Chain: process remainder immediately, no API round-trip needed.
|
|
397
|
+
if (approved && remainder && remainder.length > 0) {
|
|
398
|
+
processResponse(remainder, withTool, signal);
|
|
433
399
|
return;
|
|
434
400
|
}
|
|
435
401
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
tool,
|
|
439
|
-
pendingMessages: currentAll,
|
|
440
|
-
resolve: executeAndContinue,
|
|
441
|
-
});
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
402
|
+
// Chain ended (or was never chained) — clear batch approval.
|
|
403
|
+
batchApprovedRef.current = false;
|
|
444
404
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
repoUrl: parsed.repoUrl,
|
|
458
|
-
launchAnalysis: true,
|
|
459
|
-
});
|
|
405
|
+
const nextAbort = new AbortController();
|
|
406
|
+
abortControllerRef.current = nextAbort;
|
|
407
|
+
setStage({ type: "thinking" });
|
|
408
|
+
callChat(provider!, systemPrompt, withTool, nextAbort.signal)
|
|
409
|
+
.then((r: string) => processResponse(r, withTool, nextAbort.signal))
|
|
410
|
+
.catch(handleError(withTool));
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// Auto-approve if: tool is safe, or global auto-approve is on, or we're
|
|
414
|
+
// already inside a user-approved batch chain.
|
|
415
|
+
if ((autoApprove && isSafe) || batchApprovedRef.current) {
|
|
416
|
+
executeAndContinue(true);
|
|
460
417
|
return;
|
|
461
418
|
}
|
|
462
419
|
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
if (githubUrl && !clonedUrls.has(githubUrl)) {
|
|
480
|
-
setTimeout(() => {
|
|
481
|
-
setStage({ type: "clone-offer", repoUrl: githubUrl });
|
|
482
|
-
}, 80);
|
|
483
|
-
} else {
|
|
484
|
-
setStage({ type: "idle" });
|
|
485
|
-
}
|
|
420
|
+
const permLabel = tool.permissionLabel ?? tool.name;
|
|
421
|
+
const permValue = tool.summariseInput
|
|
422
|
+
? String(tool.summariseInput(parsed.input))
|
|
423
|
+
: parsed.rawInput;
|
|
424
|
+
|
|
425
|
+
setStage({
|
|
426
|
+
type: "permission",
|
|
427
|
+
tool: {
|
|
428
|
+
type: parsed.toolName as any,
|
|
429
|
+
_display: permValue,
|
|
430
|
+
_label: permLabel,
|
|
431
|
+
} as any,
|
|
432
|
+
pendingMessages: currentAll,
|
|
433
|
+
resolve: executeAndContinue,
|
|
434
|
+
});
|
|
486
435
|
};
|
|
487
436
|
|
|
488
437
|
const sendMessage = (text: string) => {
|
|
@@ -492,7 +441,6 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
492
441
|
setShowTimeline(true);
|
|
493
442
|
return;
|
|
494
443
|
}
|
|
495
|
-
|
|
496
444
|
if (text.trim().toLowerCase() === "/review") {
|
|
497
445
|
setShowReview(true);
|
|
498
446
|
return;
|
|
@@ -504,7 +452,7 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
504
452
|
const msg: Message = {
|
|
505
453
|
role: "assistant",
|
|
506
454
|
content: next
|
|
507
|
-
? "Auto-approve ON — read, search,
|
|
455
|
+
? "Auto-approve ON — safe tools (read, search, fetch) will run without asking."
|
|
508
456
|
: "Auto-approve OFF — all tools will ask for permission.",
|
|
509
457
|
type: "text",
|
|
510
458
|
};
|
|
@@ -514,14 +462,233 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
514
462
|
}
|
|
515
463
|
|
|
516
464
|
if (text.trim().toLowerCase() === "/clear history") {
|
|
517
|
-
|
|
518
|
-
const
|
|
465
|
+
clearRepoMemory(repoPath);
|
|
466
|
+
const msg: Message = {
|
|
519
467
|
role: "assistant",
|
|
520
468
|
content: "History cleared for this repo.",
|
|
521
469
|
type: "text",
|
|
522
470
|
};
|
|
523
|
-
setCommitted((prev) => [...prev,
|
|
524
|
-
setAllMessages((prev) => [...prev,
|
|
471
|
+
setCommitted((prev) => [...prev, msg]);
|
|
472
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (text.trim().toLowerCase() === "/chat") {
|
|
477
|
+
const msg: Message = {
|
|
478
|
+
role: "assistant",
|
|
479
|
+
content:
|
|
480
|
+
"Chat commands: `/chat list` · `/chat load <n>` · `/chat rename <n>` · `/chat delete <n>`",
|
|
481
|
+
type: "text",
|
|
482
|
+
};
|
|
483
|
+
setCommitted((prev) => [...prev, msg]);
|
|
484
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (text.trim().toLowerCase().startsWith("/chat rename")) {
|
|
489
|
+
const parts = text.trim().split(/\s+/);
|
|
490
|
+
const newName = parts.slice(2).join("-");
|
|
491
|
+
if (!newName) {
|
|
492
|
+
const msg: Message = {
|
|
493
|
+
role: "assistant",
|
|
494
|
+
content: "Usage: `/chat rename <new-name>`",
|
|
495
|
+
type: "text",
|
|
496
|
+
};
|
|
497
|
+
setCommitted((prev) => [...prev, msg]);
|
|
498
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const oldName = chatNameRef.current;
|
|
502
|
+
if (oldName) deleteChat(oldName);
|
|
503
|
+
updateChatName(newName);
|
|
504
|
+
saveChat(newName, repoPath, allMessages);
|
|
505
|
+
setRecentChats((prev) =>
|
|
506
|
+
[newName, ...prev.filter((n) => n !== newName && n !== oldName)].slice(
|
|
507
|
+
0,
|
|
508
|
+
10,
|
|
509
|
+
),
|
|
510
|
+
);
|
|
511
|
+
const msg: Message = {
|
|
512
|
+
role: "assistant",
|
|
513
|
+
content: `Chat renamed to **${newName}**.`,
|
|
514
|
+
type: "text",
|
|
515
|
+
};
|
|
516
|
+
setCommitted((prev) => [...prev, msg]);
|
|
517
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (text.trim().toLowerCase().startsWith("/chat delete")) {
|
|
522
|
+
const parts = text.trim().split(/\s+/);
|
|
523
|
+
const name = parts.slice(2).join("-");
|
|
524
|
+
if (!name) {
|
|
525
|
+
const msg: Message = {
|
|
526
|
+
role: "assistant",
|
|
527
|
+
content: "Usage: `/chat delete <n>`",
|
|
528
|
+
type: "text",
|
|
529
|
+
};
|
|
530
|
+
setCommitted((prev) => [...prev, msg]);
|
|
531
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const deleted = deleteChat(name);
|
|
535
|
+
if (!deleted) {
|
|
536
|
+
const msg: Message = {
|
|
537
|
+
role: "assistant",
|
|
538
|
+
content: `Chat **${name}** not found.`,
|
|
539
|
+
type: "text",
|
|
540
|
+
};
|
|
541
|
+
setCommitted((prev) => [...prev, msg]);
|
|
542
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (chatNameRef.current === name) {
|
|
546
|
+
chatNameRef.current = null;
|
|
547
|
+
setChatName(null);
|
|
548
|
+
}
|
|
549
|
+
setRecentChats((prev) => prev.filter((n) => n !== name));
|
|
550
|
+
const msg: Message = {
|
|
551
|
+
role: "assistant",
|
|
552
|
+
content: `Chat **${name}** deleted.`,
|
|
553
|
+
type: "text",
|
|
554
|
+
};
|
|
555
|
+
setCommitted((prev) => [...prev, msg]);
|
|
556
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (text.trim().toLowerCase() === "/chat list") {
|
|
561
|
+
const chats = listChats(repoPath);
|
|
562
|
+
const content =
|
|
563
|
+
chats.length === 0
|
|
564
|
+
? "No saved chats for this repo yet."
|
|
565
|
+
: `Saved chats:\n\n${chats
|
|
566
|
+
.map(
|
|
567
|
+
(c) =>
|
|
568
|
+
`- **${c.name}** · ${c.userMessageCount} messages · ${new Date(c.savedAt).toLocaleString()}`,
|
|
569
|
+
)
|
|
570
|
+
.join("\n")}`;
|
|
571
|
+
const msg: Message = { role: "assistant", content, type: "text" };
|
|
572
|
+
setCommitted((prev) => [...prev, msg]);
|
|
573
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (text.trim().toLowerCase().startsWith("/chat load")) {
|
|
578
|
+
const parts = text.trim().split(/\s+/);
|
|
579
|
+
const name = parts.slice(2).join("-");
|
|
580
|
+
if (!name) {
|
|
581
|
+
const chats = listChats(repoPath);
|
|
582
|
+
const content =
|
|
583
|
+
chats.length === 0
|
|
584
|
+
? "No saved chats found."
|
|
585
|
+
: `Specify a chat name. Recent chats:\n\n${chats
|
|
586
|
+
.slice(0, 10)
|
|
587
|
+
.map((c) => `- **${c.name}**`)
|
|
588
|
+
.join("\n")}`;
|
|
589
|
+
const msg: Message = { role: "assistant", content, type: "text" };
|
|
590
|
+
setCommitted((prev) => [...prev, msg]);
|
|
591
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const saved = loadChat(name);
|
|
595
|
+
if (!saved) {
|
|
596
|
+
const msg: Message = {
|
|
597
|
+
role: "assistant",
|
|
598
|
+
content: `Chat **${name}** not found. Use \`/chat list\` to see saved chats.`,
|
|
599
|
+
type: "text",
|
|
600
|
+
};
|
|
601
|
+
setCommitted((prev) => [...prev, msg]);
|
|
602
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
updateChatName(name);
|
|
606
|
+
setAllMessages(saved.messages);
|
|
607
|
+
setCommitted(saved.messages);
|
|
608
|
+
const notice: Message = {
|
|
609
|
+
role: "assistant",
|
|
610
|
+
content: `Loaded chat **${name}** · ${saved.userMessageCount} messages · saved ${new Date(saved.savedAt).toLocaleString()}`,
|
|
611
|
+
type: "text",
|
|
612
|
+
};
|
|
613
|
+
setCommitted((prev) => [...prev, notice]);
|
|
614
|
+
setAllMessages((prev) => [...prev, notice]);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (
|
|
619
|
+
text.trim().toLowerCase() === "/memory list" ||
|
|
620
|
+
text.trim().toLowerCase() === "/memory"
|
|
621
|
+
) {
|
|
622
|
+
const mems = listMemories(repoPath);
|
|
623
|
+
const content =
|
|
624
|
+
mems.length === 0
|
|
625
|
+
? "No memories stored for this repo yet."
|
|
626
|
+
: `Memories for this repo:\n\n${mems
|
|
627
|
+
.map((m) => `- [${m.id}] ${m.content}`)
|
|
628
|
+
.join("\n")}`;
|
|
629
|
+
const msg: Message = { role: "assistant", content, type: "text" };
|
|
630
|
+
setCommitted((prev) => [...prev, msg]);
|
|
631
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (text.trim().toLowerCase().startsWith("/memory add")) {
|
|
636
|
+
const content = text.trim().slice("/memory add".length).trim();
|
|
637
|
+
if (!content) {
|
|
638
|
+
const msg: Message = {
|
|
639
|
+
role: "assistant",
|
|
640
|
+
content: "Usage: `/memory add <content>`",
|
|
641
|
+
type: "text",
|
|
642
|
+
};
|
|
643
|
+
setCommitted((prev) => [...prev, msg]);
|
|
644
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const mem = addMemory(content, repoPath);
|
|
648
|
+
const msg: Message = {
|
|
649
|
+
role: "assistant",
|
|
650
|
+
content: `Memory saved **[${mem.id}]**: ${mem.content}`,
|
|
651
|
+
type: "text",
|
|
652
|
+
};
|
|
653
|
+
setCommitted((prev) => [...prev, msg]);
|
|
654
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (text.trim().toLowerCase().startsWith("/memory delete")) {
|
|
659
|
+
const id = text.trim().split(/\s+/)[2];
|
|
660
|
+
if (!id) {
|
|
661
|
+
const msg: Message = {
|
|
662
|
+
role: "assistant",
|
|
663
|
+
content: "Usage: `/memory delete <id>`",
|
|
664
|
+
type: "text",
|
|
665
|
+
};
|
|
666
|
+
setCommitted((prev) => [...prev, msg]);
|
|
667
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const deleted = deleteMemory(id, repoPath);
|
|
671
|
+
const msg: Message = {
|
|
672
|
+
role: "assistant",
|
|
673
|
+
content: deleted
|
|
674
|
+
? `Memory **[${id}]** deleted.`
|
|
675
|
+
: `Memory **[${id}]** not found.`,
|
|
676
|
+
type: "text",
|
|
677
|
+
};
|
|
678
|
+
setCommitted((prev) => [...prev, msg]);
|
|
679
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (text.trim().toLowerCase() === "/memory clear") {
|
|
684
|
+
clearRepoMemory(repoPath);
|
|
685
|
+
const msg: Message = {
|
|
686
|
+
role: "assistant",
|
|
687
|
+
content: "All memories cleared for this repo.",
|
|
688
|
+
type: "text",
|
|
689
|
+
};
|
|
690
|
+
setCommitted((prev) => [...prev, msg]);
|
|
691
|
+
setAllMessages((prev) => [...prev, msg]);
|
|
525
692
|
return;
|
|
526
693
|
}
|
|
527
694
|
|
|
@@ -530,8 +697,25 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
530
697
|
setCommitted((prev) => [...prev, userMsg]);
|
|
531
698
|
setAllMessages(nextAll);
|
|
532
699
|
toolResultCache.current.clear();
|
|
700
|
+
batchApprovedRef.current = false;
|
|
701
|
+
|
|
702
|
+
inputHistoryRef.current = [
|
|
703
|
+
text,
|
|
704
|
+
...inputHistoryRef.current.filter((m) => m !== text),
|
|
705
|
+
].slice(0, 50);
|
|
706
|
+
historyIndexRef.current = -1;
|
|
707
|
+
|
|
708
|
+
if (!chatName) {
|
|
709
|
+
const name =
|
|
710
|
+
getChatNameSuggestions(nextAll)[0] ??
|
|
711
|
+
`chat-${new Date().toISOString().slice(0, 10)}`;
|
|
712
|
+
updateChatName(name);
|
|
713
|
+
setRecentChats((prev) =>
|
|
714
|
+
[name, ...prev.filter((n) => n !== name)].slice(0, 10),
|
|
715
|
+
);
|
|
716
|
+
saveChat(name, repoPath, nextAll);
|
|
717
|
+
}
|
|
533
718
|
|
|
534
|
-
// Create a fresh abort controller for this request
|
|
535
719
|
const abort = new AbortController();
|
|
536
720
|
abortControllerRef.current = abort;
|
|
537
721
|
|
|
@@ -544,10 +728,10 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
544
728
|
useInput((input, key) => {
|
|
545
729
|
if (showTimeline) return;
|
|
546
730
|
|
|
547
|
-
// ESC while thinking → abort the in-flight request and go idle
|
|
548
731
|
if (stage.type === "thinking" && key.escape) {
|
|
549
732
|
abortControllerRef.current?.abort();
|
|
550
733
|
abortControllerRef.current = null;
|
|
734
|
+
batchApprovedRef.current = false;
|
|
551
735
|
setStage({ type: "idle" });
|
|
552
736
|
return;
|
|
553
737
|
}
|
|
@@ -557,7 +741,23 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
557
741
|
process.exit(0);
|
|
558
742
|
return;
|
|
559
743
|
}
|
|
560
|
-
|
|
744
|
+
if (key.upArrow && inputHistoryRef.current.length > 0) {
|
|
745
|
+
const next = Math.min(
|
|
746
|
+
historyIndexRef.current + 1,
|
|
747
|
+
inputHistoryRef.current.length - 1,
|
|
748
|
+
);
|
|
749
|
+
historyIndexRef.current = next;
|
|
750
|
+
setInputValue(inputHistoryRef.current[next]!);
|
|
751
|
+
setInputKey((k) => k + 1);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (key.downArrow) {
|
|
755
|
+
const next = historyIndexRef.current - 1;
|
|
756
|
+
historyIndexRef.current = next;
|
|
757
|
+
setInputValue(next < 0 ? "" : inputHistoryRef.current[next]!);
|
|
758
|
+
setInputKey((k) => k + 1);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
561
761
|
if (key.tab && inputValue.startsWith("/")) {
|
|
562
762
|
const q = inputValue.toLowerCase();
|
|
563
763
|
const match = COMMANDS.find((c) => c.cmd.startsWith(q));
|
|
@@ -582,7 +782,7 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
582
782
|
?.replace(/\.git$/, "") ?? "repo";
|
|
583
783
|
const destPath = path.join(os.tmpdir(), repoName);
|
|
584
784
|
const fileCount = walkDir(destPath).length;
|
|
585
|
-
|
|
785
|
+
appendMemory({
|
|
586
786
|
kind: "url-fetched",
|
|
587
787
|
detail: repoUrl,
|
|
588
788
|
summary: `Cloned ${repoName} — ${fileCount} files`,
|
|
@@ -622,37 +822,36 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
622
822
|
if (stage.type === "clone-exists") {
|
|
623
823
|
if (input === "y" || input === "Y") {
|
|
624
824
|
const { repoUrl, repoPath: existingPath } = stage;
|
|
625
|
-
const cloneUrl = toCloneUrl(repoUrl);
|
|
626
825
|
setStage({ type: "cloning", repoUrl });
|
|
627
|
-
startCloneRepo(
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
826
|
+
startCloneRepo(toCloneUrl(repoUrl), { forceReclone: true }).then(
|
|
827
|
+
(result) => {
|
|
828
|
+
if (result.done) {
|
|
829
|
+
setStage({
|
|
830
|
+
type: "clone-done",
|
|
831
|
+
repoUrl,
|
|
832
|
+
destPath: existingPath,
|
|
833
|
+
fileCount: walkDir(existingPath).length,
|
|
834
|
+
});
|
|
835
|
+
} else {
|
|
836
|
+
setStage({
|
|
837
|
+
type: "clone-error",
|
|
838
|
+
message:
|
|
839
|
+
!result.folderExists && result.error
|
|
840
|
+
? result.error
|
|
841
|
+
: "Clone failed",
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
},
|
|
845
|
+
);
|
|
646
846
|
return;
|
|
647
847
|
}
|
|
648
848
|
if (input === "n" || input === "N") {
|
|
649
849
|
const { repoUrl, repoPath: existingPath } = stage;
|
|
650
|
-
const fileCount = walkDir(existingPath).length;
|
|
651
850
|
setStage({
|
|
652
851
|
type: "clone-done",
|
|
653
852
|
repoUrl,
|
|
654
853
|
destPath: existingPath,
|
|
655
|
-
fileCount,
|
|
854
|
+
fileCount: walkDir(existingPath).length,
|
|
656
855
|
});
|
|
657
856
|
return;
|
|
658
857
|
}
|
|
@@ -663,19 +862,17 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
663
862
|
if (key.return || key.escape) {
|
|
664
863
|
if (stage.type === "clone-done") {
|
|
665
864
|
const repoName = stage.repoUrl.split("/").pop() ?? "repo";
|
|
666
|
-
|
|
667
865
|
const summaryMsg: Message = {
|
|
668
866
|
role: "assistant",
|
|
669
867
|
type: "text",
|
|
670
868
|
content: `Cloned **${repoName}** (${stage.fileCount} files) to \`${stage.destPath}\`.\n\nAsk me anything about it — I can read files, explain how it works, or suggest improvements.`,
|
|
671
869
|
};
|
|
672
|
-
|
|
673
870
|
const contextMsg: Message = {
|
|
674
871
|
role: "assistant",
|
|
675
872
|
type: "tool",
|
|
676
873
|
toolName: "fetch",
|
|
677
874
|
content: stage.repoUrl,
|
|
678
|
-
result: `Clone complete. Repo: ${repoName}. Local path: ${stage.destPath}. ${stage.fileCount} files
|
|
875
|
+
result: `Clone complete. Repo: ${repoName}. Local path: ${stage.destPath}. ${stage.fileCount} files.`,
|
|
679
876
|
approved: true,
|
|
680
877
|
};
|
|
681
878
|
const withClone = [...allMessages, contextMsg, summaryMsg];
|
|
@@ -697,6 +894,7 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
697
894
|
return;
|
|
698
895
|
}
|
|
699
896
|
if (input === "n" || input === "N" || key.escape) {
|
|
897
|
+
batchApprovedRef.current = false;
|
|
700
898
|
stage.resolve(false);
|
|
701
899
|
return;
|
|
702
900
|
}
|
|
@@ -720,7 +918,7 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
720
918
|
const msg = allMessages[pendingMsgIndex];
|
|
721
919
|
if (msg?.type === "plan") {
|
|
722
920
|
setCommitted((prev) => [...prev, { ...msg, applied: false }]);
|
|
723
|
-
|
|
921
|
+
appendMemory({
|
|
724
922
|
kind: "code-skipped",
|
|
725
923
|
detail: msg.patches
|
|
726
924
|
.map((p: { path: string }) => p.path)
|
|
@@ -737,7 +935,7 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
737
935
|
if (key.return || input === "a" || input === "A") {
|
|
738
936
|
try {
|
|
739
937
|
applyPatches(repoPath, stage.patches);
|
|
740
|
-
|
|
938
|
+
appendMemory({
|
|
741
939
|
kind: "code-applied",
|
|
742
940
|
detail: stage.patches.map((p) => p.path).join(", "),
|
|
743
941
|
summary: `Applied changes to ${stage.patches.length} file(s)`,
|
|
@@ -788,29 +986,19 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
|
|
|
788
986
|
.catch(() => walkDir(repoPath))
|
|
789
987
|
.then((fileTree) => {
|
|
790
988
|
const importantFiles = readImportantFiles(repoPath, fileTree);
|
|
791
|
-
const historySummary =
|
|
989
|
+
const historySummary = buildMemorySummary(repoPath);
|
|
792
990
|
const lensFile = readLensFile(repoPath);
|
|
793
991
|
const lensContext = lensFile
|
|
794
|
-
? `
|
|
795
|
-
|
|
796
|
-
## LENS.md (previous analysis)
|
|
797
|
-
${lensFile.overview}
|
|
798
|
-
|
|
799
|
-
Important folders: ${lensFile.importantFolders.join(", ")}
|
|
800
|
-
Suggestions: ${lensFile.suggestions.slice(0, 3).join("; ")}`
|
|
992
|
+
? `\n\n## LENS.md (previous analysis)\n${lensFile.overview}\n\nImportant folders: ${lensFile.importantFolders.join(", ")}\nSuggestions: ${lensFile.suggestions.slice(0, 3).join("; ")}`
|
|
801
993
|
: "";
|
|
994
|
+
const toolsSection = registry.buildSystemPromptSection();
|
|
802
995
|
setSystemPrompt(
|
|
803
|
-
buildSystemPrompt(importantFiles, historySummary) +
|
|
996
|
+
buildSystemPrompt(importantFiles, historySummary, toolsSection) +
|
|
997
|
+
lensContext,
|
|
804
998
|
);
|
|
805
|
-
const historyNote = historySummary
|
|
806
|
-
? "\n\nI have memory of previous actions in this repo."
|
|
807
|
-
: "";
|
|
808
|
-
const lensGreetNote = lensFile
|
|
809
|
-
? "\n\nFound LENS.md — I have context from a previous analysis of this repo."
|
|
810
|
-
: "";
|
|
811
999
|
const greeting: Message = {
|
|
812
1000
|
role: "assistant",
|
|
813
|
-
content: `Welcome to Lens
|
|
1001
|
+
content: `Welcome to Lens\nCodebase loaded — ${importantFiles.length} files indexed.${historySummary ? "\n\nI have memory of previous actions in this repo." : ""}${lensFile ? "\n\nFound LENS.md — I have context from a previous analysis of this repo." : ""}\nAsk me anything, tell me what to build, share a URL, or ask me to read/write files.\n\nTip: type /timeline to browse commit history.`,
|
|
814
1002
|
type: "text",
|
|
815
1003
|
};
|
|
816
1004
|
setCommitted([greeting]);
|
|
@@ -822,8 +1010,7 @@ Suggestions: ${lensFile.suggestions.slice(0, 3).join("; ")}`
|
|
|
822
1010
|
|
|
823
1011
|
if (stage.type === "picking-provider")
|
|
824
1012
|
return <ProviderPicker onDone={handleProviderDone} />;
|
|
825
|
-
|
|
826
|
-
if (stage.type === "loading") {
|
|
1013
|
+
if (stage.type === "loading")
|
|
827
1014
|
return (
|
|
828
1015
|
<Box gap={1} marginTop={1}>
|
|
829
1016
|
<Text color={ACCENT}>*</Text>
|
|
@@ -835,23 +1022,17 @@ Suggestions: ${lensFile.suggestions.slice(0, 3).join("; ")}`
|
|
|
835
1022
|
</Text>
|
|
836
1023
|
</Box>
|
|
837
1024
|
);
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
if (showTimeline) {
|
|
1025
|
+
if (showTimeline)
|
|
841
1026
|
return (
|
|
842
1027
|
<TimelineRunner
|
|
843
1028
|
repoPath={repoPath}
|
|
844
1029
|
onExit={() => setShowTimeline(false)}
|
|
845
1030
|
/>
|
|
846
1031
|
);
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
if (showReview) {
|
|
1032
|
+
if (showReview)
|
|
850
1033
|
return (
|
|
851
1034
|
<ReviewCommand path={repoPath} onExit={() => setShowReview(false)} />
|
|
852
1035
|
);
|
|
853
|
-
}
|
|
854
|
-
|
|
855
1036
|
if (stage.type === "clone-offer")
|
|
856
1037
|
return <CloneOfferView stage={stage} committed={committed} />;
|
|
857
1038
|
if (stage.type === "cloning")
|
|
@@ -892,18 +1073,21 @@ Suggestions: ${lensFile.suggestions.slice(0, 3).join("; ")}`
|
|
|
892
1073
|
{inputValue.startsWith("/") && (
|
|
893
1074
|
<CommandPalette
|
|
894
1075
|
query={inputValue}
|
|
895
|
-
onSelect={(cmd) =>
|
|
896
|
-
|
|
897
|
-
}}
|
|
1076
|
+
onSelect={(cmd) => setInputValue(cmd)}
|
|
1077
|
+
recentChats={recentChats}
|
|
898
1078
|
/>
|
|
899
1079
|
)}
|
|
900
1080
|
<InputBox
|
|
901
1081
|
value={inputValue}
|
|
902
|
-
onChange={
|
|
1082
|
+
onChange={(v) => {
|
|
1083
|
+
historyIndexRef.current = -1;
|
|
1084
|
+
setInputValue(v);
|
|
1085
|
+
}}
|
|
903
1086
|
onSubmit={(val) => {
|
|
904
1087
|
if (val.trim()) sendMessage(val.trim());
|
|
905
1088
|
setInputValue("");
|
|
906
1089
|
}}
|
|
1090
|
+
inputKey={inputKey}
|
|
907
1091
|
/>
|
|
908
1092
|
<ShortcutBar autoApprove={autoApprove} />
|
|
909
1093
|
</Box>
|