@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.
@@ -0,0 +1,531 @@
1
+ import { useState, useRef } from "react";
2
+ import React from "react";
3
+ import type { Provider } from "../../../types/config";
4
+ import type { Message, ChatStage } from "../../../types/chat";
5
+ import {
6
+ saveChat,
7
+ listChats,
8
+ getChatNameSuggestions,
9
+ } from "../../../utils/chatHistory";
10
+ import {
11
+ appendMemory,
12
+ buildMemorySummary,
13
+ addMemory,
14
+ deleteMemory,
15
+ } from "../../../utils/memory";
16
+ import { fetchFileTree, readImportantFiles } from "../../../utils/files";
17
+ import { readLensFile } from "../../../utils/lensfile";
18
+ import { registry } from "../../../utils/tools/registry";
19
+ import { buildDiffs } from "../../repo/DiffViewer";
20
+ import {
21
+ walkDir,
22
+ applyPatches,
23
+ extractGithubUrl,
24
+ toCloneUrl,
25
+ buildSystemPrompt,
26
+ parseResponse,
27
+ callChat,
28
+ } from "../../../utils/chat";
29
+
30
+ export function useChat(repoPath: string) {
31
+ const [stage, setStage] = useState<ChatStage>({ type: "picking-provider" });
32
+ const [committed, setCommitted] = useState<Message[]>([]);
33
+ const [provider, setProvider] = useState<Provider | null>(null);
34
+ const [systemPrompt, setSystemPrompt] = useState("");
35
+ const [pendingMsgIndex, setPendingMsgIndex] = useState<number | null>(null);
36
+ const [allMessages, setAllMessages] = useState<Message[]>([]);
37
+ const [clonedUrls, setClonedUrls] = useState<Set<string>>(new Set());
38
+ const [showTimeline, setShowTimeline] = useState(false);
39
+ const [showReview, setShowReview] = useState(false);
40
+ const [autoApprove, setAutoApprove] = useState(false);
41
+ const [forceApprove, setForceApprove] = useState(false);
42
+ const [showForceWarning, setShowForceWarning] = useState(false);
43
+ const [chatName, setChatName] = useState<string | null>(null);
44
+ const [recentChats, setRecentChats] = useState<string[]>([]);
45
+
46
+ const chatNameRef = useRef<string | null>(null);
47
+ const providerRef = useRef<Provider | null>(null);
48
+ const systemPromptRef = useRef<string>("");
49
+ const abortControllerRef = useRef<AbortController | null>(null);
50
+ const toolResultCache = useRef<Map<string, string>>(new Map());
51
+ const batchApprovedRef = useRef(false);
52
+
53
+ const updateChatName = (name: string) => {
54
+ chatNameRef.current = name;
55
+ setChatName(name);
56
+ };
57
+
58
+ React.useEffect(() => {
59
+ providerRef.current = provider;
60
+ }, [provider]);
61
+ React.useEffect(() => {
62
+ systemPromptRef.current = systemPrompt;
63
+ }, [systemPrompt]);
64
+
65
+ React.useEffect(() => {
66
+ const chats = listChats(repoPath);
67
+ setRecentChats(chats.slice(0, 10).map((c) => c.name));
68
+ }, [repoPath]);
69
+
70
+ React.useEffect(() => {
71
+ if (chatNameRef.current && allMessages.length > 1) {
72
+ saveChat(chatNameRef.current, repoPath, allMessages);
73
+ }
74
+ }, [allMessages]);
75
+
76
+ const handleError = (currentAll: Message[]) => (err: unknown) => {
77
+ batchApprovedRef.current = false;
78
+ if (err instanceof Error && err.name === "AbortError") {
79
+ setStage({ type: "idle" });
80
+ return;
81
+ }
82
+ const errMsg: Message = {
83
+ role: "assistant",
84
+ content: `Error: ${err instanceof Error ? err.message : "Something went wrong"}`,
85
+ type: "text",
86
+ };
87
+ setAllMessages([...currentAll, errMsg]);
88
+ setCommitted((prev) => [...prev, errMsg]);
89
+ setStage({ type: "idle" });
90
+ };
91
+
92
+ const TOOL_TAG_NAMES = [
93
+ "shell",
94
+ "fetch",
95
+ "read-file",
96
+ "read-folder",
97
+ "grep",
98
+ "write-file",
99
+ "delete-file",
100
+ "delete-folder",
101
+ "open-url",
102
+ "generate-pdf",
103
+ "search",
104
+ "clone",
105
+ "changes",
106
+ ];
107
+
108
+ function isLikelyTruncated(text: string): boolean {
109
+ return TOOL_TAG_NAMES.some(
110
+ (tag) => text.includes(`<${tag}>`) && !text.includes(`</${tag}>`),
111
+ );
112
+ }
113
+
114
+ const processResponse = (
115
+ raw: string,
116
+ currentAll: Message[],
117
+ signal: AbortSignal,
118
+ ) => {
119
+ if (signal.aborted) {
120
+ batchApprovedRef.current = false;
121
+ setStage({ type: "idle" });
122
+ return;
123
+ }
124
+
125
+ if (isLikelyTruncated(raw)) {
126
+ const truncMsg: Message = {
127
+ role: "assistant",
128
+ content:
129
+ "(response cut off — the model hit its output limit mid-tool-call. Try asking it to continue, or simplify the request.)",
130
+ type: "text",
131
+ };
132
+ setAllMessages([...currentAll, truncMsg]);
133
+ setCommitted((prev) => [...prev, truncMsg]);
134
+ setStage({ type: "idle" });
135
+ return;
136
+ }
137
+
138
+ const memAddMatches = [
139
+ ...raw.matchAll(/<memory-add>([\s\S]*?)<\/memory-add>/g),
140
+ ];
141
+ const memDelMatches = [
142
+ ...raw.matchAll(/<memory-delete>([\s\S]*?)<\/memory-delete>/g),
143
+ ];
144
+ for (const match of memAddMatches) {
145
+ const content = match[1]!.trim();
146
+ if (content) addMemory(content, repoPath);
147
+ }
148
+ for (const match of memDelMatches) {
149
+ const id = match[1]!.trim();
150
+ if (id) deleteMemory(id, repoPath);
151
+ }
152
+ const cleanRaw = raw
153
+ .replace(/<memory-add>[\s\S]*?<\/memory-add>/g, "")
154
+ .replace(/<memory-delete>[\s\S]*?<\/memory-delete>/g, "")
155
+ .trim();
156
+
157
+ const parsed = parseResponse(cleanRaw);
158
+
159
+ if (parsed.kind === "changes") {
160
+ batchApprovedRef.current = false;
161
+ if (parsed.patches.length === 0) {
162
+ const msg: Message = {
163
+ role: "assistant",
164
+ content: parsed.content,
165
+ type: "text",
166
+ };
167
+ setAllMessages([...currentAll, msg]);
168
+ setCommitted((prev) => [...prev, msg]);
169
+ setStage({ type: "idle" });
170
+ return;
171
+ }
172
+ const assistantMsg: Message = {
173
+ role: "assistant",
174
+ content: parsed.content,
175
+ type: "plan",
176
+ patches: parsed.patches,
177
+ applied: false,
178
+ };
179
+ const withAssistant = [...currentAll, assistantMsg];
180
+ setAllMessages(withAssistant);
181
+ setPendingMsgIndex(withAssistant.length - 1);
182
+ const diffLines = buildDiffs(repoPath, parsed.patches);
183
+ setStage({
184
+ type: "preview",
185
+ patches: parsed.patches,
186
+ diffLines,
187
+ scrollOffset: 0,
188
+ pendingMessages: currentAll,
189
+ });
190
+ return;
191
+ }
192
+
193
+ if (parsed.kind === "clone") {
194
+ batchApprovedRef.current = false;
195
+ if (parsed.content) {
196
+ const preambleMsg: Message = {
197
+ role: "assistant",
198
+ content: parsed.content,
199
+ type: "text",
200
+ };
201
+ setAllMessages([...currentAll, preambleMsg]);
202
+ setCommitted((prev) => [...prev, preambleMsg]);
203
+ }
204
+ setStage({
205
+ type: "clone-offer",
206
+ repoUrl: parsed.repoUrl,
207
+ launchAnalysis: true,
208
+ });
209
+ return;
210
+ }
211
+
212
+ if (parsed.kind === "text") {
213
+ batchApprovedRef.current = false;
214
+
215
+ if (!parsed.content.trim()) {
216
+ const stallMsg: Message = {
217
+ role: "assistant",
218
+ content:
219
+ '(no response — the model may have stalled. Try sending a short follow-up like "continue" or start a new message.)',
220
+ type: "text",
221
+ };
222
+ setAllMessages([...currentAll, stallMsg]);
223
+ setCommitted((prev) => [...prev, stallMsg]);
224
+ setStage({ type: "idle" });
225
+ return;
226
+ }
227
+
228
+ const msg: Message = {
229
+ role: "assistant",
230
+ content: parsed.content,
231
+ type: "text",
232
+ };
233
+ const withMsg = [...currentAll, msg];
234
+ setAllMessages(withMsg);
235
+ setCommitted((prev) => [...prev, msg]);
236
+ const lastUserMsg = [...currentAll]
237
+ .reverse()
238
+ .find((m) => m.role === "user");
239
+ const githubUrl = lastUserMsg
240
+ ? extractGithubUrl(lastUserMsg.content)
241
+ : null;
242
+ if (githubUrl && !clonedUrls.has(githubUrl)) {
243
+ setTimeout(
244
+ () => setStage({ type: "clone-offer", repoUrl: githubUrl }),
245
+ 80,
246
+ );
247
+ } else {
248
+ setStage({ type: "idle" });
249
+ }
250
+ return;
251
+ }
252
+
253
+ const tool = registry.get(parsed.toolName);
254
+ if (!tool) {
255
+ batchApprovedRef.current = false;
256
+ setStage({ type: "idle" });
257
+ return;
258
+ }
259
+
260
+ if (parsed.content) {
261
+ const preambleMsg: Message = {
262
+ role: "assistant",
263
+ content: parsed.content,
264
+ type: "text",
265
+ };
266
+ setAllMessages([...currentAll, preambleMsg]);
267
+ setCommitted((prev) => [...prev, preambleMsg]);
268
+ }
269
+
270
+ const remainder = parsed.remainder;
271
+ const isSafe = tool.safe ?? false;
272
+
273
+ const executeAndContinue = async (approved: boolean) => {
274
+ if (approved && remainder) {
275
+ batchApprovedRef.current = true;
276
+ }
277
+
278
+ const currentProvider = providerRef.current;
279
+ const currentSystemPrompt = systemPromptRef.current;
280
+
281
+ if (!currentProvider) {
282
+ batchApprovedRef.current = false;
283
+ setStage({ type: "idle" });
284
+ return;
285
+ }
286
+
287
+ let result = "(denied by user)";
288
+
289
+ if (approved) {
290
+ const cacheKey = isSafe
291
+ ? `${parsed.toolName}:${parsed.rawInput}`
292
+ : null;
293
+ if (cacheKey && toolResultCache.current.has(cacheKey)) {
294
+ result =
295
+ toolResultCache.current.get(cacheKey)! +
296
+ "\n\n[NOTE: This result was already retrieved earlier. Do not request it again.]";
297
+ } else {
298
+ try {
299
+ setStage({ type: "thinking" });
300
+ const toolResult = await tool.execute(parsed.input, {
301
+ repoPath,
302
+ messages: currentAll,
303
+ });
304
+ result = toolResult.value;
305
+ if (cacheKey && toolResult.kind === "text") {
306
+ toolResultCache.current.set(cacheKey, result);
307
+ }
308
+ } catch (err: unknown) {
309
+ result = `Error: ${err instanceof Error ? err.message : "failed"}`;
310
+ }
311
+ }
312
+ }
313
+
314
+ if (approved && !result.startsWith("Error:")) {
315
+ appendMemory({
316
+ kind: "shell-run",
317
+ detail: tool.summariseInput
318
+ ? String(tool.summariseInput(parsed.input))
319
+ : parsed.rawInput,
320
+ summary: result.split("\n")[0]?.slice(0, 120) ?? "",
321
+ });
322
+ }
323
+
324
+ const displayContent = tool.summariseInput
325
+ ? String(tool.summariseInput(parsed.input))
326
+ : parsed.rawInput;
327
+
328
+ const toolMsg: Message = {
329
+ role: "assistant",
330
+ type: "tool",
331
+ toolName: parsed.toolName as any,
332
+ content: displayContent,
333
+ result,
334
+ approved,
335
+ };
336
+
337
+ const withTool = [...currentAll, toolMsg];
338
+ setAllMessages(withTool);
339
+ setCommitted((prev) => [...prev, toolMsg]);
340
+
341
+ if (approved && remainder && remainder.length > 0) {
342
+ processResponse(remainder, withTool, signal);
343
+ return;
344
+ }
345
+
346
+ batchApprovedRef.current = false;
347
+
348
+ const nextAbort = new AbortController();
349
+ abortControllerRef.current = nextAbort;
350
+ setStage({ type: "thinking" });
351
+
352
+ callChat(currentProvider, currentSystemPrompt, withTool, nextAbort.signal)
353
+ .then((r: string) => {
354
+ if (nextAbort.signal.aborted) return;
355
+ if (!r.trim()) {
356
+ const nudged: Message[] = [
357
+ ...withTool,
358
+ { role: "user", content: "Please continue.", type: "text" },
359
+ ];
360
+ return callChat(
361
+ currentProvider,
362
+ currentSystemPrompt,
363
+ nudged,
364
+ nextAbort.signal,
365
+ );
366
+ }
367
+ return r;
368
+ })
369
+ .then((r: string | undefined) => {
370
+ if (nextAbort.signal.aborted) return;
371
+ processResponse(r ?? "", withTool, nextAbort.signal);
372
+ })
373
+ .catch(handleError(withTool));
374
+ };
375
+
376
+ if (forceApprove || (autoApprove && isSafe) || batchApprovedRef.current) {
377
+ executeAndContinue(true);
378
+ return;
379
+ }
380
+
381
+ const permLabel = tool.permissionLabel ?? tool.name;
382
+ const permValue = tool.summariseInput
383
+ ? String(tool.summariseInput(parsed.input))
384
+ : parsed.rawInput;
385
+
386
+ setStage({
387
+ type: "permission",
388
+ tool: {
389
+ type: parsed.toolName as any,
390
+ _display: permValue,
391
+ _label: permLabel,
392
+ } as any,
393
+ pendingMessages: currentAll,
394
+ resolve: executeAndContinue,
395
+ });
396
+ };
397
+
398
+ const sendMessage = (
399
+ text: string,
400
+ currentProvider: Provider,
401
+ currentSystemPrompt: string,
402
+ currentAllMessages: Message[],
403
+ ) => {
404
+ const userMsg: Message = { role: "user", content: text, type: "text" };
405
+ const nextAll = [...currentAllMessages, userMsg];
406
+ setCommitted((prev) => [...prev, userMsg]);
407
+ setAllMessages(nextAll);
408
+ batchApprovedRef.current = false;
409
+
410
+ if (!chatName) {
411
+ const name =
412
+ getChatNameSuggestions(nextAll)[0] ??
413
+ `chat-${new Date().toISOString().slice(0, 10)}`;
414
+ updateChatName(name);
415
+ setRecentChats((prev) =>
416
+ [name, ...prev.filter((n) => n !== name)].slice(0, 10),
417
+ );
418
+ saveChat(name, repoPath, nextAll);
419
+ }
420
+
421
+ const abort = new AbortController();
422
+ abortControllerRef.current = abort;
423
+
424
+ setStage({ type: "thinking" });
425
+ callChat(currentProvider, currentSystemPrompt, nextAll, abort.signal)
426
+ .then((raw: string) => processResponse(raw, nextAll, abort.signal))
427
+ .catch(handleError(nextAll));
428
+ };
429
+
430
+ const handleProviderDone = (p: Provider) => {
431
+ setProvider(p);
432
+ providerRef.current = p;
433
+ setStage({ type: "loading" });
434
+ fetchFileTree(repoPath)
435
+ .catch(() => walkDir(repoPath))
436
+ .then((fileTree) => {
437
+ const importantFiles = readImportantFiles(repoPath, fileTree);
438
+ const historySummary = buildMemorySummary(repoPath);
439
+ const lensFile = readLensFile(repoPath);
440
+ const lensContext = lensFile
441
+ ? `\n\n## LENS.md (previous analysis)\n${lensFile.overview}\n\nImportant folders: ${lensFile.importantFolders.join(", ")}\nSuggestions: ${lensFile.suggestions.slice(0, 3).join("; ")}`
442
+ : "";
443
+ const toolsSection = registry.buildSystemPromptSection();
444
+ const prompt =
445
+ buildSystemPrompt(importantFiles, historySummary, toolsSection) +
446
+ lensContext;
447
+ setSystemPrompt(prompt);
448
+ systemPromptRef.current = prompt;
449
+ const greeting: Message = {
450
+ role: "assistant",
451
+ 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.\nTip: ⭐ Star Lens on GitHub — github.com/ridit-jangra/Lens`,
452
+ type: "text",
453
+ };
454
+ setCommitted([greeting]);
455
+ setAllMessages([greeting]);
456
+ setStage({ type: "idle" });
457
+ })
458
+ .catch(() => setStage({ type: "idle" }));
459
+ };
460
+
461
+ const abortThinking = () => {
462
+ abortControllerRef.current?.abort();
463
+ abortControllerRef.current = null;
464
+ batchApprovedRef.current = false;
465
+ setStage({ type: "idle" });
466
+ };
467
+
468
+ const applyPatchesAndContinue = (patches: any[]) => {
469
+ try {
470
+ applyPatches(repoPath, patches);
471
+ appendMemory({
472
+ kind: "code-applied",
473
+ detail: patches.map((p) => p.path).join(", "),
474
+ summary: `Applied changes to ${patches.length} file(s)`,
475
+ });
476
+ } catch {
477
+ /* non-fatal */
478
+ }
479
+ };
480
+
481
+ const skipPatches = (patches: any[]) => {
482
+ appendMemory({
483
+ kind: "code-skipped",
484
+ detail: patches.map((p: { path: string }) => p.path).join(", "),
485
+ summary: `Skipped changes to ${patches.length} file(s)`,
486
+ });
487
+ };
488
+
489
+ return {
490
+ stage,
491
+ setStage,
492
+ committed,
493
+ setCommitted,
494
+ provider,
495
+ setProvider,
496
+ systemPrompt,
497
+ allMessages,
498
+ setAllMessages,
499
+ clonedUrls,
500
+ setClonedUrls,
501
+ showTimeline,
502
+ setShowTimeline,
503
+ showReview,
504
+ setShowReview,
505
+ autoApprove,
506
+ setAutoApprove,
507
+ forceApprove,
508
+ setForceApprove,
509
+ showForceWarning,
510
+ setShowForceWarning,
511
+ chatName,
512
+ setChatName,
513
+ recentChats,
514
+ setRecentChats,
515
+ pendingMsgIndex,
516
+ setPendingMsgIndex,
517
+
518
+ chatNameRef,
519
+ providerRef,
520
+ batchApprovedRef,
521
+
522
+ updateChatName,
523
+ sendMessage,
524
+ handleProviderDone,
525
+ abortThinking,
526
+ applyPatchesAndContinue,
527
+ skipPatches,
528
+ processResponse,
529
+ handleError,
530
+ };
531
+ }
@@ -0,0 +1,79 @@
1
+ import { useState, useRef } from "react";
2
+ import { useInput } from "ink";
3
+ import { COMMANDS } from "./useCommandHandlers";
4
+ import type { ChatStage } from "../../../types/chat";
5
+
6
+ export function useChatInput(
7
+ stage: ChatStage,
8
+ showTimeline: boolean,
9
+ showForceWarning: boolean,
10
+ onAbortThinking: () => void,
11
+ onStageKeyInput: (input: string, key: any) => void,
12
+ ) {
13
+ const [inputValue, setInputValue] = useState("");
14
+ const [inputKey, setInputKey] = useState(0);
15
+ const inputHistoryRef = useRef<string[]>([]);
16
+ const historyIndexRef = useRef<number>(-1);
17
+
18
+ const pushHistory = (text: string) => {
19
+ inputHistoryRef.current = [
20
+ text,
21
+ ...inputHistoryRef.current.filter((m) => m !== text),
22
+ ].slice(0, 50);
23
+ historyIndexRef.current = -1;
24
+ };
25
+
26
+ useInput((input, key) => {
27
+ if (showTimeline) return;
28
+
29
+ if (showForceWarning && key.escape) {
30
+ onStageKeyInput(input, key);
31
+ return;
32
+ }
33
+
34
+ if (stage.type === "thinking" && key.escape) {
35
+ onAbortThinking();
36
+ return;
37
+ }
38
+
39
+ if (stage.type === "idle") {
40
+ if (key.ctrl && input === "c") {
41
+ process.exit(0);
42
+ return;
43
+ }
44
+ if (key.upArrow && inputHistoryRef.current.length > 0) {
45
+ const next = Math.min(
46
+ historyIndexRef.current + 1,
47
+ inputHistoryRef.current.length - 1,
48
+ );
49
+ historyIndexRef.current = next;
50
+ setInputValue(inputHistoryRef.current[next]!);
51
+ setInputKey((k) => k + 1);
52
+ return;
53
+ }
54
+ if (key.downArrow) {
55
+ const next = historyIndexRef.current - 1;
56
+ historyIndexRef.current = next;
57
+ setInputValue(next < 0 ? "" : inputHistoryRef.current[next]!);
58
+ setInputKey((k) => k + 1);
59
+ return;
60
+ }
61
+ if (key.tab && inputValue.startsWith("/")) {
62
+ const q = inputValue.toLowerCase();
63
+ const match = COMMANDS.find((c) => c.cmd.startsWith(q));
64
+ if (match) setInputValue(match.cmd);
65
+ return;
66
+ }
67
+ return;
68
+ }
69
+
70
+ onStageKeyInput(input, key);
71
+ });
72
+
73
+ return {
74
+ inputValue,
75
+ setInputValue,
76
+ inputKey,
77
+ pushHistory,
78
+ };
79
+ }