@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.
@@ -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
- appendHistory,
54
- buildHistorySummary,
55
- clearRepoHistory,
56
- } from "../../utils/history";
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 codebsae" },
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
- const inputBuffer = useRef("");
119
- const flushTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
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
- const flushBuffer = () => {
123
- const buf = inputBuffer.current;
124
- if (!buf) return;
125
- inputBuffer.current = "";
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
- const scheduleFlush = () => {
130
- if (flushTimer.current !== null) return;
131
- flushTimer.current = setTimeout(() => {
132
- flushTimer.current = null;
133
- flushBuffer();
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
- // Silently drop aborted requests — user pressed ESC intentionally
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
- const parsed = parseResponse(raw);
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
- if (
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
- // Safe tools that can be auto-approved (no side effects)
256
- const isSafeTool =
257
- parsed.kind === "read-file" ||
258
- parsed.kind === "read-folder" ||
259
- parsed.kind === "grep" ||
260
- parsed.kind === "fetch" ||
261
- parsed.kind === "open-url" ||
262
- parsed.kind === "search";
263
-
264
- const executeAndContinue = async (approved: boolean) => {
265
- let result = "(denied by user)";
266
- if (approved) {
267
- const cacheKey =
268
- parsed.kind === "read-file"
269
- ? `read-file:${parsed.filePath}`
270
- : parsed.kind === "read-folder"
271
- ? `read-folder:${parsed.folderPath}`
272
- : parsed.kind === "grep"
273
- ? `grep:${parsed.pattern}:${parsed.glob}`
274
- : null;
275
-
276
- if (cacheKey && toolResultCache.current.has(cacheKey)) {
277
- result =
278
- toolResultCache.current.get(cacheKey)! +
279
- "\n\n[NOTE: This result was already retrieved earlier. Do not request it again.]";
280
- } else {
281
- try {
282
- setStage({ type: "thinking" });
283
- if (parsed.kind === "shell") {
284
- result = await runShell(parsed.command, repoPath);
285
- } else if (parsed.kind === "fetch") {
286
- result = await fetchUrl(parsed.url);
287
- } else if (parsed.kind === "read-file") {
288
- result = readFile(parsed.filePath, repoPath);
289
- } else if (parsed.kind === "read-folder") {
290
- result = readFolder(parsed.folderPath, repoPath);
291
- } else if (parsed.kind === "grep") {
292
- result = grepFiles(parsed.pattern, parsed.glob, repoPath);
293
- } else if (parsed.kind === "delete-file") {
294
- result = deleteFile(parsed.filePath, repoPath);
295
- } else if (parsed.kind === "delete-folder") {
296
- result = deleteFolder(parsed.folderPath, repoPath);
297
- } else if (parsed.kind === "open-url") {
298
- result = openUrl(parsed.url);
299
- } else if (parsed.kind === "generate-pdf") {
300
- result = generatePdf(
301
- parsed.filePath,
302
- parsed.pdfContent,
303
- repoPath,
304
- );
305
- } else if (parsed.kind === "write-file") {
306
- result = writeFile(
307
- parsed.filePath,
308
- parsed.fileContent,
309
- repoPath,
310
- );
311
- } else if (parsed.kind === "search") {
312
- result = await searchWeb(parsed.query);
313
- }
314
- if (cacheKey) {
315
- toolResultCache.current.set(cacheKey, result);
316
- }
317
- } catch (err: unknown) {
318
- result = `Error: ${err instanceof Error ? err.message : "failed"}`;
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
- if (approved && !result.startsWith("Error:")) {
324
- const kindMap = {
325
- shell: "shell-run",
326
- fetch: "url-fetched",
327
- "read-file": "file-read",
328
- "read-folder": "file-read",
329
- grep: "file-read",
330
- "delete-file": "file-written",
331
- "delete-folder": "file-written",
332
- "open-url": "url-fetched",
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
- // Create a fresh abort controller for the follow-up call
422
- const nextAbort = new AbortController();
423
- abortControllerRef.current = nextAbort;
379
+ const displayContent = tool.summariseInput
380
+ ? String(tool.summariseInput(parsed.input))
381
+ : parsed.rawInput;
424
382
 
425
- setStage({ type: "thinking" });
426
- callChat(provider!, systemPrompt, withTool, nextAbort.signal)
427
- .then((r: string) => processResponse(r, withTool, nextAbort.signal))
428
- .catch(handleError(withTool));
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
- if (autoApprove && isSafeTool) {
432
- executeAndContinue(true);
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
- setStage({
437
- type: "permission",
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
- if (parsed.kind === "clone") {
446
- if (parsed.content) {
447
- const preambleMsg: Message = {
448
- role: "assistant",
449
- content: parsed.content,
450
- type: "text",
451
- };
452
- setAllMessages([...currentAll, preambleMsg]);
453
- setCommitted((prev) => [...prev, preambleMsg]);
454
- }
455
- setStage({
456
- type: "clone-offer",
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 msg: Message = {
464
- role: "assistant",
465
- content: parsed.content,
466
- type: "text",
467
- };
468
- const withMsg = [...currentAll, msg];
469
- setAllMessages(withMsg);
470
- setCommitted((prev) => [...prev, msg]);
471
-
472
- const lastUserMsg = [...currentAll]
473
- .reverse()
474
- .find((m) => m.role === "user");
475
- const githubUrl = lastUserMsg
476
- ? extractGithubUrl(lastUserMsg.content)
477
- : null;
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, grep and folder tools will run without asking. Write and code changes still require approval."
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
- clearRepoHistory(repoPath);
518
- const clearedMsg: Message = {
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, clearedMsg]);
524
- setAllMessages((prev) => [...prev, clearedMsg]);
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
- appendHistory({
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(cloneUrl, { forceReclone: true }).then((result) => {
628
- if (result.done) {
629
- const fileCount = walkDir(existingPath).length;
630
- setStage({
631
- type: "clone-done",
632
- repoUrl,
633
- destPath: existingPath,
634
- fileCount,
635
- });
636
- } else {
637
- setStage({
638
- type: "clone-error",
639
- message:
640
- !result.folderExists && result.error
641
- ? result.error
642
- : "Clone failed",
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. Use read-file with full path e.g. read-file ${stage.destPath}/README.md`,
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
- appendHistory({
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
- appendHistory({
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 = buildHistorySummary(repoPath);
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) + lensContext,
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 \nCodebase loaded — ${importantFiles.length} files indexed.${historyNote}${lensGreetNote}\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.`,
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
- setInputValue(cmd);
897
- }}
1076
+ onSelect={(cmd) => setInputValue(cmd)}
1077
+ recentChats={recentChats}
898
1078
  />
899
1079
  )}
900
1080
  <InputBox
901
1081
  value={inputValue}
902
- onChange={setInputValue}
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>