@ridit/lens 0.3.7 → 0.3.8

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.
@@ -31,7 +31,16 @@ import {
31
31
  type ChatResult,
32
32
  } from "../../../utils/chat";
33
33
 
34
- export function useChat(repoPath: string) {
34
+ function hasUnclosedToolTag(text: string): boolean {
35
+ for (const tag of registry.names()) {
36
+ if (text.includes(`<${tag}>`) && !text.includes(`</${tag}>`)) return true;
37
+ }
38
+ const fences = text.match(/```/g);
39
+ if (fences && fences.length % 2 !== 0) return true;
40
+ return false;
41
+ }
42
+
43
+ export function useChat(repoPath: string, autoForce = false) {
35
44
  const [stage, setStage] = useState<ChatStage>({ type: "picking-provider" });
36
45
  const [committed, setCommitted] = useState<Message[]>([]);
37
46
  const [provider, setProvider] = useState<Provider | null>(null);
@@ -41,8 +50,8 @@ export function useChat(repoPath: string) {
41
50
  const [clonedUrls, setClonedUrls] = useState<Set<string>>(new Set());
42
51
  const [showTimeline, setShowTimeline] = useState(false);
43
52
  const [showReview, setShowReview] = useState(false);
44
- const [autoApprove, setAutoApprove] = useState(false);
45
- const [forceApprove, setForceApprove] = useState(false);
53
+ const [autoApprove, setAutoApprove] = useState(autoForce);
54
+ const [forceApprove, setForceApprove] = useState(autoForce);
46
55
  const [showForceWarning, setShowForceWarning] = useState(false);
47
56
  const [chatName, setChatName] = useState<string | null>(null);
48
57
  const [recentChats, setRecentChats] = useState<string[]>([]);
@@ -53,6 +62,7 @@ export function useChat(repoPath: string) {
53
62
  const abortControllerRef = useRef<AbortController | null>(null);
54
63
  const toolResultCache = useRef<Map<string, string>>(new Map());
55
64
  const batchApprovedRef = useRef(false);
65
+ const forceApproveRef = useRef(autoForce);
56
66
 
57
67
  const updateChatName = (name: string) => {
58
68
  chatNameRef.current = name;
@@ -65,6 +75,9 @@ export function useChat(repoPath: string) {
65
75
  React.useEffect(() => {
66
76
  systemPromptRef.current = systemPrompt;
67
77
  }, [systemPrompt]);
78
+ React.useEffect(() => {
79
+ forceApproveRef.current = forceApprove;
80
+ }, [forceApprove]);
68
81
 
69
82
  React.useEffect(() => {
70
83
  const chats = listChats(repoPath);
@@ -77,6 +90,13 @@ export function useChat(repoPath: string) {
77
90
  }
78
91
  }, [allMessages]);
79
92
 
93
+ const pushMsg = (msg: Message, currentAll: Message[]): Message[] => {
94
+ const next = [...currentAll, msg];
95
+ setAllMessages(next);
96
+ setCommitted((prev) => [...prev, msg]);
97
+ return next;
98
+ };
99
+
80
100
  const handleError = (currentAll: Message[]) => (err: unknown) => {
81
101
  batchApprovedRef.current = false;
82
102
  if (err instanceof Error && err.name === "AbortError") {
@@ -88,41 +108,132 @@ export function useChat(repoPath: string) {
88
108
  content: `Error: ${err instanceof Error ? err.message : "Something went wrong"}`,
89
109
  type: "text",
90
110
  };
91
- setAllMessages([...currentAll, errMsg]);
92
- setCommitted((prev) => [...prev, errMsg]);
111
+ pushMsg(errMsg, currentAll);
93
112
  setStage({ type: "idle" });
94
113
  };
95
114
 
96
- const MAX_AUTO_CONTINUES = 3;
115
+ const callNext = async (
116
+ messages: Message[],
117
+ signal: AbortSignal,
118
+ maxRetries = 3,
119
+ ): Promise<ChatResult> => {
120
+ const currentProvider = providerRef.current;
121
+ const currentSystemPrompt = systemPromptRef.current;
122
+
123
+ if (!currentProvider || signal.aborted)
124
+ return { text: "", truncated: false };
97
125
 
98
- function isLikelyTruncated(text: string): boolean {
99
- // Check unclosed XML tool tags (dynamic — includes addon tools)
100
- for (const tag of registry.names()) {
101
- if (text.includes(`<${tag}>`) && !text.includes(`</${tag}>`))
102
- return true;
126
+ let currentMessages = messages;
127
+
128
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
129
+ if (signal.aborted) return { text: "", truncated: false };
130
+
131
+ const result = await callChat(
132
+ currentProvider,
133
+ currentSystemPrompt,
134
+ currentMessages,
135
+ signal,
136
+ );
137
+
138
+ if (result.text.trim()) return result;
139
+
140
+ if (attempt < maxRetries) {
141
+ const nudge: Message = {
142
+ role: "assistant",
143
+ content: `(model stalled — retrying ${attempt + 1}/${maxRetries})`,
144
+ type: "text",
145
+ };
146
+ setCommitted((prev) => [...prev, nudge]);
147
+ currentMessages = [
148
+ ...currentMessages,
149
+ {
150
+ role: "user",
151
+ content: "Please continue your response.",
152
+ type: "text",
153
+ },
154
+ ];
155
+ }
103
156
  }
104
- // Check unclosed fenced code blocks (```tool\n... without closing ```)
105
- const fences = text.match(/```/g);
106
- if (fences && fences.length % 2 !== 0) return true;
107
- return false;
108
- }
109
157
 
110
- const processResponse = (
158
+ return { text: "", truncated: false };
159
+ };
160
+
161
+ const MAX_CONTINUATIONS = 3;
162
+
163
+ const handleTruncation = async (
164
+ raw: string,
165
+ currentAll: Message[],
166
+ signal: AbortSignal,
167
+ depth: number,
168
+ ): Promise<{ text: string; messages: Message[] } | null> => {
169
+ if (depth >= MAX_CONTINUATIONS) return null;
170
+
171
+ const truncNotice: Message = {
172
+ role: "assistant",
173
+ content: `(response cut off — continuing ${depth + 1}/${MAX_CONTINUATIONS}…)`,
174
+ type: "text",
175
+ };
176
+ setCommitted((prev) => [...prev, truncNotice]);
177
+
178
+ const partialMsg: Message = {
179
+ role: "assistant",
180
+ content: raw,
181
+ type: "text",
182
+ };
183
+ const nudgeMsg: Message = {
184
+ role: "user",
185
+ content:
186
+ "Your response was cut off. Please continue exactly from where you left off.",
187
+ type: "text",
188
+ };
189
+ const withContext = [...currentAll, partialMsg, nudgeMsg];
190
+
191
+ const result = await callNext(withContext, signal);
192
+ return { text: result.text ?? "", messages: withContext };
193
+ };
194
+
195
+ const processMemoryTags = (raw: string): string => {
196
+ const addMatches = [
197
+ ...raw.matchAll(/<memory-add>([\s\S]*?)<\/memory-add>/g),
198
+ ];
199
+ const delMatches = [
200
+ ...raw.matchAll(/<memory-delete>([\s\S]*?)<\/memory-delete>/g),
201
+ ];
202
+ for (const m of addMatches) {
203
+ const content = m[1]!.trim();
204
+ if (content) addMemory(content, repoPath);
205
+ }
206
+ for (const m of delMatches) {
207
+ const id = m[1]!.trim();
208
+ if (id) deleteMemory(id, repoPath);
209
+ }
210
+ return raw
211
+ .replace(/<memory-add>[\s\S]*?<\/memory-add>/g, "")
212
+ .replace(/<memory-delete>[\s\S]*?<\/memory-delete>/g, "")
213
+ .trim();
214
+ };
215
+
216
+ const processResponse = async (
111
217
  raw: string,
112
218
  currentAll: Message[],
113
219
  signal: AbortSignal,
114
220
  truncated = false,
115
- continueCount = 0,
116
- ) => {
221
+ continuationDepth = 0,
222
+ ): Promise<void> => {
117
223
  if (signal.aborted) {
118
224
  batchApprovedRef.current = false;
119
225
  setStage({ type: "idle" });
120
226
  return;
121
227
  }
122
228
 
123
- if (truncated || isLikelyTruncated(raw)) {
124
- if (continueCount >= MAX_AUTO_CONTINUES) {
125
- // Give up after max attempts — show whatever we have
229
+ if (truncated || hasUnclosedToolTag(raw)) {
230
+ const cont = await handleTruncation(
231
+ raw,
232
+ currentAll,
233
+ signal,
234
+ continuationDepth,
235
+ );
236
+ if (!cont) {
126
237
  batchApprovedRef.current = false;
127
238
  const msg: Message = {
128
239
  role: "assistant",
@@ -131,110 +242,116 @@ export function useChat(repoPath: string) {
131
242
  "(response was empty after multiple continuation attempts)",
132
243
  type: "text",
133
244
  };
134
- setAllMessages([...currentAll, msg]);
135
- setCommitted((prev) => [...prev, msg]);
245
+ pushMsg(msg, currentAll);
136
246
  setStage({ type: "idle" });
137
247
  return;
138
248
  }
249
+ return processResponse(
250
+ cont.text,
251
+ cont.messages,
252
+ signal,
253
+ false,
254
+ continuationDepth + 1,
255
+ );
256
+ }
139
257
 
140
- // Include the partial response so the model knows where it left off
141
- const partialMsg: Message = {
142
- role: "assistant",
143
- content: raw,
144
- type: "text",
145
- };
146
- const nudgeMsg: Message = {
147
- role: "user",
148
- content:
149
- "Your response was cut off. Please continue exactly from where you left off.",
150
- type: "text",
151
- };
152
- const withContext = [...currentAll, partialMsg, nudgeMsg];
258
+ const cleanRaw = processMemoryTags(raw);
153
259
 
154
- const truncMsg: Message = {
155
- role: "assistant",
156
- content: `(response cut off — auto-continuing ${continueCount + 1}/${MAX_AUTO_CONTINUES}…)`,
157
- type: "text",
158
- };
159
- setAllMessages([...currentAll, truncMsg]);
160
- setCommitted((prev) => [...prev, truncMsg]);
260
+ const parsed = parseResponse(cleanRaw);
161
261
 
162
- const currentProvider = providerRef.current;
163
- const currentSystemPrompt = systemPromptRef.current;
262
+ if (parsed.kind === "text") {
263
+ batchApprovedRef.current = false;
164
264
 
165
- if (!currentProvider) {
265
+ if (!parsed.content.trim()) {
266
+ const stallMsg: Message = {
267
+ role: "assistant",
268
+ content:
269
+ '(no response — try sending "continue" or start a new message)',
270
+ type: "text",
271
+ };
272
+ pushMsg(stallMsg, currentAll);
166
273
  setStage({ type: "idle" });
167
274
  return;
168
275
  }
169
276
 
170
- const nextAbort = new AbortController();
171
- abortControllerRef.current = nextAbort;
172
- setStage({ type: "thinking" });
173
- callChat(
174
- currentProvider,
175
- currentSystemPrompt,
176
- withContext,
177
- nextAbort.signal,
178
- )
179
- .then((result: ChatResult) => {
180
- if (nextAbort.signal.aborted) return;
181
- processResponse(
182
- result.text ?? "",
183
- withContext,
184
- nextAbort.signal,
185
- result.truncated,
186
- continueCount + 1,
187
- );
188
- })
189
- .catch(handleError(withContext));
190
- return;
191
- }
277
+ const msg: Message = {
278
+ role: "assistant",
279
+ content: parsed.content,
280
+ type: "text",
281
+ };
282
+ const withMsg = pushMsg(msg, currentAll);
192
283
 
193
- const memAddMatches = [
194
- ...raw.matchAll(/<memory-add>([\s\S]*?)<\/memory-add>/g),
195
- ];
196
- const memDelMatches = [
197
- ...raw.matchAll(/<memory-delete>([\s\S]*?)<\/memory-delete>/g),
198
- ];
199
- for (const match of memAddMatches) {
200
- const content = match[1]!.trim();
201
- if (content) addMemory(content, repoPath);
202
- }
203
- for (const match of memDelMatches) {
204
- const id = match[1]!.trim();
205
- if (id) deleteMemory(id, repoPath);
284
+ const lastUserMsg = [...currentAll]
285
+ .reverse()
286
+ .find((m) => m.role === "user");
287
+ const githubUrl = lastUserMsg
288
+ ? extractGithubUrl(lastUserMsg.content)
289
+ : null;
290
+ if (githubUrl && !clonedUrls.has(githubUrl)) {
291
+ setTimeout(
292
+ () => setStage({ type: "clone-offer", repoUrl: githubUrl }),
293
+ 80,
294
+ );
295
+ } else {
296
+ setStage({ type: "idle" });
297
+ }
298
+ return;
206
299
  }
207
- const cleanRaw = raw
208
- .replace(/<memory-add>[\s\S]*?<\/memory-add>/g, "")
209
- .replace(/<memory-delete>[\s\S]*?<\/memory-delete>/g, "")
210
- .trim();
211
-
212
- const parsed = parseResponse(cleanRaw);
213
300
 
214
301
  if (parsed.kind === "changes") {
215
302
  batchApprovedRef.current = false;
303
+
216
304
  if (parsed.patches.length === 0) {
217
305
  const msg: Message = {
218
306
  role: "assistant",
219
307
  content: parsed.content,
220
308
  type: "text",
221
309
  };
222
- setAllMessages([...currentAll, msg]);
223
- setCommitted((prev) => [...prev, msg]);
310
+ pushMsg(msg, currentAll);
224
311
  setStage({ type: "idle" });
225
312
  return;
226
313
  }
314
+
315
+ const diffLines = buildDiffs(repoPath, parsed.patches);
316
+
317
+ if (forceApproveRef.current) {
318
+ const assistantMsg: Message = {
319
+ role: "assistant",
320
+ content: parsed.content,
321
+ type: "plan",
322
+ patches: parsed.patches,
323
+ diffLines,
324
+ applied: true,
325
+ };
326
+ const withAssistant = [...currentAll, assistantMsg];
327
+ setAllMessages(withAssistant);
328
+ setCommitted((prev) => [...prev, assistantMsg]);
329
+ try {
330
+ applyPatches(repoPath, parsed.patches);
331
+ logToolCall(
332
+ "changes",
333
+ parsed.patches.map((p) => p.path).join(", "),
334
+ `Applied changes to ${parsed.patches.length} file(s)`,
335
+ repoPath,
336
+ );
337
+ } catch {}
338
+ continueAfterChanges(withAssistant, parsed.content || "code changes");
339
+ return;
340
+ }
341
+
227
342
  const assistantMsg: Message = {
228
343
  role: "assistant",
229
344
  content: parsed.content,
230
345
  type: "plan",
231
346
  patches: parsed.patches,
347
+ diffLines,
232
348
  applied: false,
233
349
  };
234
350
  const withAssistant = [...currentAll, assistantMsg];
235
351
  setAllMessages(withAssistant);
352
+ setCommitted((prev) => [...prev, assistantMsg]);
236
353
  setPendingMsgIndex(withAssistant.length - 1);
237
- const diffLines = buildDiffs(repoPath, parsed.patches);
354
+
238
355
  setStage({
239
356
  type: "preview",
240
357
  patches: parsed.patches,
@@ -253,8 +370,7 @@ export function useChat(repoPath: string) {
253
370
  content: parsed.content,
254
371
  type: "text",
255
372
  };
256
- setAllMessages([...currentAll, preambleMsg]);
257
- setCommitted((prev) => [...prev, preambleMsg]);
373
+ pushMsg(preambleMsg, currentAll);
258
374
  }
259
375
  setStage({
260
376
  type: "clone-offer",
@@ -264,215 +380,194 @@ export function useChat(repoPath: string) {
264
380
  return;
265
381
  }
266
382
 
267
- if (parsed.kind === "text") {
268
- batchApprovedRef.current = false;
269
-
270
- if (!parsed.content.trim()) {
271
- const stallMsg: Message = {
272
- role: "assistant",
273
- content:
274
- '(no response — the model may have stalled. Try sending a short follow-up like "continue" or start a new message.)',
275
- type: "text",
276
- };
277
- setAllMessages([...currentAll, stallMsg]);
278
- setCommitted((prev) => [...prev, stallMsg]);
383
+ if (parsed.kind === "tool") {
384
+ const tool = registry.get(parsed.toolName);
385
+ if (!tool) {
386
+ batchApprovedRef.current = false;
279
387
  setStage({ type: "idle" });
280
388
  return;
281
389
  }
282
390
 
283
- const msg: Message = {
284
- role: "assistant",
285
- content: parsed.content,
286
- type: "text",
287
- };
288
- const withMsg = [...currentAll, msg];
289
- setAllMessages(withMsg);
290
- setCommitted((prev) => [...prev, msg]);
291
- const lastUserMsg = [...currentAll]
292
- .reverse()
293
- .find((m) => m.role === "user");
294
- const githubUrl = lastUserMsg
295
- ? extractGithubUrl(lastUserMsg.content)
296
- : null;
297
- if (githubUrl && !clonedUrls.has(githubUrl)) {
298
- setTimeout(
299
- () => setStage({ type: "clone-offer", repoUrl: githubUrl }),
300
- 80,
301
- );
302
- } else {
303
- setStage({ type: "idle" });
391
+ if (parsed.content) {
392
+ const preambleMsg: Message = {
393
+ role: "assistant",
394
+ content: parsed.content,
395
+ type: "text",
396
+ };
397
+ pushMsg(preambleMsg, currentAll);
304
398
  }
305
- return;
306
- }
307
-
308
- const tool = registry.get(parsed.toolName);
309
- if (!tool) {
310
- batchApprovedRef.current = false;
311
- setStage({ type: "idle" });
312
- return;
313
- }
314
399
 
315
- if (parsed.content) {
316
- const preambleMsg: Message = {
317
- role: "assistant",
318
- content: parsed.content,
319
- type: "text",
320
- };
321
- setAllMessages([...currentAll, preambleMsg]);
322
- setCommitted((prev) => [...prev, preambleMsg]);
323
- }
400
+ const isSafe = tool.safe ?? false;
401
+ const remainder = parsed.remainder;
324
402
 
325
- const remainder = parsed.remainder;
326
- const isSafe = tool.safe ?? false;
403
+ const executeAndContinue = async (approved: boolean): Promise<void> => {
404
+ if (approved && remainder) batchApprovedRef.current = true;
327
405
 
328
- const executeAndContinue = async (approved: boolean) => {
329
- if (approved && remainder) {
330
- batchApprovedRef.current = true;
331
- }
406
+ const currentProvider = providerRef.current;
407
+ const currentSystemPrompt = systemPromptRef.current;
332
408
 
333
- const currentProvider = providerRef.current;
334
- const currentSystemPrompt = systemPromptRef.current;
335
-
336
- if (!currentProvider) {
337
- batchApprovedRef.current = false;
338
- setStage({ type: "idle" });
339
- return;
340
- }
409
+ if (!currentProvider) {
410
+ batchApprovedRef.current = false;
411
+ setStage({ type: "idle" });
412
+ return;
413
+ }
341
414
 
342
- let result = "(denied by user)";
343
-
344
- if (approved) {
345
- const cacheKey = isSafe
346
- ? `${parsed.toolName}:${parsed.rawInput}`
347
- : null;
348
- if (cacheKey && toolResultCache.current.has(cacheKey)) {
349
- result =
350
- toolResultCache.current.get(cacheKey)! +
351
- "\n\n[NOTE: This result was already retrieved earlier. Do not request it again.]";
352
- } else {
353
- try {
354
- setStage({ type: "thinking" });
355
- const toolResult = await tool.execute(parsed.input, {
356
- repoPath,
357
- messages: currentAll,
358
- });
359
- result = toolResult.value;
360
- if (cacheKey && toolResult.kind === "text") {
361
- toolResultCache.current.set(cacheKey, result);
415
+ let result = "(denied by user)";
416
+
417
+ if (approved) {
418
+ const cacheKey = isSafe
419
+ ? `${parsed.toolName}:${parsed.rawInput}`
420
+ : null;
421
+ if (cacheKey && toolResultCache.current.has(cacheKey)) {
422
+ result =
423
+ toolResultCache.current.get(cacheKey)! +
424
+ "\n\n[NOTE: This result was already retrieved earlier. Do not request it again.]";
425
+ } else {
426
+ try {
427
+ setStage({ type: "thinking" });
428
+ const toolResult = await tool.execute(parsed.input, {
429
+ repoPath,
430
+ messages: currentAll,
431
+ });
432
+ result = toolResult.value;
433
+ if (cacheKey && toolResult.kind === "text") {
434
+ toolResultCache.current.set(cacheKey, result);
435
+ }
436
+ } catch (err: unknown) {
437
+ result = `Error: ${err instanceof Error ? err.message : "failed"}`;
362
438
  }
363
- } catch (err: unknown) {
364
- result = `Error: ${err instanceof Error ? err.message : "failed"}`;
365
439
  }
366
440
  }
367
- }
368
441
 
369
- if (approved && !result.startsWith("Error:")) {
370
- logToolCall(
371
- parsed.toolName,
372
- tool.summariseInput
373
- ? String(tool.summariseInput(parsed.input))
374
- : parsed.rawInput,
442
+ if (approved && !result.startsWith("Error:")) {
443
+ logToolCall(
444
+ parsed.toolName,
445
+ tool.summariseInput
446
+ ? String(tool.summariseInput(parsed.input))
447
+ : parsed.rawInput,
448
+ result,
449
+ repoPath,
450
+ );
451
+ }
452
+
453
+ const displayContent = tool.summariseInput
454
+ ? String(tool.summariseInput(parsed.input))
455
+ : parsed.rawInput;
456
+
457
+ const toolMsg: Message = {
458
+ role: "assistant",
459
+ type: "tool",
460
+ toolName: parsed.toolName as any,
461
+ content: displayContent,
375
462
  result,
376
- repoPath,
377
- );
378
- }
463
+ approved,
464
+ };
379
465
 
380
- const displayContent = tool.summariseInput
381
- ? String(tool.summariseInput(parsed.input))
382
- : parsed.rawInput;
466
+ const withTool = pushMsg(toolMsg, currentAll);
383
467
 
384
- const toolMsg: Message = {
385
- role: "assistant",
386
- type: "tool",
387
- toolName: parsed.toolName as any,
388
- content: displayContent,
389
- result,
390
- approved,
391
- };
468
+ if (approved && remainder && remainder.length > 0) {
469
+ return processResponse(
470
+ remainder,
471
+ withTool,
472
+ signal,
473
+ false,
474
+ continuationDepth,
475
+ );
476
+ }
392
477
 
393
- const withTool = [...currentAll, toolMsg];
394
- setAllMessages(withTool);
395
- setCommitted((prev) => [...prev, toolMsg]);
478
+ batchApprovedRef.current = false;
396
479
 
397
- if (approved && remainder && remainder.length > 0) {
398
- processResponse(remainder, withTool, signal, truncated, continueCount);
399
- return;
400
- }
480
+ const nextAbort = new AbortController();
481
+ abortControllerRef.current = nextAbort;
482
+ setStage({ type: "thinking" });
401
483
 
402
- batchApprovedRef.current = false;
484
+ try {
485
+ const nextResult = await callNext(withTool, nextAbort.signal);
403
486
 
404
- const nextAbort = new AbortController();
405
- abortControllerRef.current = nextAbort;
406
- setStage({ type: "thinking" });
407
-
408
- const callWithAutoContinue = async (
409
- messages: Message[],
410
- maxRetries = 3,
411
- ): Promise<ChatResult> => {
412
- let currentMessages = messages;
413
- for (let i = 0; i < maxRetries; i++) {
414
- if (nextAbort.signal.aborted)
415
- return { text: "", truncated: false };
416
- const result = await callChat(
417
- currentProvider,
418
- currentSystemPrompt,
419
- currentMessages,
420
- nextAbort.signal,
421
- );
422
- if (result.text.trim()) return result;
423
- const nudgeMsg: Message = {
424
- role: "assistant",
425
- content: `(model stalled — auto-continuing, attempt ${i + 1}/${maxRetries})`,
426
- type: "text",
427
- };
428
- setCommitted((prev) => [...prev, nudgeMsg]);
429
- setAllMessages((prev) => [...prev, nudgeMsg]);
430
- currentMessages = [
431
- ...currentMessages,
432
- {
433
- role: "user",
434
- content:
435
- "Please continue. Provide your response to the previous tool output.",
487
+ if (nextAbort.signal.aborted) return;
488
+
489
+ if (!nextResult.text.trim()) {
490
+ const stallMsg: Message = {
491
+ role: "assistant",
492
+ content: '(model stopped responding — try sending "continue")',
436
493
  type: "text",
437
- },
438
- ];
439
- }
440
- return { text: "", truncated: false };
441
- };
494
+ };
495
+ pushMsg(stallMsg, withTool);
496
+ setStage({ type: "idle" });
497
+ return;
498
+ }
442
499
 
443
- callWithAutoContinue(withTool)
444
- .then((result: ChatResult) => {
445
- if (nextAbort.signal.aborted) return;
446
- processResponse(
447
- result.text ?? "",
500
+ return processResponse(
501
+ nextResult.text,
448
502
  withTool,
449
503
  nextAbort.signal,
450
- result.truncated,
504
+ nextResult.truncated,
451
505
  );
452
- })
453
- .catch(handleError(withTool));
454
- };
506
+ } catch (err) {
507
+ handleError(withTool)(err);
508
+ }
509
+ };
510
+
511
+ if (forceApprove || isSafe || batchApprovedRef.current) {
512
+ return executeAndContinue(true);
513
+ }
514
+
515
+ const permLabel = tool.permissionLabel ?? tool.name;
516
+ const permValue = tool.summariseInput
517
+ ? String(tool.summariseInput(parsed.input))
518
+ : parsed.rawInput;
455
519
 
456
- if (forceApprove || isSafe || batchApprovedRef.current) {
457
- executeAndContinue(true);
520
+ setStage({
521
+ type: "permission",
522
+ tool: {
523
+ type: parsed.toolName as any,
524
+ _display: permValue,
525
+ _label: permLabel,
526
+ } as any,
527
+ pendingMessages: currentAll,
528
+ resolve: executeAndContinue,
529
+ });
530
+ }
531
+ };
532
+
533
+ const continueAfterChanges = (currentAll: Message[], summary: string) => {
534
+ if (!providerRef.current) {
535
+ setStage({ type: "idle" });
458
536
  return;
459
537
  }
460
538
 
461
- const permLabel = tool.permissionLabel ?? tool.name;
462
- const permValue = tool.summariseInput
463
- ? String(tool.summariseInput(parsed.input))
464
- : parsed.rawInput;
465
-
466
- setStage({
467
- type: "permission",
468
- tool: {
469
- type: parsed.toolName as any,
470
- _display: permValue,
471
- _label: permLabel,
472
- } as any,
473
- pendingMessages: currentAll,
474
- resolve: executeAndContinue,
475
- });
539
+ const resultMsg: Message = {
540
+ role: "assistant",
541
+ type: "tool",
542
+ toolName: "changes",
543
+ content: summary,
544
+ result: "Changes applied successfully.",
545
+ approved: true,
546
+ };
547
+
548
+ const withResult = [...currentAll, resultMsg];
549
+ setAllMessages(withResult);
550
+ setCommitted((prev) => [...prev, resultMsg]);
551
+
552
+ const abort = new AbortController();
553
+ abortControllerRef.current = abort;
554
+ setStage({ type: "thinking" });
555
+
556
+ callNext(withResult, abort.signal)
557
+ .then((result) => {
558
+ if (abort.signal.aborted) return;
559
+ if (!result.text.trim()) {
560
+ setStage({ type: "idle" });
561
+ return;
562
+ }
563
+ return processResponse(
564
+ result.text,
565
+ withResult,
566
+ abort.signal,
567
+ result.truncated,
568
+ );
569
+ })
570
+ .catch(handleError(withResult));
476
571
  };
477
572
 
478
573
  const sendMessage = (
@@ -509,7 +604,6 @@ export function useChat(repoPath: string) {
509
604
  /## TOOLS[\s\S]*?(?=\n## (?!TOOLS))/,
510
605
  scopedToolsSection + "\n\n",
511
606
  );
512
-
513
607
  if (sessionSummary) {
514
608
  scopedSystemPrompt = scopedSystemPrompt.replace(
515
609
  /## CODEBASE/,
@@ -518,6 +612,7 @@ export function useChat(repoPath: string) {
518
612
  }
519
613
 
520
614
  setStage({ type: "thinking" });
615
+
521
616
  callChat(currentProvider, scopedSystemPrompt, nextAll, abort.signal)
522
617
  .then((result: ChatResult) =>
523
618
  processResponse(result.text, nextAll, abort.signal, result.truncated),
@@ -529,6 +624,7 @@ export function useChat(repoPath: string) {
529
624
  setProvider(p);
530
625
  providerRef.current = p;
531
626
  setStage({ type: "loading" });
627
+
532
628
  fetchFileTree(repoPath)
533
629
  .catch(() => walkDir(repoPath))
534
630
  .then((fileTree) => {
@@ -542,13 +638,16 @@ export function useChat(repoPath: string) {
542
638
  const prompt =
543
639
  buildSystemPrompt(importantFiles, historySummary, toolsSection) +
544
640
  lensContext;
641
+
545
642
  setSystemPrompt(prompt);
546
643
  systemPromptRef.current = prompt;
644
+
547
645
  const greeting: Message = {
548
646
  role: "assistant",
549
647
  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`,
550
648
  type: "text",
551
649
  };
650
+
552
651
  setCommitted([greeting]);
553
652
  setAllMessages([greeting]);
554
653
  setStage({ type: "idle" });
@@ -624,6 +723,7 @@ export function useChat(repoPath: string) {
624
723
  handleProviderDone,
625
724
  abortThinking,
626
725
  applyPatchesAndContinue,
726
+ continueAfterChanges,
627
727
  skipPatches,
628
728
  processResponse,
629
729
  handleError,