@ridit/lens 0.3.2 → 0.3.3

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,929 @@
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 WatchRunner({
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.securityIssues.length > 0 ? `\nKnown security issues:\n${lensFile.securityIssues.map((s) => `- ${s}`).join("\n")}` : ""}
464
+ ${lensFile.suggestions.length > 0 ? `\nProject suggestions:\n${lensFile.suggestions.map((s) => `- ${s}`).join("\n")}` : ""}`;
465
+ }
466
+ }
467
+
468
+ systemPromptRef.current = buildWatchSystemPrompt(
469
+ repoPath,
470
+ deps,
471
+ importantFiles,
472
+ lensContext,
473
+ extraPrompt ?? "",
474
+ );
475
+ } catch {
476
+ systemPromptRef.current = buildWatchSystemPrompt(
477
+ repoPath,
478
+ "",
479
+ [],
480
+ "",
481
+ extraPrompt ?? "",
482
+ );
483
+ }
484
+ setStage({ type: "running" });
485
+ startWatching();
486
+ };
487
+
488
+ const startWatching = () => {
489
+ patchedThisRunRef.current = 0;
490
+ const proc = spawnWatch(cmd, repoPath);
491
+ processRef.current = proc;
492
+
493
+ proc.onLog((line, isErr) => {
494
+ const text = stripAnsi(line).slice(0, 200);
495
+ setLogs((prev) => {
496
+ const next = [...prev, { text, isErr }];
497
+ return next.length > MAX_LOGS ? next.slice(-MAX_LOGS) : next;
498
+ });
499
+ });
500
+
501
+ proc.onError((chunk: ErrorChunk) => {
502
+ const id = nanoid(6);
503
+
504
+ activeCountRef.current += 1;
505
+
506
+ if (fixAll) {
507
+ const abort = new AbortController();
508
+ abortControllersRef.current.set(id, abort);
509
+ const t = Date.now();
510
+ setActive((prev) => [
511
+ ...prev,
512
+ { id, chunk, toolLog: [], startTime: t },
513
+ ]);
514
+ const initialMessages: Message[] = [
515
+ { role: "user", content: buildErrorPrompt(chunk), type: "text" },
516
+ ];
517
+ runInvestigation(id, chunk, initialMessages, abort.signal, t);
518
+ } else {
519
+ setPendingQueue((prev) => [...prev, { id, chunk }]);
520
+ }
521
+ });
522
+
523
+ proc.onInputRequest((prompt) => {
524
+ setInputRequest(prompt);
525
+ setInputValue("");
526
+ });
527
+
528
+ proc.onExit((code) => {
529
+ pendingExitCode.current = code;
530
+ setTimeout(() => {
531
+ if (activeCountRef.current === 0) {
532
+ setStage({
533
+ type: "crashed",
534
+ exitCode: code,
535
+ patchedCount: patchedThisRunRef.current,
536
+ });
537
+ }
538
+ }, 0);
539
+ });
540
+ };
541
+
542
+ const dispatchInvestigation = (id: string, chunk: ErrorChunk) => {
543
+ const abort = new AbortController();
544
+ abortControllersRef.current.set(id, abort);
545
+ const t = Date.now();
546
+ setActive((prev) => [...prev, { id, chunk, toolLog: [], startTime: t }]);
547
+ const initialMessages: Message[] = [
548
+ { role: "user", content: buildErrorPrompt(chunk), type: "text" },
549
+ ];
550
+ runInvestigation(id, chunk, initialMessages, abort.signal, t);
551
+ };
552
+
553
+ useEffect(() => {
554
+ return () => {
555
+ processRef.current?.kill();
556
+ abortControllersRef.current.forEach((a) => a.abort());
557
+ };
558
+ }, []);
559
+
560
+ useEffect(() => {
561
+ if (autoRestart && stage.type === "crashed") {
562
+ const t = setTimeout(() => handleRestart(), 1500);
563
+ return () => clearTimeout(t);
564
+ }
565
+ }, [stage.type]);
566
+
567
+ const runInvestigation = async (
568
+ id: string,
569
+ chunk: ErrorChunk,
570
+ messages: Message[],
571
+ signal: AbortSignal,
572
+ startTime = Date.now(),
573
+ ): Promise<void> => {
574
+ const provider = providerRef.current;
575
+ if (!provider || signal.aborted) return;
576
+
577
+ const finishInvestigation = () => {
578
+ activeCountRef.current -= 1;
579
+ setActive((prev) => prev.filter((a) => a.id !== id));
580
+ if (
581
+ activeCountRef.current === 0 &&
582
+ pendingExitCode.current !== undefined
583
+ ) {
584
+ setTimeout(() => {
585
+ setStage({
586
+ type: "crashed",
587
+ exitCode: pendingExitCode.current!,
588
+ patchedCount: patchedThisRunRef.current,
589
+ });
590
+ }, 100);
591
+ }
592
+ };
593
+
594
+ try {
595
+ const timeoutController = new AbortController();
596
+ const timeoutId = setTimeout(
597
+ () => timeoutController.abort(),
598
+ INVESTIGATION_TIMEOUT_MS,
599
+ );
600
+ const combinedSignal = AbortSignal.any
601
+ ? AbortSignal.any([signal, timeoutController.signal])
602
+ : signal;
603
+
604
+ let raw: string;
605
+ try {
606
+ raw = await callChat(
607
+ provider,
608
+ systemPromptRef.current,
609
+ messages,
610
+ combinedSignal,
611
+ );
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
+ }