@ridit/lens 0.3.7 → 0.3.9

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.
Files changed (96) hide show
  1. package/dist/index.mjs +105368 -274002
  2. package/package.json +13 -19
  3. package/src/colors.ts +15 -15
  4. package/src/commands/chat.tsx +32 -23
  5. package/src/commands/provider.tsx +11 -238
  6. package/src/commands/repo.tsx +66 -120
  7. package/src/commands/timeline.tsx +11 -22
  8. package/src/components/ChatView.tsx +238 -0
  9. package/src/components/Message.tsx +46 -0
  10. package/src/components/ToolCall.tsx +67 -0
  11. package/src/components/chat/ChatView.tsx +550 -0
  12. package/src/components/chat/Message.tsx +152 -0
  13. package/src/components/chat/StatusBar.tsx +214 -0
  14. package/src/components/chat/TextArea.tsx +173 -176
  15. package/src/components/provider/ApiKeyStep.tsx +207 -199
  16. package/src/components/provider/ModelStep.tsx +90 -88
  17. package/src/components/provider/ProviderSetup.tsx +331 -0
  18. package/src/components/provider/ProviderTypeStep.tsx +53 -61
  19. package/src/components/repo/StepRow.tsx +68 -69
  20. package/src/components/timeline/TimelineView.tsx +840 -0
  21. package/src/components/toolcall-utils.ts +103 -0
  22. package/src/components/watch/RunView.tsx +497 -0
  23. package/src/hooks/useChatInput.ts +49 -0
  24. package/src/hooks/useCommandHandler.ts +117 -0
  25. package/src/index.tsx +386 -139
  26. package/src/utils/git.ts +149 -155
  27. package/src/utils/repo.ts +62 -69
  28. package/src/utils/thinking.tsx +64 -0
  29. package/src/utils/watch.ts +165 -307
  30. package/tests/message.test.ts +38 -0
  31. package/tests/toolcall-utils.test.ts +111 -0
  32. package/tsconfig.json +8 -24
  33. package/CLAUDE.md +0 -50
  34. package/LENS.md +0 -48
  35. package/LICENSE +0 -21
  36. package/README.md +0 -93
  37. package/addons/README.md +0 -55
  38. package/addons/clean-cache.js +0 -48
  39. package/addons/generate-readme.js +0 -67
  40. package/addons/git-stats.js +0 -29
  41. package/addons/run-tests.js +0 -127
  42. package/src/commands/commit.tsx +0 -668
  43. package/src/commands/review.tsx +0 -294
  44. package/src/commands/run.tsx +0 -56
  45. package/src/commands/task.tsx +0 -36
  46. package/src/components/chat/ChatMessage.tsx +0 -195
  47. package/src/components/chat/ChatOverlays.tsx +0 -399
  48. package/src/components/chat/ChatRunner.tsx +0 -517
  49. package/src/components/chat/hooks/useChat.ts +0 -631
  50. package/src/components/chat/hooks/useChatInput.ts +0 -79
  51. package/src/components/chat/hooks/useCommandHandlers.ts +0 -327
  52. package/src/components/provider/ProviderPicker.tsx +0 -76
  53. package/src/components/provider/RemoveProviderStep.tsx +0 -82
  54. package/src/components/repo/DiffViewer.tsx +0 -175
  55. package/src/components/repo/FileReviewer.tsx +0 -70
  56. package/src/components/repo/FileViewer.tsx +0 -60
  57. package/src/components/repo/IssueFixer.tsx +0 -666
  58. package/src/components/repo/LensFileMenu.tsx +0 -115
  59. package/src/components/repo/NoProviderPrompt.tsx +0 -28
  60. package/src/components/repo/PreviewRunner.tsx +0 -217
  61. package/src/components/repo/RepoAnalysis.tsx +0 -534
  62. package/src/components/task/TaskRunner.tsx +0 -396
  63. package/src/components/timeline/CommitDetail.tsx +0 -272
  64. package/src/components/timeline/CommitList.tsx +0 -162
  65. package/src/components/timeline/TimelineChat.tsx +0 -166
  66. package/src/components/timeline/TimelineRunner.tsx +0 -1285
  67. package/src/components/watch/RunRunner.tsx +0 -929
  68. package/src/prompts/fewshot.ts +0 -252
  69. package/src/prompts/index.ts +0 -2
  70. package/src/prompts/system.ts +0 -285
  71. package/src/tools/chart.ts +0 -202
  72. package/src/tools/convert-image.ts +0 -312
  73. package/src/tools/files.ts +0 -253
  74. package/src/tools/git.ts +0 -603
  75. package/src/tools/index.ts +0 -17
  76. package/src/tools/pdf.ts +0 -164
  77. package/src/tools/shell.ts +0 -96
  78. package/src/tools/view-image.ts +0 -335
  79. package/src/tools/web.ts +0 -212
  80. package/src/types/chat.ts +0 -86
  81. package/src/types/config.ts +0 -20
  82. package/src/types/repo.ts +0 -54
  83. package/src/utils/addons/loadAddons.ts +0 -34
  84. package/src/utils/ai.ts +0 -321
  85. package/src/utils/chat.ts +0 -326
  86. package/src/utils/chatHistory.ts +0 -121
  87. package/src/utils/config.ts +0 -61
  88. package/src/utils/files.ts +0 -105
  89. package/src/utils/intentClassifier.ts +0 -58
  90. package/src/utils/lensfile.ts +0 -142
  91. package/src/utils/llm.ts +0 -81
  92. package/src/utils/memory.ts +0 -209
  93. package/src/utils/preview.ts +0 -119
  94. package/src/utils/stats.ts +0 -174
  95. package/src/utils/tools/builtins.ts +0 -377
  96. package/src/utils/tools/registry.ts +0 -105
@@ -1,929 +0,0 @@
1
- import React, { useState, useEffect, useRef } from "react";
2
- import { Box, Text, useInput, useStdout } from "ink";
3
- import Spinner from "ink-spinner";
4
- import figures from "figures";
5
- import { nanoid } from "nanoid";
6
- import { spawnWatch, readPackageJson } from "../../utils/watch";
7
- import { callChat, parseResponse } from "../../utils/chat";
8
- import { registry } from "../../utils/tools/registry";
9
- import { applyPatches } from "../../tools/files";
10
- import { buildSystemPrompt } from "../../prompts";
11
- import { ProviderPicker } from "../provider/ProviderPicker";
12
- import { fetchFileTree, readImportantFiles } from "../../utils/files";
13
- import { lensFileExists, readLensFile } from "../../utils/lensfile";
14
- import type { ErrorChunk, Suggestion, WatchProcess } from "../../utils/watch";
15
- import type { Provider } from "../../types/config";
16
- import type { Message } from "../../types/chat";
17
- import { ACCENT, GREEN, RED, CYAN, TEXT } from "../../colors";
18
-
19
- const MAX_LOGS = 120;
20
- const MAX_SUGGESTIONS = 8;
21
-
22
- type WatchStage =
23
- | { type: "picking-provider" }
24
- | { type: "running" }
25
- | { type: "crashed"; exitCode: number | null; patchedCount: number };
26
-
27
- type PendingError = {
28
- id: string;
29
- chunk: ErrorChunk;
30
- };
31
-
32
- interface Props {
33
- cmd: string;
34
- repoPath: string;
35
- clean: boolean;
36
- fixAll: boolean;
37
- autoRestart: boolean;
38
- extraPrompt?: string;
39
- }
40
-
41
- function stripAnsi(str: string): string {
42
- return str.replace(/\x1b\[[0-9;]*m/g, "");
43
- }
44
-
45
- function buildWatchSystemPrompt(
46
- repoPath: string,
47
- deps: string,
48
- importantFiles: { path: string; content: string }[],
49
- lensContext: string,
50
- extraPrompt: string,
51
- ): string {
52
- const base = buildSystemPrompt(importantFiles, "", undefined);
53
-
54
- const sections: string[] = [base];
55
-
56
- if (lensContext) {
57
- sections.push(`## PROJECT CONTEXT (from LENS.md)\n\n${lensContext}`);
58
- }
59
-
60
- if (extraPrompt) {
61
- sections.push(
62
- `## ADDITIONAL CONTEXT (HIGHEST PRIORITY — override your assumptions with this)\n\n${extraPrompt}\n\nWhen providing patches, you MUST follow the above context. Do not guess intent — use exactly what is described above.`,
63
- );
64
- }
65
-
66
- sections.push(`## WATCH MODE
67
-
68
- You are monitoring a running dev process at: ${repoPath}
69
- ${deps ? `Project dependencies: ${deps}` : ""}
70
-
71
- When an error occurs you will be given the error output. You should:
72
- 1. Use your tools to investigate — read the erroring file, grep for related patterns, check imports
73
- 2. Explain the error in plain language (2-3 sentences max)
74
- 3. Give a specific fix referencing actual file names and line numbers
75
-
76
- After investigating, respond ONLY with this exact JSON (no markdown, no backticks):
77
- {
78
- "errorSummary": "one line — what went wrong",
79
- "simplified": "2-3 sentences plain language explanation",
80
- "fix": "specific actionable fix with file names and line numbers",
81
- "patch": null
82
- }
83
-
84
- If confident in a code fix, replace patch with:
85
- { "path": "relative/path.ts", "content": "complete corrected file content", "isNew": false }
86
-
87
- CRITICAL patch rules:
88
- - You MUST read the file with read-file BEFORE providing a patch
89
- - The patch content must be the COMPLETE file with ONLY the broken lines changed
90
- - Do NOT simplify, rewrite, or remove any existing code
91
- - Do NOT invent new content — preserve every function, comment, and line exactly as-is except the fix
92
- - If you haven't read the file yet, use read-file first, then respond with the JSON`);
93
-
94
- return sections.join("\n\n");
95
- }
96
-
97
- function buildErrorPrompt(chunk: ErrorChunk): string {
98
- return `Error detected in dev process:
99
-
100
- \`\`\`
101
- ${chunk.lines.join("\n").slice(0, 2000)}
102
- \`\`\`
103
-
104
- ${chunk.contextBefore.length > 0 ? `Log context before error:\n\`\`\`\n${chunk.contextBefore.join("\n")}\n\`\`\`` : ""}
105
- ${chunk.filePath ? `Error file: ${chunk.filePath}${chunk.lineNumber ? `:${chunk.lineNumber}` : ""}` : ""}
106
-
107
- Use read-file to read the full file content first, then respond with the JSON. Do not provide a patch without reading the file first.`;
108
- }
109
-
110
- function SuggestionCard({
111
- suggestion,
112
- isNew,
113
- fixAll,
114
- repoPath,
115
- }: {
116
- suggestion: Suggestion;
117
- isNew: boolean;
118
- fixAll: boolean;
119
- repoPath: string;
120
- }) {
121
- const w = process.stdout.columns ?? 80;
122
- const divider = "─".repeat(Math.min(w - 4, 60));
123
-
124
- const [patchState, setPatchState] = useState<
125
- null | "applied" | "skipped" | "error"
126
- >(fixAll && suggestion.patch ? "applied" : null);
127
-
128
- useInput((input) => {
129
- if (!isNew || !suggestion.patch || patchState !== null || fixAll) return;
130
- if (input === "y" || input === "Y") {
131
- try {
132
- applyPatches(repoPath, [suggestion.patch!]);
133
- setPatchState("applied");
134
- } catch {
135
- setPatchState("error");
136
- }
137
- } else if (input === "n" || input === "N") {
138
- setPatchState("skipped");
139
- }
140
- });
141
-
142
- return (
143
- <Box flexDirection="column" marginBottom={1}>
144
- <Text color="gray">{divider}</Text>
145
- <Box gap={1}>
146
- <Text color={RED}>✖</Text>
147
- <Text color="white" bold>
148
- {suggestion.errorSummary}
149
- </Text>
150
- {isNew && (
151
- <Text color={ACCENT} bold>
152
- [new]
153
- </Text>
154
- )}
155
- </Box>
156
- <Box marginLeft={2}>
157
- <Text color="gray">{suggestion.simplified}</Text>
158
- </Box>
159
- <Box marginLeft={2} marginTop={1} flexDirection="column">
160
- <Text color={CYAN} bold>
161
- fix →
162
- </Text>
163
- <Box marginLeft={2}>
164
- <Text color={TEXT}>{suggestion.fix}</Text>
165
- </Box>
166
- </Box>
167
- {suggestion.patch && (
168
- <Box marginLeft={2} marginTop={1} flexDirection="column" gap={1}>
169
- {patchState === "applied" && (
170
- <Box gap={1}>
171
- <Text color={ACCENT}>✔</Text>
172
- <Text color={GREEN}>
173
- patch applied →{" "}
174
- <Text color="white">{suggestion.patch.path}</Text>
175
- </Text>
176
- </Box>
177
- )}
178
- {patchState === "skipped" && (
179
- <Box gap={1}>
180
- <Text color="gray" dimColor>
181
-
182
- </Text>
183
- <Text color="gray" dimColor>
184
- patch skipped
185
- </Text>
186
- </Box>
187
- )}
188
- {patchState === "error" && (
189
- <Box gap={1}>
190
- <Text color={RED}>✗</Text>
191
- <Text color={RED}>failed to apply patch</Text>
192
- </Box>
193
- )}
194
- {patchState === null && !fixAll && (
195
- <Box gap={1}>
196
- <Text color="gray" dimColor>
197
- {figures.pointer}
198
- </Text>
199
- <Text color="gray" dimColor>
200
- {suggestion.patch.path}
201
- </Text>
202
- <Text color="gray" dimColor>
203
- ·
204
- </Text>
205
- <Text color={ACCENT} bold>
206
- y
207
- </Text>
208
- <Text color="white">apply patch</Text>
209
- <Text color="gray" dimColor>
210
- ·
211
- </Text>
212
- <Text color="gray" bold>
213
- n
214
- </Text>
215
- <Text color="gray">skip</Text>
216
- </Box>
217
- )}
218
- </Box>
219
- )}
220
- {suggestion.filePath && (
221
- <Box marginLeft={2} gap={1}>
222
- <Text color="gray" dimColor>
223
- {figures.pointer}
224
- </Text>
225
- <Text color="gray" dimColor>
226
- {suggestion.filePath}
227
- </Text>
228
- </Box>
229
- )}
230
- </Box>
231
- );
232
- }
233
-
234
- const INVESTIGATION_TIMEOUT_MS = 60_000;
235
-
236
- function ThinkingCard({
237
- chunk,
238
- toolLog,
239
- startTime,
240
- }: {
241
- chunk: ErrorChunk;
242
- toolLog: string[];
243
- startTime: number;
244
- }) {
245
- const [elapsed, setElapsed] = useState(0);
246
-
247
- useEffect(() => {
248
- const t = setInterval(
249
- () => setElapsed(Math.floor((Date.now() - startTime) / 1000)),
250
- 1000,
251
- );
252
- return () => clearInterval(t);
253
- }, [startTime]);
254
-
255
- return (
256
- <Box flexDirection="column" marginBottom={1}>
257
- <Box gap={1}>
258
- <Text color={ACCENT}>
259
- <Spinner />
260
- </Text>
261
- <Text color="gray" dimColor>
262
- {chunk.lines[0]?.slice(0, 50) ?? ""}
263
- </Text>
264
- <Text color="gray" dimColor>
265
- {elapsed}s
266
- </Text>
267
- <Text color="gray">investigating...</Text>
268
- </Box>
269
- {toolLog.slice(-3).map((t, i) => (
270
- <Box key={i} marginLeft={2} gap={1}>
271
- <Text color="gray" dimColor>
272
- $
273
- </Text>
274
- <Text color="gray" dimColor>
275
- {t}
276
- </Text>
277
- </Box>
278
- ))}
279
- </Box>
280
- );
281
- }
282
-
283
- function ConfirmCard({ pending }: { pending: PendingError }) {
284
- const w = process.stdout.columns ?? 80;
285
- const divider = "─".repeat(Math.min(w - 4, 60));
286
- const preview = pending.chunk.lines[0]?.slice(0, 60) ?? "error detected";
287
-
288
- return (
289
- <Box flexDirection="column" marginBottom={1}>
290
- <Text color="gray">{divider}</Text>
291
- <Box gap={1}>
292
- <Text color={RED}>✖</Text>
293
- <Text color="white">{preview}</Text>
294
- </Box>
295
- {pending.chunk.filePath && (
296
- <Box marginLeft={2} gap={1}>
297
- <Text color="gray" dimColor>
298
- {figures.pointer}
299
- </Text>
300
- <Text color="gray" dimColor>
301
- {pending.chunk.filePath}
302
- {pending.chunk.lineNumber ? `:${pending.chunk.lineNumber}` : ""}
303
- </Text>
304
- </Box>
305
- )}
306
- <Box marginLeft={2} marginTop={1} gap={1}>
307
- <Text color={ACCENT} bold>
308
- y
309
- </Text>
310
- <Text color="white">investigate</Text>
311
- <Text color="gray" dimColor>
312
- ·
313
- </Text>
314
- <Text color="gray" bold>
315
- n
316
- </Text>
317
- <Text color="gray">skip</Text>
318
- </Box>
319
- </Box>
320
- );
321
- }
322
-
323
- function InputCard({ prompt, value }: { prompt: string; value: string }) {
324
- const w = process.stdout.columns ?? 80;
325
- const divider = "─".repeat(Math.min(w - 4, 60));
326
-
327
- return (
328
- <Box flexDirection="column" marginBottom={1}>
329
- <Text color="gray">{divider}</Text>
330
- <Box gap={1}>
331
- <Text color={CYAN} bold>
332
-
333
- </Text>
334
- <Text color="white">{prompt}</Text>
335
- </Box>
336
- <Box marginLeft={2} marginTop={1} gap={1}>
337
- <Text color={ACCENT}>&gt;</Text>
338
- <Text color="white">{value}</Text>
339
- <Text color={ACCENT}>▋</Text>
340
- </Box>
341
- <Box marginLeft={2}>
342
- <Text color="gray" dimColor>
343
- enter to confirm
344
- </Text>
345
- </Box>
346
- </Box>
347
- );
348
- }
349
-
350
- type ActiveInvestigation = {
351
- id: string;
352
- chunk: ErrorChunk;
353
- toolLog: string[];
354
- startTime: number;
355
- };
356
-
357
- export function RunRunner({
358
- cmd,
359
- repoPath,
360
- clean,
361
- fixAll,
362
- autoRestart,
363
- extraPrompt,
364
- }: Props) {
365
- const [stage, setStage] = useState<WatchStage>({ type: "picking-provider" });
366
- const [logs, setLogs] = useState<{ text: string; isErr: boolean }[]>([]);
367
- const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
368
- const [active, setActive] = useState<ActiveInvestigation[]>([]);
369
- const [lensLoaded, setLensLoaded] = useState(false);
370
-
371
- const [pendingQueue, setPendingQueue] = useState<PendingError[]>([]);
372
- const [fixedCount, setFixedCount] = useState(0);
373
- const [inputRequest, setInputRequest] = useState<string | null>(null);
374
- const [inputValue, setInputValue] = useState("");
375
- const processRef = useRef<WatchProcess | null>(null);
376
- const providerRef = useRef<Provider | null>(null);
377
- const systemPromptRef = useRef<string>("");
378
- const activeCountRef = useRef(0);
379
- const pendingExitCode = useRef<number | null | undefined>(undefined);
380
- const abortControllersRef = useRef<Map<string, AbortController>>(new Map());
381
- const patchedThisRunRef = useRef(0);
382
- const { stdout } = useStdout();
383
-
384
- const currentPending = pendingQueue[0] ?? null;
385
-
386
- const handleRestart = () => {
387
- pendingExitCode.current = undefined;
388
- activeCountRef.current = 0;
389
- abortControllersRef.current.forEach((a) => a.abort());
390
- abortControllersRef.current.clear();
391
- processRef.current?.kill();
392
-
393
- setActive([]);
394
- setSuggestions([]);
395
- setLogs([]);
396
- setPendingQueue([]);
397
- setStage({ type: "running" });
398
- startWatching();
399
- };
400
-
401
- useInput((input, key) => {
402
- if (key.ctrl && input === "c") {
403
- processRef.current?.kill();
404
- process.exit(0);
405
- }
406
-
407
- if (inputRequest !== null) {
408
- if (key.return) {
409
- processRef.current?.sendInput(inputValue);
410
- setInputRequest(null);
411
- setInputValue("");
412
- } else if (key.backspace || key.delete) {
413
- setInputValue((v) => v.slice(0, -1));
414
- } else if (input && !key.ctrl && !key.meta) {
415
- setInputValue((v) => v + input);
416
- }
417
- return;
418
- }
419
-
420
- if (stage.type === "crashed" && (input === "r" || input === "R")) {
421
- handleRestart();
422
- }
423
-
424
- if (currentPending) {
425
- if (input === "y" || input === "Y") {
426
- const confirmed = currentPending;
427
- setPendingQueue((prev) => prev.filter((p) => p.id !== confirmed.id));
428
- dispatchInvestigation(confirmed.id, confirmed.chunk);
429
- } else if (input === "n" || input === "N") {
430
- activeCountRef.current -= 1;
431
- setPendingQueue((prev) =>
432
- prev.filter((p) => p.id !== currentPending.id),
433
- );
434
- if (
435
- activeCountRef.current === 0 &&
436
- pendingExitCode.current !== undefined
437
- ) {
438
- setStage({
439
- type: "crashed",
440
- exitCode: pendingExitCode.current,
441
- patchedCount: patchedThisRunRef.current,
442
- });
443
- }
444
- }
445
- }
446
- });
447
-
448
- const handleProviderDone = async (p: Provider) => {
449
- providerRef.current = p;
450
- try {
451
- const fileTree = await fetchFileTree(repoPath).catch(() => []);
452
- const importantFiles = readImportantFiles(repoPath, fileTree);
453
- const deps = readPackageJson(repoPath);
454
-
455
- let lensContext = "";
456
- if (lensFileExists(repoPath)) {
457
- const lensFile = readLensFile(repoPath);
458
- if (lensFile) {
459
- setLensLoaded(true);
460
- lensContext = `Overview: ${lensFile.overview}
461
-
462
- Important folders: ${lensFile.importantFolders.join(", ")}
463
- ${lensFile.suggestions.length > 0 ? `\nProject suggestions:\n${lensFile.suggestions.map((s) => `- ${s}`).join("\n")}` : ""}`;
464
- }
465
- }
466
-
467
- systemPromptRef.current = buildWatchSystemPrompt(
468
- repoPath,
469
- deps,
470
- importantFiles,
471
- lensContext,
472
- extraPrompt ?? "",
473
- );
474
- } catch {
475
- systemPromptRef.current = buildWatchSystemPrompt(
476
- repoPath,
477
- "",
478
- [],
479
- "",
480
- extraPrompt ?? "",
481
- );
482
- }
483
- setStage({ type: "running" });
484
- startWatching();
485
- };
486
-
487
- const startWatching = () => {
488
- patchedThisRunRef.current = 0;
489
- const proc = spawnWatch(cmd, repoPath);
490
- processRef.current = proc;
491
-
492
- proc.onLog((line, isErr) => {
493
- const text = stripAnsi(line).slice(0, 200);
494
- setLogs((prev) => {
495
- const next = [...prev, { text, isErr }];
496
- return next.length > MAX_LOGS ? next.slice(-MAX_LOGS) : next;
497
- });
498
- });
499
-
500
- proc.onError((chunk: ErrorChunk) => {
501
- const id = nanoid(6);
502
-
503
- activeCountRef.current += 1;
504
-
505
- if (fixAll) {
506
- const abort = new AbortController();
507
- abortControllersRef.current.set(id, abort);
508
- const t = Date.now();
509
- setActive((prev) => [
510
- ...prev,
511
- { id, chunk, toolLog: [], startTime: t },
512
- ]);
513
- const initialMessages: Message[] = [
514
- { role: "user", content: buildErrorPrompt(chunk), type: "text" },
515
- ];
516
- runInvestigation(id, chunk, initialMessages, abort.signal, t);
517
- } else {
518
- setPendingQueue((prev) => [...prev, { id, chunk }]);
519
- }
520
- });
521
-
522
- proc.onInputRequest((prompt) => {
523
- setInputRequest(prompt);
524
- setInputValue("");
525
- });
526
-
527
- proc.onExit((code) => {
528
- pendingExitCode.current = code;
529
- setTimeout(() => {
530
- if (activeCountRef.current === 0) {
531
- setStage({
532
- type: "crashed",
533
- exitCode: code,
534
- patchedCount: patchedThisRunRef.current,
535
- });
536
- }
537
- }, 0);
538
- });
539
- };
540
-
541
- const dispatchInvestigation = (id: string, chunk: ErrorChunk) => {
542
- const abort = new AbortController();
543
- abortControllersRef.current.set(id, abort);
544
- const t = Date.now();
545
- setActive((prev) => [...prev, { id, chunk, toolLog: [], startTime: t }]);
546
- const initialMessages: Message[] = [
547
- { role: "user", content: buildErrorPrompt(chunk), type: "text" },
548
- ];
549
- runInvestigation(id, chunk, initialMessages, abort.signal, t);
550
- };
551
-
552
- useEffect(() => {
553
- return () => {
554
- processRef.current?.kill();
555
- abortControllersRef.current.forEach((a) => a.abort());
556
- };
557
- }, []);
558
-
559
- useEffect(() => {
560
- if (autoRestart && stage.type === "crashed") {
561
- const t = setTimeout(() => handleRestart(), 1500);
562
- return () => clearTimeout(t);
563
- }
564
- }, [stage.type]);
565
-
566
- const runInvestigation = async (
567
- id: string,
568
- chunk: ErrorChunk,
569
- messages: Message[],
570
- signal: AbortSignal,
571
- startTime = Date.now(),
572
- ): Promise<void> => {
573
- const provider = providerRef.current;
574
- if (!provider || signal.aborted) return;
575
-
576
- const finishInvestigation = () => {
577
- activeCountRef.current -= 1;
578
- setActive((prev) => prev.filter((a) => a.id !== id));
579
- if (
580
- activeCountRef.current === 0 &&
581
- pendingExitCode.current !== undefined
582
- ) {
583
- setTimeout(() => {
584
- setStage({
585
- type: "crashed",
586
- exitCode: pendingExitCode.current!,
587
- patchedCount: patchedThisRunRef.current,
588
- });
589
- }, 100);
590
- }
591
- };
592
-
593
- try {
594
- const timeoutController = new AbortController();
595
- const timeoutId = setTimeout(
596
- () => timeoutController.abort(),
597
- INVESTIGATION_TIMEOUT_MS,
598
- );
599
- const combinedSignal = AbortSignal.any
600
- ? AbortSignal.any([signal, timeoutController.signal])
601
- : signal;
602
-
603
- let raw: string;
604
- try {
605
- const result = await callChat(
606
- provider,
607
- systemPromptRef.current,
608
- messages,
609
- combinedSignal,
610
- );
611
- raw = result.text;
612
- } finally {
613
- clearTimeout(timeoutId);
614
- }
615
- if (signal.aborted) return;
616
-
617
- const parsed = parseResponse(raw);
618
-
619
- if (parsed.kind === "tool") {
620
- const tool = registry.get(parsed.toolName);
621
- if (!tool) throw new Error(`unknown tool: ${parsed.toolName}`);
622
-
623
- const label = tool.summariseInput
624
- ? String(tool.summariseInput(parsed.input))
625
- : parsed.toolName;
626
-
627
- setActive((prev) =>
628
- prev.map((a) =>
629
- a.id === id ? { ...a, toolLog: [...a.toolLog, label] } : a,
630
- ),
631
- );
632
-
633
- const approved = tool.safe || fixAll;
634
- let result = "(denied)";
635
-
636
- if (approved) {
637
- try {
638
- const r = await tool.execute(parsed.input, { repoPath, messages });
639
- result = r.value;
640
- if ((r as any).kind === "image") {
641
- stdout.write(result + "\n");
642
- result = "(image rendered)";
643
- }
644
- } catch (e: any) {
645
- result = `Error: ${e.message}`;
646
- }
647
- }
648
-
649
- const nextMessages: Message[] = [
650
- ...messages,
651
- {
652
- role: "user" as const,
653
- content: approved
654
- ? `Tool result for <${parsed.toolName}>:\n${result}`
655
- : `Tool <${parsed.toolName}> was denied.`,
656
- type: "text" as const,
657
- },
658
- ];
659
-
660
- return runInvestigation(id, chunk, nextMessages, signal, startTime);
661
- }
662
-
663
- const text = parsed.kind === "text" ? parsed.content : raw;
664
- const cleaned = text.replace(/```json|```/g, "").trim();
665
- const match = cleaned.match(/\{[\s\S]*\}/);
666
-
667
- if (match) {
668
- const data = JSON.parse(match[0]) as {
669
- errorSummary: string;
670
- simplified: string;
671
- fix: string;
672
- patch?: { path: string; content: string; isNew: boolean } | null;
673
- };
674
-
675
- const suggestion: Suggestion = {
676
- id,
677
- errorSummary: data.errorSummary,
678
- simplified: data.simplified,
679
- fix: data.fix,
680
- filePath: chunk.filePath,
681
- patch: data.patch ?? undefined,
682
- timestamp: Date.now(),
683
- };
684
-
685
- if (fixAll && data.patch) {
686
- try {
687
- applyPatches(repoPath, [data.patch]);
688
- setFixedCount((n) => n + 1);
689
- patchedThisRunRef.current += 1;
690
- } catch {}
691
- }
692
-
693
- const elapsed = Date.now() - startTime;
694
- if (elapsed < 800)
695
- await new Promise((r) => setTimeout(r, 800 - elapsed));
696
-
697
- setSuggestions((prev) => {
698
- const next = [...prev, suggestion];
699
- return next.length > MAX_SUGGESTIONS
700
- ? next.slice(-MAX_SUGGESTIONS)
701
- : next;
702
- });
703
- finishInvestigation();
704
- } else {
705
- const elapsed = Date.now() - startTime;
706
- if (elapsed < 800)
707
- await new Promise((r) => setTimeout(r, 800 - elapsed));
708
- finishInvestigation();
709
- }
710
- } catch (e: any) {
711
- if (e?.name === "AbortError" && signal.aborted) return;
712
-
713
- const errMsg =
714
- e?.name === "AbortError"
715
- ? `Timed out after ${INVESTIGATION_TIMEOUT_MS / 1000}s — provider may be slow or unreachable`
716
- : (e?.message ?? String(e));
717
- const elapsed = Date.now() - startTime;
718
- if (elapsed < 800) await new Promise((r) => setTimeout(r, 800 - elapsed));
719
-
720
- setSuggestions((prev) => [
721
- ...prev,
722
- {
723
- id,
724
- errorSummary: chunk.lines[0]?.slice(0, 80) ?? "Error",
725
- simplified: `Investigation failed: ${errMsg}`,
726
- fix: "Check your provider config or try again.",
727
- filePath: chunk.filePath,
728
- timestamp: Date.now(),
729
- },
730
- ]);
731
- finishInvestigation();
732
- }
733
- };
734
-
735
- if (stage.type === "picking-provider") {
736
- return <ProviderPicker onDone={handleProviderDone} />;
737
- }
738
-
739
- const w = process.stdout.columns ?? 80;
740
-
741
- return (
742
- <Box flexDirection="column">
743
- <Box flexDirection="column" marginBottom={1}>
744
- <Text color={ACCENT} bold>
745
- ◈ SPY{" "}
746
- <Text color="white" bold={false}>
747
- {cmd}
748
- </Text>
749
- {clean && (
750
- <Text color="gray" bold={false}>
751
- {" "}
752
- --clean
753
- </Text>
754
- )}
755
- {fixAll && (
756
- <Text color={GREEN} bold={false}>
757
- {" "}
758
- --fix-all
759
- </Text>
760
- )}
761
- {autoRestart && (
762
- <Text color={CYAN} bold={false}>
763
- {" "}
764
- --auto-restart
765
- </Text>
766
- )}
767
- {extraPrompt && (
768
- <Text color="gray" bold={false}>
769
- {" "}
770
- --prompt
771
- </Text>
772
- )}
773
- {lensLoaded && (
774
- <Text color={ACCENT} bold={false}>
775
- {" "}
776
- [LENS.md]
777
- </Text>
778
- )}
779
- {fixedCount > 0 && (
780
- <Text color={GREEN} bold={false}>
781
- {" "}
782
- ({fixedCount} fixed)
783
- </Text>
784
- )}
785
- </Text>
786
- <Text color="gray">{"═".repeat(Math.min(w, 80))}</Text>
787
- </Box>
788
-
789
- {!clean && (
790
- <Box flexDirection="column" marginBottom={1}>
791
- {logs
792
- .slice(-Math.max(4, (process.stdout.rows ?? 24) - 10))
793
- .map((log, i) => (
794
- <Text
795
- key={i}
796
- color={log.isErr ? RED : "gray"}
797
- dimColor={!log.isErr}
798
- >
799
- {log.text}
800
- </Text>
801
- ))}
802
- {stage.type === "running" && logs.length === 0 && (
803
- <Box gap={1}>
804
- <Text color={ACCENT}>
805
- <Spinner />
806
- </Text>
807
- <Text color="gray">waiting for output...</Text>
808
- </Box>
809
- )}
810
- </Box>
811
- )}
812
-
813
- {inputRequest !== null && (
814
- <InputCard prompt={inputRequest} value={inputValue} />
815
- )}
816
-
817
- {(suggestions.length > 0 || active.length > 0 || currentPending) && (
818
- <Box marginBottom={1} gap={1}>
819
- <Text color={ACCENT} bold>
820
- ◈ LENS
821
- </Text>
822
- {fixAll && <Text color={GREEN}>· auto-fixing</Text>}
823
- </Box>
824
- )}
825
-
826
- {currentPending && <ConfirmCard pending={currentPending} />}
827
-
828
- {pendingQueue.length > 1 && (
829
- <Box marginLeft={2} marginBottom={1}>
830
- <Text color="gray" dimColor>
831
- +{pendingQueue.length - 1} more error
832
- {pendingQueue.length - 1 > 1 ? "s" : ""} queued
833
- </Text>
834
- </Box>
835
- )}
836
-
837
- {active.map((a) => (
838
- <ThinkingCard
839
- key={a.id}
840
- chunk={a.chunk}
841
- toolLog={a.toolLog}
842
- startTime={a.startTime}
843
- />
844
- ))}
845
-
846
- {suggestions.map((s, i) => (
847
- <SuggestionCard
848
- key={s.id}
849
- suggestion={s}
850
- isNew={i === suggestions.length - 1}
851
- fixAll={fixAll}
852
- repoPath={repoPath}
853
- />
854
- ))}
855
-
856
- {clean &&
857
- suggestions.length === 0 &&
858
- active.length === 0 &&
859
- !currentPending && (
860
- <Box gap={1} marginTop={1}>
861
- <Text color={ACCENT}>
862
- <Spinner />
863
- </Text>
864
- <Text color="gray">watching for errors...</Text>
865
- </Box>
866
- )}
867
-
868
- {stage.type === "crashed" && (
869
- <Box flexDirection="column" marginTop={1} gap={1}>
870
- <Box gap={1}>
871
- <Text color={RED}>✗</Text>
872
- <Text color="white">
873
- process exited
874
- {stage.exitCode !== null ? ` (code ${stage.exitCode})` : ""}
875
- </Text>
876
- </Box>
877
- {autoRestart && stage.patchedCount > 0 && stage.exitCode !== 0 ? (
878
- <Box gap={1}>
879
- <Text color={ACCENT}>
880
- <Spinner />
881
- </Text>
882
- <Text color="gray">restarting...</Text>
883
- </Box>
884
- ) : stage.patchedCount > 0 ? (
885
- <Box flexDirection="column" gap={1}>
886
- <Box gap={1}>
887
- <Text color={ACCENT}>✔</Text>
888
- <Text color={GREEN}>
889
- {stage.patchedCount} patch{stage.patchedCount > 1 ? "es" : ""}{" "}
890
- applied
891
- </Text>
892
- </Box>
893
- <Box gap={1}>
894
- <Text color={ACCENT} bold>
895
- r
896
- </Text>
897
- <Text color="white">re-run to verify fixes</Text>
898
- <Text color="gray" dimColor>
899
- · ctrl+c to quit
900
- </Text>
901
- </Box>
902
- </Box>
903
- ) : (
904
- <Box gap={1}>
905
- <Text color={ACCENT} bold>
906
- r
907
- </Text>
908
- <Text color="white">re-run</Text>
909
- <Text color="gray" dimColor>
910
- · ctrl+c to quit
911
- </Text>
912
- </Box>
913
- )}
914
- </Box>
915
- )}
916
-
917
- {stage.type === "running" && (
918
- <Box marginTop={1}>
919
- <Text color="gray" dimColor>
920
- ctrl+c to stop
921
- {!fixAll && suggestions.some((s) => s.patch)
922
- ? " · patches available (use --fix-all to auto-apply)"
923
- : ""}
924
- </Text>
925
- </Box>
926
- )}
927
- </Box>
928
- );
929
- }