@ridit/lens 0.3.4 → 0.3.5

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.
@@ -1,34 +1,17 @@
1
1
  import React from "react";
2
2
  import { Box, Text, Static, useInput } from "ink";
3
3
  import Spinner from "ink-spinner";
4
- import { useState, useRef } from "react";
4
+ import { useState } from "react";
5
5
  import path from "path";
6
6
  import os from "os";
7
7
  import TextInput from "ink-text-input";
8
8
  import { ACCENT } from "../../colors";
9
- import { buildDiffs } from "../repo/DiffViewer";
10
9
  import { ProviderPicker } from "../provider/ProviderPicker";
11
- import { fetchFileTree, readImportantFiles } from "../../utils/files";
12
10
  import { startCloneRepo } from "../../utils/repo";
13
11
  import { useThinkingPhrase } from "../../utils/thinking";
14
- import {
15
- walkDir,
16
- readClipboard,
17
- applyPatches,
18
- extractGithubUrl,
19
- toCloneUrl,
20
- parseCloneTag,
21
- buildSystemPrompt,
22
- parseResponse,
23
- callChat,
24
- } from "../../utils/chat";
25
- import {
26
- saveChat,
27
- loadChat,
28
- listChats,
29
- deleteChat,
30
- getChatNameSuggestions,
31
- } from "../../utils/chatHistory";
12
+ import { walkDir, applyPatches, toCloneUrl } from "../../utils/chat";
13
+ import { appendMemory } from "../../utils/memory";
14
+ import { getChatNameSuggestions, saveChat } from "../../utils/chatHistory";
32
15
  import { StaticMessage } from "./ChatMessage";
33
16
  import {
34
17
  PermissionPrompt,
@@ -44,48 +27,17 @@ import {
44
27
  ViewingFileView,
45
28
  } from "./ChatOverlays";
46
29
  import { TimelineRunner } from "../timeline/TimelineRunner";
47
- import type { Provider } from "../../types/config";
48
- import type { Message, ChatStage } from "../../types/chat";
49
- import {
50
- appendMemory,
51
- buildMemorySummary,
52
- clearRepoMemory,
53
- addMemory,
54
- deleteMemory,
55
- listMemories,
56
- } from "../../utils/memory";
57
- import { readLensFile } from "../../utils/lensfile";
58
30
  import { ReviewCommand } from "../../commands/review";
59
- import { registry } from "../../utils/tools/registry";
60
-
61
- const COMMANDS = [
62
- { cmd: "/timeline", desc: "browse commit history" },
63
- { cmd: "/clear history", desc: "wipe session memory for this repo" },
64
- { cmd: "/review", desc: "review current codebase" },
65
- { cmd: "/auto", desc: "toggle auto-approve for read/search tools" },
66
- {
67
- cmd: "/auto --force-all",
68
- desc: "auto-approve ALL tools including shell and writes (⚠ dangerous)",
69
- },
70
- { cmd: "/chat", desc: "chat history commands" },
71
- { cmd: "/chat list", desc: "list saved chats for this repo" },
72
- { cmd: "/chat load", desc: "load a saved chat by name" },
73
- { cmd: "/chat rename", desc: "rename the current chat" },
74
- { cmd: "/chat delete", desc: "delete a saved chat by name" },
75
- { cmd: "/memory", desc: "memory commands" },
76
- { cmd: "/memory list", desc: "list all memories for this repo" },
77
- { cmd: "/memory add", desc: "add a memory" },
78
- { cmd: "/memory delete", desc: "delete a memory by id" },
79
- { cmd: "/memory clear", desc: "clear all memories for this repo" },
80
- ];
31
+ import type { Message } from "../../types/chat";
32
+ import { useChat } from "./hooks/useChat";
33
+ import { useChatInput } from "./hooks/useChatInput";
34
+ import { handleCommand, COMMANDS } from "./hooks/useCommandHandlers";
81
35
 
82
36
  function CommandPalette({
83
37
  query,
84
- onSelect,
85
38
  recentChats,
86
39
  }: {
87
40
  query: string;
88
- onSelect: (cmd: string) => void;
89
41
  recentChats: string[];
90
42
  }) {
91
43
  const q = query.toLowerCase();
@@ -139,7 +91,6 @@ function ForceAllWarning({
139
91
  onConfirm: (confirmed: boolean) => void;
140
92
  }) {
141
93
  const [input, setInput] = useState("");
142
-
143
94
  return (
144
95
  <Box flexDirection="column" marginY={1} gap={1}>
145
96
  <Box gap={1}>
@@ -197,713 +148,14 @@ function ForceAllWarning({
197
148
  }
198
149
 
199
150
  export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
200
- const [stage, setStage] = useState<ChatStage>({ type: "picking-provider" });
201
- const [committed, setCommitted] = useState<Message[]>([]);
202
- const [provider, setProvider] = useState<Provider | null>(null);
203
- const [systemPrompt, setSystemPrompt] = useState("");
204
- const [inputValue, setInputValue] = useState("");
205
- const [pendingMsgIndex, setPendingMsgIndex] = useState<number | null>(null);
206
- const [allMessages, setAllMessages] = useState<Message[]>([]);
207
- const [clonedUrls, setClonedUrls] = useState<Set<string>>(new Set());
208
- const [showTimeline, setShowTimeline] = useState(false);
209
- const [showReview, setShowReview] = useState(false);
210
- const [autoApprove, setAutoApprove] = useState(false);
211
- const [forceApprove, setForceApprove] = useState(false);
212
- const [showForceWarning, setShowForceWarning] = useState(false);
213
- const [chatName, setChatName] = useState<string | null>(null);
214
- const chatNameRef = useRef<string | null>(null);
215
- const [recentChats, setRecentChats] = useState<string[]>([]);
216
- const inputHistoryRef = useRef<string[]>([]);
217
- const historyIndexRef = useRef<number>(-1);
218
- const [inputKey, setInputKey] = useState(0);
219
-
220
- const updateChatName = (name: string) => {
221
- chatNameRef.current = name;
222
- setChatName(name);
223
- };
224
-
225
- const abortControllerRef = useRef<AbortController | null>(null);
226
- const toolResultCache = useRef<Map<string, string>>(new Map());
227
- const batchApprovedRef = useRef(false);
228
-
229
- const thinkingPhrase = useThinkingPhrase(stage.type === "thinking");
230
-
231
- React.useEffect(() => {
232
- const chats = listChats(repoPath);
233
- setRecentChats(chats.slice(0, 10).map((c) => c.name));
234
- }, [repoPath]);
235
-
236
- React.useEffect(() => {
237
- if (chatNameRef.current && allMessages.length > 1) {
238
- saveChat(chatNameRef.current, repoPath, allMessages);
239
- }
240
- }, [allMessages]);
241
-
242
- const handleError = (currentAll: Message[]) => (err: unknown) => {
243
- batchApprovedRef.current = false;
244
- if (err instanceof Error && err.name === "AbortError") {
245
- setStage({ type: "idle" });
246
- return;
247
- }
248
- const errMsg: Message = {
249
- role: "assistant",
250
- content: `Error: ${err instanceof Error ? err.message : "Something went wrong"}`,
251
- type: "text",
252
- };
253
- setAllMessages([...currentAll, errMsg]);
254
- setCommitted((prev) => [...prev, errMsg]);
255
- setStage({ type: "idle" });
256
- };
257
-
258
- const TOOL_TAG_NAMES = [
259
- "shell",
260
- "fetch",
261
- "read-file",
262
- "read-folder",
263
- "grep",
264
- "write-file",
265
- "delete-file",
266
- "delete-folder",
267
- "open-url",
268
- "generate-pdf",
269
- "search",
270
- "clone",
271
- "changes",
272
- ];
273
-
274
- function isLikelyTruncated(text: string): boolean {
275
- return TOOL_TAG_NAMES.some(
276
- (tag) => text.includes(`<${tag}>`) && !text.includes(`</${tag}>`),
277
- );
278
- }
279
-
280
- const processResponse = (
281
- raw: string,
282
- currentAll: Message[],
283
- signal: AbortSignal,
284
- ) => {
285
- if (signal.aborted) {
286
- batchApprovedRef.current = false;
287
- setStage({ type: "idle" });
288
- return;
289
- }
290
-
291
- // Guard: response cut off mid-tool-tag (context limit hit during generation)
292
- if (isLikelyTruncated(raw)) {
293
- const truncMsg: Message = {
294
- role: "assistant",
295
- content:
296
- "(response cut off — the model hit its output limit mid-tool-call. Try asking it to continue, or simplify the request.)",
297
- type: "text",
298
- };
299
- setAllMessages([...currentAll, truncMsg]);
300
- setCommitted((prev) => [...prev, truncMsg]);
301
- setStage({ type: "idle" });
302
- return;
303
- }
304
-
305
- const memAddMatches = [
306
- ...raw.matchAll(/<memory-add>([\s\S]*?)<\/memory-add>/g),
307
- ];
308
- const memDelMatches = [
309
- ...raw.matchAll(/<memory-delete>([\s\S]*?)<\/memory-delete>/g),
310
- ];
311
- for (const match of memAddMatches) {
312
- const content = match[1]!.trim();
313
- if (content) addMemory(content, repoPath);
314
- }
315
- for (const match of memDelMatches) {
316
- const id = match[1]!.trim();
317
- if (id) deleteMemory(id, repoPath);
318
- }
319
- const cleanRaw = raw
320
- .replace(/<memory-add>[\s\S]*?<\/memory-add>/g, "")
321
- .replace(/<memory-delete>[\s\S]*?<\/memory-delete>/g, "")
322
- .trim();
323
-
324
- const parsed = parseResponse(cleanRaw);
325
-
326
- if (parsed.kind === "changes") {
327
- batchApprovedRef.current = false;
328
- if (parsed.patches.length === 0) {
329
- const msg: Message = {
330
- role: "assistant",
331
- content: parsed.content,
332
- type: "text",
333
- };
334
- setAllMessages([...currentAll, msg]);
335
- setCommitted((prev) => [...prev, msg]);
336
- setStage({ type: "idle" });
337
- return;
338
- }
339
- const assistantMsg: Message = {
340
- role: "assistant",
341
- content: parsed.content,
342
- type: "plan",
343
- patches: parsed.patches,
344
- applied: false,
345
- };
346
- const withAssistant = [...currentAll, assistantMsg];
347
- setAllMessages(withAssistant);
348
- setPendingMsgIndex(withAssistant.length - 1);
349
- const diffLines = buildDiffs(repoPath, parsed.patches);
350
- setStage({
351
- type: "preview",
352
- patches: parsed.patches,
353
- diffLines,
354
- scrollOffset: 0,
355
- pendingMessages: currentAll,
356
- });
357
- return;
358
- }
359
-
360
- if (parsed.kind === "clone") {
361
- batchApprovedRef.current = false;
362
- if (parsed.content) {
363
- const preambleMsg: Message = {
364
- role: "assistant",
365
- content: parsed.content,
366
- type: "text",
367
- };
368
- setAllMessages([...currentAll, preambleMsg]);
369
- setCommitted((prev) => [...prev, preambleMsg]);
370
- }
371
- setStage({
372
- type: "clone-offer",
373
- repoUrl: parsed.repoUrl,
374
- launchAnalysis: true,
375
- });
376
- return;
377
- }
378
-
379
- if (parsed.kind === "text") {
380
- batchApprovedRef.current = false;
381
-
382
- if (!parsed.content.trim()) {
383
- const stallMsg: Message = {
384
- role: "assistant",
385
- content:
386
- '(no response — the model may have stalled. Try sending a short follow-up like "continue" or start a new message.)',
387
- type: "text",
388
- };
389
- setAllMessages([...currentAll, stallMsg]);
390
- setCommitted((prev) => [...prev, stallMsg]);
391
- setStage({ type: "idle" });
392
- return;
393
- }
394
-
395
- const msg: Message = {
396
- role: "assistant",
397
- content: parsed.content,
398
- type: "text",
399
- };
400
- const withMsg = [...currentAll, msg];
401
- setAllMessages(withMsg);
402
- setCommitted((prev) => [...prev, msg]);
403
- const lastUserMsg = [...currentAll]
404
- .reverse()
405
- .find((m) => m.role === "user");
406
- const githubUrl = lastUserMsg
407
- ? extractGithubUrl(lastUserMsg.content)
408
- : null;
409
- if (githubUrl && !clonedUrls.has(githubUrl)) {
410
- setTimeout(
411
- () => setStage({ type: "clone-offer", repoUrl: githubUrl }),
412
- 80,
413
- );
414
- } else {
415
- setStage({ type: "idle" });
416
- }
417
- return;
418
- }
419
-
420
- const tool = registry.get(parsed.toolName);
421
- if (!tool) {
422
- batchApprovedRef.current = false;
423
- setStage({ type: "idle" });
424
- return;
425
- }
426
-
427
- if (parsed.content) {
428
- const preambleMsg: Message = {
429
- role: "assistant",
430
- content: parsed.content,
431
- type: "text",
432
- };
433
- setAllMessages([...currentAll, preambleMsg]);
434
- setCommitted((prev) => [...prev, preambleMsg]);
435
- }
436
-
437
- const remainder = parsed.remainder;
438
- const isSafe = tool.safe ?? false;
439
-
440
- const executeAndContinue = async (approved: boolean) => {
441
- if (approved && remainder) {
442
- batchApprovedRef.current = true;
443
- }
444
-
445
- let result = "(denied by user)";
446
-
447
- if (approved) {
448
- const cacheKey = isSafe
449
- ? `${parsed.toolName}:${parsed.rawInput}`
450
- : null;
451
- if (cacheKey && toolResultCache.current.has(cacheKey)) {
452
- result =
453
- toolResultCache.current.get(cacheKey)! +
454
- "\n\n[NOTE: This result was already retrieved earlier. Do not request it again.]";
455
- } else {
456
- try {
457
- setStage({ type: "thinking" });
458
- const toolResult = await tool.execute(parsed.input, {
459
- repoPath,
460
- messages: currentAll,
461
- });
462
- result = toolResult.value;
463
- if (cacheKey && toolResult.kind === "text") {
464
- toolResultCache.current.set(cacheKey, result);
465
- }
466
- } catch (err: unknown) {
467
- result = `Error: ${err instanceof Error ? err.message : "failed"}`;
468
- }
469
- }
470
- }
471
-
472
- if (approved && !result.startsWith("Error:")) {
473
- appendMemory({
474
- kind: "shell-run",
475
- detail: tool.summariseInput
476
- ? String(tool.summariseInput(parsed.input))
477
- : parsed.rawInput,
478
- summary: result.split("\n")[0]?.slice(0, 120) ?? "",
479
- });
480
- }
481
-
482
- const displayContent = tool.summariseInput
483
- ? String(tool.summariseInput(parsed.input))
484
- : parsed.rawInput;
485
-
486
- const toolMsg: Message = {
487
- role: "assistant",
488
- type: "tool",
489
- toolName: parsed.toolName as any,
490
- content: displayContent,
491
- result,
492
- approved,
493
- };
494
-
495
- const withTool = [...currentAll, toolMsg];
496
- setAllMessages(withTool);
497
- setCommitted((prev) => [...prev, toolMsg]);
498
-
499
- if (approved && remainder && remainder.length > 0) {
500
- processResponse(remainder, withTool, signal);
501
- return;
502
- }
503
-
504
- batchApprovedRef.current = false;
505
-
506
- const nextAbort = new AbortController();
507
- abortControllerRef.current = nextAbort;
508
- setStage({ type: "thinking" });
509
- callChat(provider!, systemPrompt, withTool, nextAbort.signal)
510
- .then((r: string) => processResponse(r, withTool, nextAbort.signal))
511
- .catch(handleError(withTool));
512
- };
513
-
514
- if (forceApprove || (autoApprove && isSafe) || batchApprovedRef.current) {
515
- executeAndContinue(true);
516
- return;
517
- }
518
-
519
- const permLabel = tool.permissionLabel ?? tool.name;
520
- const permValue = tool.summariseInput
521
- ? String(tool.summariseInput(parsed.input))
522
- : parsed.rawInput;
523
-
524
- setStage({
525
- type: "permission",
526
- tool: {
527
- type: parsed.toolName as any,
528
- _display: permValue,
529
- _label: permLabel,
530
- } as any,
531
- pendingMessages: currentAll,
532
- resolve: executeAndContinue,
533
- });
534
- };
535
-
536
- const sendMessage = (text: string) => {
537
- if (!provider) return;
538
-
539
- if (text.trim().toLowerCase() === "/timeline") {
540
- setShowTimeline(true);
541
- return;
542
- }
543
- if (text.trim().toLowerCase() === "/review") {
544
- setShowReview(true);
545
- return;
546
- }
547
-
548
- // /auto --force-all — show warning first
549
- if (text.trim().toLowerCase() === "/auto --force-all") {
550
- if (forceApprove) {
551
- // Toggle off immediately, no warning needed
552
- setForceApprove(false);
553
- setAutoApprove(false);
554
- const msg: Message = {
555
- role: "assistant",
556
- content: "Force-all mode OFF — tools will ask for permission again.",
557
- type: "text",
558
- };
559
- setCommitted((prev) => [...prev, msg]);
560
- setAllMessages((prev) => [...prev, msg]);
561
- } else {
562
- setShowForceWarning(true);
563
- }
564
- return;
565
- }
566
-
567
- if (text.trim().toLowerCase() === "/auto") {
568
- // /auto never enables force-all, only toggles safe auto-approve
569
- if (forceApprove) {
570
- // Step down from force-all to normal auto
571
- setForceApprove(false);
572
- setAutoApprove(true);
573
- const msg: Message = {
574
- role: "assistant",
575
- content:
576
- "Force-all mode OFF — switched to normal auto-approve (safe tools only).",
577
- type: "text",
578
- };
579
- setCommitted((prev) => [...prev, msg]);
580
- setAllMessages((prev) => [...prev, msg]);
581
- return;
582
- }
583
- const next = !autoApprove;
584
- setAutoApprove(next);
585
- const msg: Message = {
586
- role: "assistant",
587
- content: next
588
- ? "Auto-approve ON — safe tools (read, search, fetch) will run without asking."
589
- : "Auto-approve OFF — all tools will ask for permission.",
590
- type: "text",
591
- };
592
- setCommitted((prev) => [...prev, msg]);
593
- setAllMessages((prev) => [...prev, msg]);
594
- return;
595
- }
596
-
597
- if (text.trim().toLowerCase() === "/clear history") {
598
- clearRepoMemory(repoPath);
599
- const msg: Message = {
600
- role: "assistant",
601
- content: "History cleared for this repo.",
602
- type: "text",
603
- };
604
- setCommitted((prev) => [...prev, msg]);
605
- setAllMessages((prev) => [...prev, msg]);
606
- return;
607
- }
608
-
609
- if (text.trim().toLowerCase() === "/chat") {
610
- const msg: Message = {
611
- role: "assistant",
612
- content:
613
- "Chat commands: `/chat list` · `/chat load <n>` · `/chat rename <n>` · `/chat delete <n>`",
614
- type: "text",
615
- };
616
- setCommitted((prev) => [...prev, msg]);
617
- setAllMessages((prev) => [...prev, msg]);
618
- return;
619
- }
620
-
621
- if (text.trim().toLowerCase().startsWith("/chat rename")) {
622
- const parts = text.trim().split(/\s+/);
623
- const newName = parts.slice(2).join("-");
624
- if (!newName) {
625
- const msg: Message = {
626
- role: "assistant",
627
- content: "Usage: `/chat rename <new-name>`",
628
- type: "text",
629
- };
630
- setCommitted((prev) => [...prev, msg]);
631
- setAllMessages((prev) => [...prev, msg]);
632
- return;
633
- }
634
- const oldName = chatNameRef.current;
635
- if (oldName) deleteChat(oldName);
636
- updateChatName(newName);
637
- saveChat(newName, repoPath, allMessages);
638
- setRecentChats((prev) =>
639
- [newName, ...prev.filter((n) => n !== newName && n !== oldName)].slice(
640
- 0,
641
- 10,
642
- ),
643
- );
644
- const msg: Message = {
645
- role: "assistant",
646
- content: `Chat renamed to **${newName}**.`,
647
- type: "text",
648
- };
649
- setCommitted((prev) => [...prev, msg]);
650
- setAllMessages((prev) => [...prev, msg]);
651
- return;
652
- }
653
-
654
- if (text.trim().toLowerCase().startsWith("/chat delete")) {
655
- const parts = text.trim().split(/\s+/);
656
- const name = parts.slice(2).join("-");
657
- if (!name) {
658
- const msg: Message = {
659
- role: "assistant",
660
- content: "Usage: `/chat delete <n>`",
661
- type: "text",
662
- };
663
- setCommitted((prev) => [...prev, msg]);
664
- setAllMessages((prev) => [...prev, msg]);
665
- return;
666
- }
667
- const deleted = deleteChat(name);
668
- if (!deleted) {
669
- const msg: Message = {
670
- role: "assistant",
671
- content: `Chat **${name}** not found.`,
672
- type: "text",
673
- };
674
- setCommitted((prev) => [...prev, msg]);
675
- setAllMessages((prev) => [...prev, msg]);
676
- return;
677
- }
678
- if (chatNameRef.current === name) {
679
- chatNameRef.current = null;
680
- setChatName(null);
681
- }
682
- setRecentChats((prev) => prev.filter((n) => n !== name));
683
- const msg: Message = {
684
- role: "assistant",
685
- content: `Chat **${name}** deleted.`,
686
- type: "text",
687
- };
688
- setCommitted((prev) => [...prev, msg]);
689
- setAllMessages((prev) => [...prev, msg]);
690
- return;
691
- }
692
-
693
- if (text.trim().toLowerCase() === "/chat list") {
694
- const chats = listChats(repoPath);
695
- const content =
696
- chats.length === 0
697
- ? "No saved chats for this repo yet."
698
- : `Saved chats:\n\n${chats
699
- .map(
700
- (c) =>
701
- `- **${c.name}** · ${c.userMessageCount} messages · ${new Date(c.savedAt).toLocaleString()}`,
702
- )
703
- .join("\n")}`;
704
- const msg: Message = { role: "assistant", content, type: "text" };
705
- setCommitted((prev) => [...prev, msg]);
706
- setAllMessages((prev) => [...prev, msg]);
707
- return;
708
- }
709
-
710
- if (text.trim().toLowerCase().startsWith("/chat load")) {
711
- const parts = text.trim().split(/\s+/);
712
- const name = parts.slice(2).join("-");
713
- if (!name) {
714
- const chats = listChats(repoPath);
715
- const content =
716
- chats.length === 0
717
- ? "No saved chats found."
718
- : `Specify a chat name. Recent chats:\n\n${chats
719
- .slice(0, 10)
720
- .map((c) => `- **${c.name}**`)
721
- .join("\n")}`;
722
- const msg: Message = { role: "assistant", content, type: "text" };
723
- setCommitted((prev) => [...prev, msg]);
724
- setAllMessages((prev) => [...prev, msg]);
725
- return;
726
- }
727
- const saved = loadChat(name);
728
- if (!saved) {
729
- const msg: Message = {
730
- role: "assistant",
731
- content: `Chat **${name}** not found. Use \`/chat list\` to see saved chats.`,
732
- type: "text",
733
- };
734
- setCommitted((prev) => [...prev, msg]);
735
- setAllMessages((prev) => [...prev, msg]);
736
- return;
737
- }
738
- updateChatName(name);
739
- setAllMessages(saved.messages);
740
- setCommitted(saved.messages);
741
- const notice: Message = {
742
- role: "assistant",
743
- content: `Loaded chat **${name}** · ${saved.userMessageCount} messages · saved ${new Date(saved.savedAt).toLocaleString()}`,
744
- type: "text",
745
- };
746
- setCommitted((prev) => [...prev, notice]);
747
- setAllMessages((prev) => [...prev, notice]);
748
- return;
749
- }
750
-
751
- if (
752
- text.trim().toLowerCase() === "/memory list" ||
753
- text.trim().toLowerCase() === "/memory"
754
- ) {
755
- const mems = listMemories(repoPath);
756
- const content =
757
- mems.length === 0
758
- ? "No memories stored for this repo yet."
759
- : `Memories for this repo:\n\n${mems
760
- .map((m) => `- [${m.id}] ${m.content}`)
761
- .join("\n")}`;
762
- const msg: Message = { role: "assistant", content, type: "text" };
763
- setCommitted((prev) => [...prev, msg]);
764
- setAllMessages((prev) => [...prev, msg]);
765
- return;
766
- }
767
-
768
- if (text.trim().toLowerCase().startsWith("/memory add")) {
769
- const content = text.trim().slice("/memory add".length).trim();
770
- if (!content) {
771
- const msg: Message = {
772
- role: "assistant",
773
- content: "Usage: `/memory add <content>`",
774
- type: "text",
775
- };
776
- setCommitted((prev) => [...prev, msg]);
777
- setAllMessages((prev) => [...prev, msg]);
778
- return;
779
- }
780
- const mem = addMemory(content, repoPath);
781
- const msg: Message = {
782
- role: "assistant",
783
- content: `Memory saved **[${mem.id}]**: ${mem.content}`,
784
- type: "text",
785
- };
786
- setCommitted((prev) => [...prev, msg]);
787
- setAllMessages((prev) => [...prev, msg]);
788
- return;
789
- }
790
-
791
- if (text.trim().toLowerCase().startsWith("/memory delete")) {
792
- const id = text.trim().split(/\s+/)[2];
793
- if (!id) {
794
- const msg: Message = {
795
- role: "assistant",
796
- content: "Usage: `/memory delete <id>`",
797
- type: "text",
798
- };
799
- setCommitted((prev) => [...prev, msg]);
800
- setAllMessages((prev) => [...prev, msg]);
801
- return;
802
- }
803
- const deleted = deleteMemory(id, repoPath);
804
- const msg: Message = {
805
- role: "assistant",
806
- content: deleted
807
- ? `Memory **[${id}]** deleted.`
808
- : `Memory **[${id}]** not found.`,
809
- type: "text",
810
- };
811
- setCommitted((prev) => [...prev, msg]);
812
- setAllMessages((prev) => [...prev, msg]);
813
- return;
814
- }
815
-
816
- if (text.trim().toLowerCase() === "/memory clear") {
817
- clearRepoMemory(repoPath);
818
- const msg: Message = {
819
- role: "assistant",
820
- content: "All memories cleared for this repo.",
821
- type: "text",
822
- };
823
- setCommitted((prev) => [...prev, msg]);
824
- setAllMessages((prev) => [...prev, msg]);
825
- return;
826
- }
827
-
828
- const userMsg: Message = { role: "user", content: text, type: "text" };
829
- const nextAll = [...allMessages, userMsg];
830
- setCommitted((prev) => [...prev, userMsg]);
831
- setAllMessages(nextAll);
832
- // Do NOT clear toolResultCache here — safe tool results (read-file, read-folder, grep)
833
- // persist across the whole session so the model never re-reads the same resource twice.
834
- batchApprovedRef.current = false;
835
-
836
- inputHistoryRef.current = [
837
- text,
838
- ...inputHistoryRef.current.filter((m) => m !== text),
839
- ].slice(0, 50);
840
- historyIndexRef.current = -1;
841
-
842
- if (!chatName) {
843
- const name =
844
- getChatNameSuggestions(nextAll)[0] ??
845
- `chat-${new Date().toISOString().slice(0, 10)}`;
846
- updateChatName(name);
847
- setRecentChats((prev) =>
848
- [name, ...prev.filter((n) => n !== name)].slice(0, 10),
849
- );
850
- saveChat(name, repoPath, nextAll);
851
- }
852
-
853
- const abort = new AbortController();
854
- abortControllerRef.current = abort;
855
-
856
- setStage({ type: "thinking" });
857
- callChat(provider, systemPrompt, nextAll, abort.signal)
858
- .then((raw: string) => processResponse(raw, nextAll, abort.signal))
859
- .catch(handleError(nextAll));
860
- };
151
+ const chat = useChat(repoPath);
152
+ const thinkingPhrase = useThinkingPhrase(chat.stage.type === "thinking");
861
153
 
862
- useInput((input, key) => {
863
- if (showTimeline) return;
154
+ const handleStageKey = (input: string, key: any) => {
155
+ const { stage } = chat;
864
156
 
865
- // Esc cancels the force-all warning
866
- if (showForceWarning && key.escape) {
867
- setShowForceWarning(false);
868
- return;
869
- }
870
-
871
- if (stage.type === "thinking" && key.escape) {
872
- abortControllerRef.current?.abort();
873
- abortControllerRef.current = null;
874
- batchApprovedRef.current = false;
875
- setStage({ type: "idle" });
876
- return;
877
- }
878
-
879
- if (stage.type === "idle") {
880
- if (key.ctrl && input === "c") {
881
- process.exit(0);
882
- return;
883
- }
884
- if (key.upArrow && inputHistoryRef.current.length > 0) {
885
- const next = Math.min(
886
- historyIndexRef.current + 1,
887
- inputHistoryRef.current.length - 1,
888
- );
889
- historyIndexRef.current = next;
890
- setInputValue(inputHistoryRef.current[next]!);
891
- setInputKey((k) => k + 1);
892
- return;
893
- }
894
- if (key.downArrow) {
895
- const next = historyIndexRef.current - 1;
896
- historyIndexRef.current = next;
897
- setInputValue(next < 0 ? "" : inputHistoryRef.current[next]!);
898
- setInputKey((k) => k + 1);
899
- return;
900
- }
901
- if (key.tab && inputValue.startsWith("/")) {
902
- const q = inputValue.toLowerCase();
903
- const match = COMMANDS.find((c) => c.cmd.startsWith(q));
904
- if (match) setInputValue(match.cmd);
905
- return;
906
- }
157
+ if (chat.showForceWarning && key.escape) {
158
+ chat.setShowForceWarning(false);
907
159
  return;
908
160
  }
909
161
 
@@ -912,7 +164,7 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
912
164
  const { repoUrl } = stage;
913
165
  const launch = stage.launchAnalysis ?? false;
914
166
  const cloneUrl = toCloneUrl(repoUrl);
915
- setStage({ type: "cloning", repoUrl });
167
+ chat.setStage({ type: "cloning", repoUrl });
916
168
  startCloneRepo(cloneUrl).then((result) => {
917
169
  if (result.done) {
918
170
  const repoName =
@@ -927,8 +179,8 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
927
179
  detail: repoUrl,
928
180
  summary: `Cloned ${repoName} — ${fileCount} files`,
929
181
  });
930
- setClonedUrls((prev) => new Set([...prev, repoUrl]));
931
- setStage({
182
+ chat.setClonedUrls((prev) => new Set([...prev, repoUrl]));
183
+ chat.setStage({
932
184
  type: "clone-done",
933
185
  repoUrl,
934
186
  destPath,
@@ -936,13 +188,13 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
936
188
  launchAnalysis: launch,
937
189
  });
938
190
  } else if (result.folderExists && result.repoPath) {
939
- setStage({
191
+ chat.setStage({
940
192
  type: "clone-exists",
941
193
  repoUrl,
942
194
  repoPath: result.repoPath,
943
195
  });
944
196
  } else {
945
- setStage({
197
+ chat.setStage({
946
198
  type: "clone-error",
947
199
  message:
948
200
  !result.folderExists && result.error
@@ -954,25 +206,25 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
954
206
  return;
955
207
  }
956
208
  if (input === "n" || input === "N" || key.escape)
957
- setStage({ type: "idle" });
209
+ chat.setStage({ type: "idle" });
958
210
  return;
959
211
  }
960
212
 
961
213
  if (stage.type === "clone-exists") {
962
214
  if (input === "y" || input === "Y") {
963
215
  const { repoUrl, repoPath: existingPath } = stage;
964
- setStage({ type: "cloning", repoUrl });
216
+ chat.setStage({ type: "cloning", repoUrl });
965
217
  startCloneRepo(toCloneUrl(repoUrl), { forceReclone: true }).then(
966
218
  (result) => {
967
219
  if (result.done) {
968
- setStage({
220
+ chat.setStage({
969
221
  type: "clone-done",
970
222
  repoUrl,
971
223
  destPath: existingPath,
972
224
  fileCount: walkDir(existingPath).length,
973
225
  });
974
226
  } else {
975
- setStage({
227
+ chat.setStage({
976
228
  type: "clone-error",
977
229
  message:
978
230
  !result.folderExists && result.error
@@ -986,7 +238,7 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
986
238
  }
987
239
  if (input === "n" || input === "N") {
988
240
  const { repoUrl, repoPath: existingPath } = stage;
989
- setStage({
241
+ chat.setStage({
990
242
  type: "clone-done",
991
243
  repoUrl,
992
244
  destPath: existingPath,
@@ -1014,12 +266,11 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
1014
266
  result: `Clone complete. Repo: ${repoName}. Local path: ${stage.destPath}. ${stage.fileCount} files.`,
1015
267
  approved: true,
1016
268
  };
1017
- const withClone = [...allMessages, contextMsg, summaryMsg];
1018
- setAllMessages(withClone);
1019
- setCommitted((prev) => [...prev, summaryMsg]);
1020
- setStage({ type: "idle" });
269
+ chat.setAllMessages([...chat.allMessages, contextMsg, summaryMsg]);
270
+ chat.setCommitted((prev) => [...prev, summaryMsg]);
271
+ chat.setStage({ type: "idle" });
1021
272
  } else {
1022
- setStage({ type: "idle" });
273
+ chat.setStage({ type: "idle" });
1023
274
  }
1024
275
  }
1025
276
  return;
@@ -1033,7 +284,7 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
1033
284
  return;
1034
285
  }
1035
286
  if (input === "n" || input === "N" || key.escape) {
1036
- batchApprovedRef.current = false;
287
+ chat.batchApprovedRef.current = false;
1037
288
  stage.resolve(false);
1038
289
  return;
1039
290
  }
@@ -1042,111 +293,116 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
1042
293
 
1043
294
  if (stage.type === "preview") {
1044
295
  if (key.upArrow) {
1045
- setStage({
296
+ chat.setStage({
1046
297
  ...stage,
1047
298
  scrollOffset: Math.max(0, stage.scrollOffset - 1),
1048
299
  });
1049
300
  return;
1050
301
  }
1051
302
  if (key.downArrow) {
1052
- setStage({ ...stage, scrollOffset: stage.scrollOffset + 1 });
303
+ chat.setStage({ ...stage, scrollOffset: stage.scrollOffset + 1 });
1053
304
  return;
1054
305
  }
1055
306
  if (key.escape || input === "s" || input === "S") {
1056
- if (pendingMsgIndex !== null) {
1057
- const msg = allMessages[pendingMsgIndex];
307
+ if (chat.pendingMsgIndex !== null) {
308
+ const msg = chat.allMessages[chat.pendingMsgIndex];
1058
309
  if (msg?.type === "plan") {
1059
- setCommitted((prev) => [...prev, { ...msg, applied: false }]);
1060
- appendMemory({
1061
- kind: "code-skipped",
1062
- detail: msg.patches
1063
- .map((p: { path: string }) => p.path)
1064
- .join(", "),
1065
- summary: `Skipped changes to ${msg.patches.length} file(s)`,
1066
- });
310
+ chat.setCommitted((prev) => [...prev, { ...msg, applied: false }]);
311
+ chat.skipPatches(msg.patches);
1067
312
  }
1068
313
  }
1069
- setPendingMsgIndex(null);
1070
- setStage({ type: "idle" });
314
+ chat.setPendingMsgIndex(null);
315
+ chat.setStage({ type: "idle" });
1071
316
  return;
1072
317
  }
1073
318
  if (key.return || input === "a" || input === "A") {
1074
- try {
1075
- applyPatches(repoPath, stage.patches);
1076
- appendMemory({
1077
- kind: "code-applied",
1078
- detail: stage.patches.map((p) => p.path).join(", "),
1079
- summary: `Applied changes to ${stage.patches.length} file(s)`,
1080
- });
1081
- } catch {
1082
- /* non-fatal */
1083
- }
1084
- if (pendingMsgIndex !== null) {
1085
- const msg = allMessages[pendingMsgIndex];
319
+ if (chat.pendingMsgIndex !== null) {
320
+ const msg = chat.allMessages[chat.pendingMsgIndex];
1086
321
  if (msg?.type === "plan") {
322
+ chat.applyPatchesAndContinue(msg.patches);
1087
323
  const applied: Message = { ...msg, applied: true };
1088
- setAllMessages((prev) =>
1089
- prev.map((m, i) => (i === pendingMsgIndex ? applied : m)),
324
+ chat.setAllMessages((prev) =>
325
+ prev.map((m, i) => (i === chat.pendingMsgIndex ? applied : m)),
1090
326
  );
1091
- setCommitted((prev) => [...prev, applied]);
327
+ chat.setCommitted((prev) => [...prev, applied]);
1092
328
  }
1093
329
  }
1094
- setPendingMsgIndex(null);
1095
- setStage({ type: "idle" });
330
+ chat.setPendingMsgIndex(null);
331
+ chat.setStage({ type: "idle" });
1096
332
  return;
1097
333
  }
1098
334
  }
1099
335
 
1100
336
  if (stage.type === "viewing-file") {
1101
337
  if (key.upArrow) {
1102
- setStage({
338
+ chat.setStage({
1103
339
  ...stage,
1104
340
  scrollOffset: Math.max(0, stage.scrollOffset - 1),
1105
341
  });
1106
342
  return;
1107
343
  }
1108
344
  if (key.downArrow) {
1109
- setStage({ ...stage, scrollOffset: stage.scrollOffset + 1 });
345
+ chat.setStage({ ...stage, scrollOffset: stage.scrollOffset + 1 });
1110
346
  return;
1111
347
  }
1112
348
  if (key.escape || key.return) {
1113
- setStage({ type: "idle" });
349
+ chat.setStage({ type: "idle" });
1114
350
  return;
1115
351
  }
1116
352
  }
1117
- });
353
+ };
1118
354
 
1119
- const handleProviderDone = (p: Provider) => {
1120
- setProvider(p);
1121
- setStage({ type: "loading" });
1122
- fetchFileTree(repoPath)
1123
- .catch(() => walkDir(repoPath))
1124
- .then((fileTree) => {
1125
- const importantFiles = readImportantFiles(repoPath, fileTree);
1126
- const historySummary = buildMemorySummary(repoPath);
1127
- const lensFile = readLensFile(repoPath);
1128
- const lensContext = lensFile
1129
- ? `\n\n## LENS.md (previous analysis)\n${lensFile.overview}\n\nImportant folders: ${lensFile.importantFolders.join(", ")}\nSuggestions: ${lensFile.suggestions.slice(0, 3).join("; ")}`
1130
- : "";
1131
- const toolsSection = registry.buildSystemPromptSection();
1132
- setSystemPrompt(
1133
- buildSystemPrompt(importantFiles, historySummary, toolsSection) +
1134
- lensContext,
1135
- );
1136
- const greeting: Message = {
1137
- role: "assistant",
1138
- 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.`,
1139
- type: "text",
1140
- };
1141
- setCommitted([greeting]);
1142
- setAllMessages([greeting]);
1143
- setStage({ type: "idle" });
1144
- })
1145
- .catch(() => setStage({ type: "idle" }));
355
+ const chatInput = useChatInput(
356
+ chat.stage,
357
+ chat.showTimeline,
358
+ chat.showForceWarning,
359
+ chat.abortThinking,
360
+ handleStageKey,
361
+ );
362
+
363
+ const sendMessage = (text: string) => {
364
+ if (!chat.provider) return;
365
+
366
+ const handled = handleCommand(text, {
367
+ repoPath,
368
+ allMessages: chat.allMessages,
369
+ autoApprove: chat.autoApprove,
370
+ forceApprove: chat.forceApprove,
371
+ chatName: chat.chatName,
372
+ chatNameRef: chat.chatNameRef,
373
+ setShowTimeline: chat.setShowTimeline,
374
+ setShowReview: chat.setShowReview,
375
+ setShowForceWarning: chat.setShowForceWarning,
376
+ setForceApprove: chat.setForceApprove,
377
+ setAutoApprove: chat.setAutoApprove,
378
+ setAllMessages: chat.setAllMessages as any,
379
+ setCommitted: chat.setCommitted as any,
380
+ setRecentChats: chat.setRecentChats,
381
+ updateChatName: chat.updateChatName,
382
+ });
383
+
384
+ if (handled) return;
385
+
386
+ chatInput.pushHistory(text);
387
+ chat.sendMessage(text, chat.provider, chat.systemPrompt, chat.allMessages);
388
+
389
+ if (!chat.chatName) {
390
+ const name =
391
+ getChatNameSuggestions([
392
+ ...chat.allMessages,
393
+ { role: "user", content: text, type: "text" },
394
+ ])[0] ?? `chat-${new Date().toISOString().slice(0, 10)}`;
395
+ chat.updateChatName(name);
396
+ chat.setRecentChats((prev) =>
397
+ [name, ...prev.filter((n) => n !== name)].slice(0, 10),
398
+ );
399
+ }
1146
400
  };
1147
401
 
402
+ const { stage } = chat;
403
+
1148
404
  if (stage.type === "picking-provider")
1149
- return <ProviderPicker onDone={handleProviderDone} />;
405
+ return <ProviderPicker onDone={chat.handleProviderDone} />;
1150
406
  if (stage.type === "loading")
1151
407
  return (
1152
408
  <Box gap={1} marginTop={1}>
@@ -1159,68 +415,67 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
1159
415
  </Text>
1160
416
  </Box>
1161
417
  );
1162
- if (showTimeline)
418
+ if (chat.showTimeline)
1163
419
  return (
1164
420
  <TimelineRunner
1165
421
  repoPath={repoPath}
1166
- onExit={() => setShowTimeline(false)}
422
+ onExit={() => chat.setShowTimeline(false)}
1167
423
  />
1168
424
  );
1169
- if (showReview)
425
+ if (chat.showReview)
1170
426
  return (
1171
- <ReviewCommand path={repoPath} onExit={() => setShowReview(false)} />
427
+ <ReviewCommand path={repoPath} onExit={() => chat.setShowReview(false)} />
1172
428
  );
1173
429
  if (stage.type === "clone-offer")
1174
- return <CloneOfferView stage={stage} committed={committed} />;
430
+ return <CloneOfferView stage={stage} committed={chat.committed} />;
1175
431
  if (stage.type === "cloning")
1176
- return <CloningView stage={stage} committed={committed} />;
432
+ return <CloningView stage={stage} committed={chat.committed} />;
1177
433
  if (stage.type === "clone-exists")
1178
- return <CloneExistsView stage={stage} committed={committed} />;
434
+ return <CloneExistsView stage={stage} committed={chat.committed} />;
1179
435
  if (stage.type === "clone-done")
1180
- return <CloneDoneView stage={stage} committed={committed} />;
436
+ return <CloneDoneView stage={stage} committed={chat.committed} />;
1181
437
  if (stage.type === "clone-error")
1182
- return <CloneErrorView stage={stage} committed={committed} />;
438
+ return <CloneErrorView stage={stage} committed={chat.committed} />;
1183
439
  if (stage.type === "preview")
1184
- return <PreviewView stage={stage} committed={committed} />;
440
+ return <PreviewView stage={stage} committed={chat.committed} />;
1185
441
  if (stage.type === "viewing-file")
1186
- return <ViewingFileView stage={stage} committed={committed} />;
442
+ return <ViewingFileView stage={stage} committed={chat.committed} />;
1187
443
 
1188
444
  return (
1189
445
  <Box flexDirection="column">
1190
- <Static items={committed}>
446
+ <Static items={chat.committed}>
1191
447
  {(msg, i) => <StaticMessage key={i} msg={msg} />}
1192
448
  </Static>
1193
449
 
1194
- {/* Force-all warning overlay */}
1195
- {showForceWarning && (
450
+ {chat.showForceWarning && (
1196
451
  <ForceAllWarning
1197
452
  onConfirm={(confirmed) => {
1198
- setShowForceWarning(false);
453
+ chat.setShowForceWarning(false);
1199
454
  if (confirmed) {
1200
- setForceApprove(true);
1201
- setAutoApprove(true);
455
+ chat.setForceApprove(true);
456
+ chat.setAutoApprove(true);
1202
457
  const msg: Message = {
1203
458
  role: "assistant",
1204
459
  content:
1205
460
  "⚡⚡ Force-all mode ON — ALL tools auto-approved including shell and writes. Type /auto --force-all again to disable.",
1206
461
  type: "text",
1207
462
  };
1208
- setCommitted((prev) => [...prev, msg]);
1209
- setAllMessages((prev) => [...prev, msg]);
463
+ chat.setCommitted((prev) => [...prev, msg]);
464
+ chat.setAllMessages((prev: Message[]) => [...prev, msg]);
1210
465
  } else {
1211
466
  const msg: Message = {
1212
467
  role: "assistant",
1213
468
  content: "Force-all cancelled.",
1214
469
  type: "text",
1215
470
  };
1216
- setCommitted((prev) => [...prev, msg]);
1217
- setAllMessages((prev) => [...prev, msg]);
471
+ chat.setCommitted((prev) => [...prev, msg]);
472
+ chat.setAllMessages((prev: Message[]) => [...prev, msg]);
1218
473
  }
1219
474
  }}
1220
475
  />
1221
476
  )}
1222
477
 
1223
- {!showForceWarning && stage.type === "thinking" && (
478
+ {!chat.showForceWarning && stage.type === "thinking" && (
1224
479
  <Box gap={1}>
1225
480
  <Text color={ACCENT}>●</Text>
1226
481
  <TypewriterText text={thinkingPhrase} />
@@ -1230,32 +485,31 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
1230
485
  </Box>
1231
486
  )}
1232
487
 
1233
- {!showForceWarning && stage.type === "permission" && (
488
+ {!chat.showForceWarning && stage.type === "permission" && (
1234
489
  <PermissionPrompt tool={stage.tool} onDecide={stage.resolve} />
1235
490
  )}
1236
491
 
1237
- {!showForceWarning && stage.type === "idle" && (
492
+ {!chat.showForceWarning && stage.type === "idle" && (
1238
493
  <Box flexDirection="column">
1239
- {inputValue.startsWith("/") && (
494
+ {chatInput.inputValue.startsWith("/") && (
1240
495
  <CommandPalette
1241
- query={inputValue}
1242
- onSelect={(cmd) => setInputValue(cmd)}
1243
- recentChats={recentChats}
496
+ query={chatInput.inputValue}
497
+ recentChats={chat.recentChats}
1244
498
  />
1245
499
  )}
1246
500
  <InputBox
1247
- value={inputValue}
1248
- onChange={(v) => {
1249
- historyIndexRef.current = -1;
1250
- setInputValue(v);
1251
- }}
501
+ value={chatInput.inputValue}
502
+ onChange={(v) => chatInput.setInputValue(v)}
1252
503
  onSubmit={(val) => {
1253
504
  if (val.trim()) sendMessage(val.trim());
1254
- setInputValue("");
505
+ chatInput.setInputValue("");
1255
506
  }}
1256
- inputKey={inputKey}
507
+ inputKey={chatInput.inputKey}
508
+ />
509
+ <ShortcutBar
510
+ autoApprove={chat.autoApprove}
511
+ forceApprove={chat.forceApprove}
1257
512
  />
1258
- <ShortcutBar autoApprove={autoApprove} forceApprove={forceApprove} />
1259
513
  </Box>
1260
514
  )}
1261
515
  </Box>