@ridit/lens 0.2.1 → 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,20 +17,9 @@ 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";
35
24
  import {
36
25
  saveChat,
@@ -66,6 +55,7 @@ import {
66
55
  } from "../../utils/memory";
67
56
  import { readLensFile } from "../../utils/lensfile";
68
57
  import { ReviewCommand } from "../../commands/review";
58
+ import { registry } from "../../utils/tools/registry";
69
59
 
70
60
  const COMMANDS = [
71
61
  { cmd: "/timeline", desc: "browse commit history" },
@@ -94,8 +84,6 @@ function CommandPalette({
94
84
  recentChats: string[];
95
85
  }) {
96
86
  const q = query.toLowerCase();
97
-
98
- // If typing "/chat load <something>", stay visible and filter chats
99
87
  const isChatLoad = q.startsWith("/chat load") || q.startsWith("/chat delete");
100
88
  const chatFilter = isChatLoad
101
89
  ? q.startsWith("/chat load")
@@ -105,13 +93,9 @@ function CommandPalette({
105
93
  const filteredChats = chatFilter
106
94
  ? recentChats.filter((n) => n.toLowerCase().includes(chatFilter))
107
95
  : recentChats;
108
-
109
96
  const matches = COMMANDS.filter((c) => c.cmd.startsWith(q));
110
-
111
- // Keep palette open if we're in /chat load mode even after space
112
97
  if (!matches.length && !isChatLoad) return null;
113
98
  if (!matches.length && isChatLoad && filteredChats.length === 0) return null;
114
-
115
99
  return (
116
100
  <Box flexDirection="column" marginBottom={1} marginLeft={2}>
117
101
  {matches.map((c, i) => {
@@ -170,39 +154,28 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
170
154
 
171
155
  const abortControllerRef = useRef<AbortController | null>(null);
172
156
  const toolResultCache = useRef<Map<string, string>>(new Map());
173
- const inputBuffer = useRef("");
174
- const flushTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
157
+
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
+
175
164
  const thinkingPhrase = useThinkingPhrase(stage.type === "thinking");
176
165
 
177
- // Load recent chats on mount
178
166
  React.useEffect(() => {
179
167
  const chats = listChats(repoPath);
180
168
  setRecentChats(chats.slice(0, 10).map((c) => c.name));
181
169
  }, [repoPath]);
182
170
 
183
- // Auto-save whenever messages change
184
171
  React.useEffect(() => {
185
172
  if (chatNameRef.current && allMessages.length > 1) {
186
173
  saveChat(chatNameRef.current, repoPath, allMessages);
187
174
  }
188
175
  }, [allMessages]);
189
176
 
190
- const flushBuffer = () => {
191
- const buf = inputBuffer.current;
192
- if (!buf) return;
193
- inputBuffer.current = "";
194
- setInputValue((v) => v + buf);
195
- };
196
-
197
- const scheduleFlush = () => {
198
- if (flushTimer.current !== null) return;
199
- flushTimer.current = setTimeout(() => {
200
- flushTimer.current = null;
201
- flushBuffer();
202
- }, 16);
203
- };
204
-
205
177
  const handleError = (currentAll: Message[]) => (err: unknown) => {
178
+ batchApprovedRef.current = false;
206
179
  if (err instanceof Error && err.name === "AbortError") {
207
180
  setStage({ type: "idle" });
208
181
  return;
@@ -223,11 +196,12 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
223
196
  signal: AbortSignal,
224
197
  ) => {
225
198
  if (signal.aborted) {
199
+ batchApprovedRef.current = false;
226
200
  setStage({ type: "idle" });
227
201
  return;
228
202
  }
229
203
 
230
- // Handle inline memory operations the model may emit
204
+ // Handle inline memory operations
231
205
  const memAddMatches = [
232
206
  ...raw.matchAll(/<memory-add>([\s\S]*?)<\/memory-add>/g),
233
207
  ];
@@ -242,7 +216,6 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
242
216
  const id = match[1]!.trim();
243
217
  if (id) deleteMemory(id, repoPath);
244
218
  }
245
- // Strip memory tags from raw before parsing
246
219
  const cleanRaw = raw
247
220
  .replace(/<memory-add>[\s\S]*?<\/memory-add>/g, "")
248
221
  .replace(/<memory-delete>[\s\S]*?<\/memory-delete>/g, "")
@@ -250,7 +223,10 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
250
223
 
251
224
  const parsed = parseResponse(cleanRaw);
252
225
 
226
+ // ── changes (diff preview UI) ──────────────────────────────────────────
227
+
253
228
  if (parsed.kind === "changes") {
229
+ batchApprovedRef.current = false;
254
230
  if (parsed.patches.length === 0) {
255
231
  const msg: Message = {
256
232
  role: "assistant",
@@ -283,52 +259,10 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
283
259
  return;
284
260
  }
285
261
 
286
- if (
287
- parsed.kind === "shell" ||
288
- parsed.kind === "fetch" ||
289
- parsed.kind === "read-file" ||
290
- parsed.kind === "read-folder" ||
291
- parsed.kind === "grep" ||
292
- parsed.kind === "write-file" ||
293
- parsed.kind === "delete-file" ||
294
- parsed.kind === "delete-folder" ||
295
- parsed.kind === "open-url" ||
296
- parsed.kind === "generate-pdf" ||
297
- parsed.kind === "search"
298
- ) {
299
- let tool: Parameters<typeof PermissionPrompt>[0]["tool"];
300
- if (parsed.kind === "shell") {
301
- tool = { type: "shell", command: parsed.command };
302
- } else if (parsed.kind === "fetch") {
303
- tool = { type: "fetch", url: parsed.url };
304
- } else if (parsed.kind === "read-file") {
305
- tool = { type: "read-file", filePath: parsed.filePath };
306
- } else if (parsed.kind === "read-folder") {
307
- tool = { type: "read-folder", folderPath: parsed.folderPath };
308
- } else if (parsed.kind === "grep") {
309
- tool = { type: "grep", pattern: parsed.pattern, glob: parsed.glob };
310
- } else if (parsed.kind === "delete-file") {
311
- tool = { type: "delete-file", filePath: parsed.filePath };
312
- } else if (parsed.kind === "delete-folder") {
313
- tool = { type: "delete-folder", folderPath: parsed.folderPath };
314
- } else if (parsed.kind === "open-url") {
315
- tool = { type: "open-url", url: parsed.url };
316
- } else if (parsed.kind === "generate-pdf") {
317
- tool = {
318
- type: "generate-pdf",
319
- filePath: parsed.filePath,
320
- content: parsed.pdfContent,
321
- };
322
- } else if (parsed.kind === "search") {
323
- tool = { type: "search", query: parsed.query };
324
- } else {
325
- tool = {
326
- type: "write-file",
327
- filePath: parsed.filePath,
328
- fileContent: parsed.fileContent,
329
- };
330
- }
262
+ // ── clone (git clone UI flow) ──────────────────────────────────────────
331
263
 
264
+ if (parsed.kind === "clone") {
265
+ batchApprovedRef.current = false;
332
266
  if (parsed.content) {
333
267
  const preambleMsg: Message = {
334
268
  role: "assistant",
@@ -338,236 +272,166 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
338
272
  setAllMessages([...currentAll, preambleMsg]);
339
273
  setCommitted((prev) => [...prev, preambleMsg]);
340
274
  }
275
+ setStage({
276
+ type: "clone-offer",
277
+ repoUrl: parsed.repoUrl,
278
+ launchAnalysis: true,
279
+ });
280
+ return;
281
+ }
341
282
 
342
- const isSafeTool =
343
- parsed.kind === "read-file" ||
344
- parsed.kind === "read-folder" ||
345
- parsed.kind === "grep" ||
346
- parsed.kind === "fetch" ||
347
- parsed.kind === "open-url" ||
348
- parsed.kind === "search";
349
-
350
- const executeAndContinue = async (approved: boolean) => {
351
- let result = "(denied by user)";
352
- if (approved) {
353
- const cacheKey =
354
- parsed.kind === "read-file"
355
- ? `read-file:${parsed.filePath}`
356
- : parsed.kind === "read-folder"
357
- ? `read-folder:${parsed.folderPath}`
358
- : parsed.kind === "grep"
359
- ? `grep:${parsed.pattern}:${parsed.glob}`
360
- : null;
361
-
362
- if (cacheKey && toolResultCache.current.has(cacheKey)) {
363
- result =
364
- toolResultCache.current.get(cacheKey)! +
365
- "\n\n[NOTE: This result was already retrieved earlier. Do not request it again.]";
366
- } else {
367
- try {
368
- setStage({ type: "thinking" });
369
- if (parsed.kind === "shell") {
370
- result = await runShell(parsed.command, repoPath);
371
- } else if (parsed.kind === "fetch") {
372
- result = await fetchUrl(parsed.url);
373
- } else if (parsed.kind === "read-file") {
374
- result = readFile(parsed.filePath, repoPath);
375
- } else if (parsed.kind === "read-folder") {
376
- result = readFolder(parsed.folderPath, repoPath);
377
- } else if (parsed.kind === "grep") {
378
- result = grepFiles(parsed.pattern, parsed.glob, repoPath);
379
- } else if (parsed.kind === "delete-file") {
380
- result = deleteFile(parsed.filePath, repoPath);
381
- } else if (parsed.kind === "delete-folder") {
382
- result = deleteFolder(parsed.folderPath, repoPath);
383
- } else if (parsed.kind === "open-url") {
384
- result = openUrl(parsed.url);
385
- } else if (parsed.kind === "generate-pdf") {
386
- result = generatePdf(
387
- parsed.filePath,
388
- parsed.pdfContent,
389
- repoPath,
390
- );
391
- } else if (parsed.kind === "write-file") {
392
- result = writeFile(
393
- parsed.filePath,
394
- parsed.fileContent,
395
- repoPath,
396
- );
397
- } else if (parsed.kind === "search") {
398
- result = await searchWeb(parsed.query);
399
- }
400
- if (cacheKey) {
401
- toolResultCache.current.set(cacheKey, result);
402
- }
403
- } catch (err: unknown) {
404
- 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);
405
361
  }
362
+ } catch (err: unknown) {
363
+ result = `Error: ${err instanceof Error ? err.message : "failed"}`;
406
364
  }
407
365
  }
366
+ }
408
367
 
409
- if (approved && !result.startsWith("Error:")) {
410
- const kindMap = {
411
- shell: "shell-run",
412
- fetch: "url-fetched",
413
- "read-file": "file-read",
414
- "read-folder": "file-read",
415
- grep: "file-read",
416
- "delete-file": "file-written",
417
- "delete-folder": "file-written",
418
- "open-url": "url-fetched",
419
- "generate-pdf": "file-written",
420
- "write-file": "file-written",
421
- search: "url-fetched",
422
- } as const;
423
- appendMemory({
424
- kind: kindMap[parsed.kind as keyof typeof kindMap] ?? "shell-run",
425
- detail:
426
- parsed.kind === "shell"
427
- ? parsed.command
428
- : parsed.kind === "fetch"
429
- ? parsed.url
430
- : parsed.kind === "search"
431
- ? parsed.query
432
- : parsed.kind === "read-folder"
433
- ? parsed.folderPath
434
- : parsed.kind === "grep"
435
- ? `${parsed.pattern} ${parsed.glob}`
436
- : parsed.kind === "delete-file"
437
- ? parsed.filePath
438
- : parsed.kind === "delete-folder"
439
- ? parsed.folderPath
440
- : parsed.kind === "open-url"
441
- ? parsed.url
442
- : parsed.kind === "generate-pdf"
443
- ? parsed.filePath
444
- : parsed.filePath,
445
- summary: result.split("\n")[0]?.slice(0, 120) ?? "",
446
- repoPath,
447
- });
448
- }
449
-
450
- const toolName =
451
- parsed.kind === "shell"
452
- ? "shell"
453
- : parsed.kind === "fetch"
454
- ? "fetch"
455
- : parsed.kind === "read-file"
456
- ? "read-file"
457
- : parsed.kind === "read-folder"
458
- ? "read-folder"
459
- : parsed.kind === "grep"
460
- ? "grep"
461
- : parsed.kind === "delete-file"
462
- ? "delete-file"
463
- : parsed.kind === "delete-folder"
464
- ? "delete-folder"
465
- : parsed.kind === "open-url"
466
- ? "open-url"
467
- : parsed.kind === "generate-pdf"
468
- ? "generate-pdf"
469
- : parsed.kind === "search"
470
- ? "search"
471
- : "write-file";
472
-
473
- const toolContent =
474
- parsed.kind === "shell"
475
- ? parsed.command
476
- : parsed.kind === "fetch"
477
- ? parsed.url
478
- : parsed.kind === "search"
479
- ? parsed.query
480
- : parsed.kind === "read-folder"
481
- ? parsed.folderPath
482
- : parsed.kind === "grep"
483
- ? `${parsed.pattern} — ${parsed.glob}`
484
- : parsed.kind === "delete-file"
485
- ? parsed.filePath
486
- : parsed.kind === "delete-folder"
487
- ? parsed.folderPath
488
- : parsed.kind === "open-url"
489
- ? parsed.url
490
- : parsed.kind === "generate-pdf"
491
- ? parsed.filePath
492
- : parsed.filePath;
493
-
494
- const toolMsg: Message = {
495
- role: "assistant",
496
- type: "tool",
497
- toolName,
498
- content: toolContent,
499
- result,
500
- approved,
501
- };
502
-
503
- const withTool = [...currentAll, toolMsg];
504
- setAllMessages(withTool);
505
- 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
+ }
506
378
 
507
- const nextAbort = new AbortController();
508
- abortControllerRef.current = nextAbort;
379
+ const displayContent = tool.summariseInput
380
+ ? String(tool.summariseInput(parsed.input))
381
+ : parsed.rawInput;
509
382
 
510
- setStage({ type: "thinking" });
511
- callChat(provider!, systemPrompt, withTool, nextAbort.signal)
512
- .then((r: string) => processResponse(r, withTool, nextAbort.signal))
513
- .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,
514
390
  };
515
391
 
516
- if (autoApprove && isSafeTool) {
517
- 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);
518
399
  return;
519
400
  }
520
401
 
521
- setStage({
522
- type: "permission",
523
- tool,
524
- pendingMessages: currentAll,
525
- resolve: executeAndContinue,
526
- });
527
- return;
528
- }
402
+ // Chain ended (or was never chained) — clear batch approval.
403
+ batchApprovedRef.current = false;
529
404
 
530
- if (parsed.kind === "clone") {
531
- if (parsed.content) {
532
- const preambleMsg: Message = {
533
- role: "assistant",
534
- content: parsed.content,
535
- type: "text",
536
- };
537
- setAllMessages([...currentAll, preambleMsg]);
538
- setCommitted((prev) => [...prev, preambleMsg]);
539
- }
540
- setStage({
541
- type: "clone-offer",
542
- repoUrl: parsed.repoUrl,
543
- launchAnalysis: true,
544
- });
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);
545
417
  return;
546
418
  }
547
419
 
548
- const msg: Message = {
549
- role: "assistant",
550
- content: parsed.content,
551
- type: "text",
552
- };
553
- const withMsg = [...currentAll, msg];
554
- setAllMessages(withMsg);
555
- setCommitted((prev) => [...prev, msg]);
556
-
557
- const lastUserMsg = [...currentAll]
558
- .reverse()
559
- .find((m) => m.role === "user");
560
- const githubUrl = lastUserMsg
561
- ? extractGithubUrl(lastUserMsg.content)
562
- : null;
563
-
564
- if (githubUrl && !clonedUrls.has(githubUrl)) {
565
- setTimeout(() => {
566
- setStage({ type: "clone-offer", repoUrl: githubUrl });
567
- }, 80);
568
- } else {
569
- setStage({ type: "idle" });
570
- }
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
+ });
571
435
  };
572
436
 
573
437
  const sendMessage = (text: string) => {
@@ -577,7 +441,6 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
577
441
  setShowTimeline(true);
578
442
  return;
579
443
  }
580
-
581
444
  if (text.trim().toLowerCase() === "/review") {
582
445
  setShowReview(true);
583
446
  return;
@@ -589,7 +452,7 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
589
452
  const msg: Message = {
590
453
  role: "assistant",
591
454
  content: next
592
- ? "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."
593
456
  : "Auto-approve OFF — all tools will ask for permission.",
594
457
  type: "text",
595
458
  };
@@ -600,17 +463,16 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
600
463
 
601
464
  if (text.trim().toLowerCase() === "/clear history") {
602
465
  clearRepoMemory(repoPath);
603
- const clearedMsg: Message = {
466
+ const msg: Message = {
604
467
  role: "assistant",
605
468
  content: "History cleared for this repo.",
606
469
  type: "text",
607
470
  };
608
- setCommitted((prev) => [...prev, clearedMsg]);
609
- setAllMessages((prev) => [...prev, clearedMsg]);
471
+ setCommitted((prev) => [...prev, msg]);
472
+ setAllMessages((prev) => [...prev, msg]);
610
473
  return;
611
474
  }
612
475
 
613
- // bare /chat — show usage
614
476
  if (text.trim().toLowerCase() === "/chat") {
615
477
  const msg: Message = {
616
478
  role: "assistant",
@@ -623,7 +485,6 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
623
485
  return;
624
486
  }
625
487
 
626
- // /chat rename <newname>
627
488
  if (text.trim().toLowerCase().startsWith("/chat rename")) {
628
489
  const parts = text.trim().split(/\s+/);
629
490
  const newName = parts.slice(2).join("-");
@@ -657,14 +518,13 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
657
518
  return;
658
519
  }
659
520
 
660
- // /chat delete <name>
661
521
  if (text.trim().toLowerCase().startsWith("/chat delete")) {
662
522
  const parts = text.trim().split(/\s+/);
663
523
  const name = parts.slice(2).join("-");
664
524
  if (!name) {
665
525
  const msg: Message = {
666
526
  role: "assistant",
667
- content: "Usage: `/chat delete <name>`",
527
+ content: "Usage: `/chat delete <n>`",
668
528
  type: "text",
669
529
  };
670
530
  setCommitted((prev) => [...prev, msg]);
@@ -682,7 +542,6 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
682
542
  setAllMessages((prev) => [...prev, msg]);
683
543
  return;
684
544
  }
685
- // If deleting the current chat, clear the name so it gets re-named on next message
686
545
  if (chatNameRef.current === name) {
687
546
  chatNameRef.current = null;
688
547
  setChatName(null);
@@ -698,7 +557,6 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
698
557
  return;
699
558
  }
700
559
 
701
- // /chat list
702
560
  if (text.trim().toLowerCase() === "/chat list") {
703
561
  const chats = listChats(repoPath);
704
562
  const content =
@@ -716,7 +574,6 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
716
574
  return;
717
575
  }
718
576
 
719
- // /chat load <n>
720
577
  if (text.trim().toLowerCase().startsWith("/chat load")) {
721
578
  const parts = text.trim().split(/\s+/);
722
579
  const name = parts.slice(2).join("-");
@@ -758,7 +615,6 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
758
615
  return;
759
616
  }
760
617
 
761
- // /memory list
762
618
  if (
763
619
  text.trim().toLowerCase() === "/memory list" ||
764
620
  text.trim().toLowerCase() === "/memory"
@@ -767,14 +623,15 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
767
623
  const content =
768
624
  mems.length === 0
769
625
  ? "No memories stored for this repo yet."
770
- : `Memories for this repo:\n\n${mems.map((m) => `- [${m.id}] ${m.content}`).join("\n")}`;
626
+ : `Memories for this repo:\n\n${mems
627
+ .map((m) => `- [${m.id}] ${m.content}`)
628
+ .join("\n")}`;
771
629
  const msg: Message = { role: "assistant", content, type: "text" };
772
630
  setCommitted((prev) => [...prev, msg]);
773
631
  setAllMessages((prev) => [...prev, msg]);
774
632
  return;
775
633
  }
776
634
 
777
- // /memory add <content>
778
635
  if (text.trim().toLowerCase().startsWith("/memory add")) {
779
636
  const content = text.trim().slice("/memory add".length).trim();
780
637
  if (!content) {
@@ -798,7 +655,6 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
798
655
  return;
799
656
  }
800
657
 
801
- // /memory delete <id>
802
658
  if (text.trim().toLowerCase().startsWith("/memory delete")) {
803
659
  const id = text.trim().split(/\s+/)[2];
804
660
  if (!id) {
@@ -824,7 +680,6 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
824
680
  return;
825
681
  }
826
682
 
827
- // /memory clear
828
683
  if (text.trim().toLowerCase() === "/memory clear") {
829
684
  clearRepoMemory(repoPath);
830
685
  const msg: Message = {
@@ -842,15 +697,14 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
842
697
  setCommitted((prev) => [...prev, userMsg]);
843
698
  setAllMessages(nextAll);
844
699
  toolResultCache.current.clear();
700
+ batchApprovedRef.current = false;
845
701
 
846
- // Track input history for up/down navigation
847
702
  inputHistoryRef.current = [
848
703
  text,
849
704
  ...inputHistoryRef.current.filter((m) => m !== text),
850
705
  ].slice(0, 50);
851
706
  historyIndexRef.current = -1;
852
707
 
853
- // Auto-name chat on first user message
854
708
  if (!chatName) {
855
709
  const name =
856
710
  getChatNameSuggestions(nextAll)[0] ??
@@ -877,6 +731,7 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
877
731
  if (stage.type === "thinking" && key.escape) {
878
732
  abortControllerRef.current?.abort();
879
733
  abortControllerRef.current = null;
734
+ batchApprovedRef.current = false;
880
735
  setStage({ type: "idle" });
881
736
  return;
882
737
  }
@@ -886,7 +741,6 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
886
741
  process.exit(0);
887
742
  return;
888
743
  }
889
-
890
744
  if (key.upArrow && inputHistoryRef.current.length > 0) {
891
745
  const next = Math.min(
892
746
  historyIndexRef.current + 1,
@@ -897,16 +751,13 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
897
751
  setInputKey((k) => k + 1);
898
752
  return;
899
753
  }
900
-
901
754
  if (key.downArrow) {
902
755
  const next = historyIndexRef.current - 1;
903
756
  historyIndexRef.current = next;
904
- const val = next < 0 ? "" : inputHistoryRef.current[next]!;
905
- setInputValue(val);
757
+ setInputValue(next < 0 ? "" : inputHistoryRef.current[next]!);
906
758
  setInputKey((k) => k + 1);
907
759
  return;
908
760
  }
909
-
910
761
  if (key.tab && inputValue.startsWith("/")) {
911
762
  const q = inputValue.toLowerCase();
912
763
  const match = COMMANDS.find((c) => c.cmd.startsWith(q));
@@ -971,37 +822,36 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
971
822
  if (stage.type === "clone-exists") {
972
823
  if (input === "y" || input === "Y") {
973
824
  const { repoUrl, repoPath: existingPath } = stage;
974
- const cloneUrl = toCloneUrl(repoUrl);
975
825
  setStage({ type: "cloning", repoUrl });
976
- startCloneRepo(cloneUrl, { forceReclone: true }).then((result) => {
977
- if (result.done) {
978
- const fileCount = walkDir(existingPath).length;
979
- setStage({
980
- type: "clone-done",
981
- repoUrl,
982
- destPath: existingPath,
983
- fileCount,
984
- });
985
- } else {
986
- setStage({
987
- type: "clone-error",
988
- message:
989
- !result.folderExists && result.error
990
- ? result.error
991
- : "Clone failed",
992
- });
993
- }
994
- });
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
+ );
995
846
  return;
996
847
  }
997
848
  if (input === "n" || input === "N") {
998
849
  const { repoUrl, repoPath: existingPath } = stage;
999
- const fileCount = walkDir(existingPath).length;
1000
850
  setStage({
1001
851
  type: "clone-done",
1002
852
  repoUrl,
1003
853
  destPath: existingPath,
1004
- fileCount,
854
+ fileCount: walkDir(existingPath).length,
1005
855
  });
1006
856
  return;
1007
857
  }
@@ -1022,7 +872,7 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
1022
872
  type: "tool",
1023
873
  toolName: "fetch",
1024
874
  content: stage.repoUrl,
1025
- 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.`,
1026
876
  approved: true,
1027
877
  };
1028
878
  const withClone = [...allMessages, contextMsg, summaryMsg];
@@ -1044,6 +894,7 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
1044
894
  return;
1045
895
  }
1046
896
  if (input === "n" || input === "N" || key.escape) {
897
+ batchApprovedRef.current = false;
1047
898
  stage.resolve(false);
1048
899
  return;
1049
900
  }
@@ -1138,26 +989,16 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
1138
989
  const historySummary = buildMemorySummary(repoPath);
1139
990
  const lensFile = readLensFile(repoPath);
1140
991
  const lensContext = lensFile
1141
- ? `
1142
-
1143
- ## LENS.md (previous analysis)
1144
- ${lensFile.overview}
1145
-
1146
- Important folders: ${lensFile.importantFolders.join(", ")}
1147
- 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("; ")}`
1148
993
  : "";
994
+ const toolsSection = registry.buildSystemPromptSection();
1149
995
  setSystemPrompt(
1150
- buildSystemPrompt(importantFiles, historySummary) + lensContext,
996
+ buildSystemPrompt(importantFiles, historySummary, toolsSection) +
997
+ lensContext,
1151
998
  );
1152
- const historyNote = historySummary
1153
- ? "\n\nI have memory of previous actions in this repo."
1154
- : "";
1155
- const lensGreetNote = lensFile
1156
- ? "\n\nFound LENS.md — I have context from a previous analysis of this repo."
1157
- : "";
1158
999
  const greeting: Message = {
1159
1000
  role: "assistant",
1160
- 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.`,
1161
1002
  type: "text",
1162
1003
  };
1163
1004
  setCommitted([greeting]);
@@ -1169,8 +1010,7 @@ Suggestions: ${lensFile.suggestions.slice(0, 3).join("; ")}`
1169
1010
 
1170
1011
  if (stage.type === "picking-provider")
1171
1012
  return <ProviderPicker onDone={handleProviderDone} />;
1172
-
1173
- if (stage.type === "loading") {
1013
+ if (stage.type === "loading")
1174
1014
  return (
1175
1015
  <Box gap={1} marginTop={1}>
1176
1016
  <Text color={ACCENT}>*</Text>
@@ -1182,23 +1022,17 @@ Suggestions: ${lensFile.suggestions.slice(0, 3).join("; ")}`
1182
1022
  </Text>
1183
1023
  </Box>
1184
1024
  );
1185
- }
1186
-
1187
- if (showTimeline) {
1025
+ if (showTimeline)
1188
1026
  return (
1189
1027
  <TimelineRunner
1190
1028
  repoPath={repoPath}
1191
1029
  onExit={() => setShowTimeline(false)}
1192
1030
  />
1193
1031
  );
1194
- }
1195
-
1196
- if (showReview) {
1032
+ if (showReview)
1197
1033
  return (
1198
1034
  <ReviewCommand path={repoPath} onExit={() => setShowReview(false)} />
1199
1035
  );
1200
- }
1201
-
1202
1036
  if (stage.type === "clone-offer")
1203
1037
  return <CloneOfferView stage={stage} committed={committed} />;
1204
1038
  if (stage.type === "cloning")