@ridit/lens 0.3.1 → 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.
- package/addons/README.md +55 -3
- package/addons/clean-cache.js +48 -0
- package/addons/generate-readme.js +67 -0
- package/addons/git-stats.js +29 -0
- package/dist/index.mjs +1695 -750
- package/package.json +1 -1
- package/src/commands/commit.tsx +31 -49
- package/src/commands/watch.tsx +56 -0
- package/src/components/timeline/TimelineRunner.tsx +0 -7
- package/src/components/watch/WatchRunner.tsx +929 -0
- package/src/index.tsx +144 -110
- package/src/prompts/fewshot.ts +46 -286
- package/src/prompts/system.ts +71 -92
- package/src/utils/tools/builtins.ts +14 -10
- package/src/utils/watch.ts +307 -0
- package/LENS.md +0 -32
|
@@ -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}>></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
|
+
}
|