@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.
@@ -66,10 +66,6 @@ function makeMsg(content: string): Message {
66
66
  return { role: "assistant", content, type: "text" };
67
67
  }
68
68
 
69
- /**
70
- * Returns true if the command was handled, false if it should fall through
71
- * to the normal message flow.
72
- */
73
69
  export function handleCommand(text: string, ctx: CommandContext): boolean {
74
70
  const t = text.trim().toLowerCase();
75
71
 
@@ -105,7 +105,7 @@ function AskingFilesStep() {
105
105
  return (
106
106
  <Box gap={1}>
107
107
  <Text color={ACCENT}>
108
- <Spinner />
108
+ <Spinner type="moon" />
109
109
  </Text>
110
110
  <Text color={ACCENT}>{phrase}</Text>
111
111
  </Box>
@@ -117,7 +117,7 @@ function AnalyzingStep() {
117
117
  return (
118
118
  <Box gap={1}>
119
119
  <Text color={ACCENT}>
120
- <Spinner />
120
+ <Spinner type="earth" />
121
121
  </Text>
122
122
  <Text color={ACCENT}>{phrase}</Text>
123
123
  </Box>
@@ -366,7 +366,7 @@ export const RepoAnalysis = ({
366
366
  return (
367
367
  <Box marginTop={1}>
368
368
  <Text color={ACCENT}>
369
- <Spinner />
369
+ <Spinner type="arc" />
370
370
  </Text>
371
371
  <Box marginLeft={1}>
372
372
  <Text>Writing file...</Text>
@@ -88,7 +88,7 @@ function ThinkingAboutStep({ prompt }: { prompt: string }) {
88
88
  return (
89
89
  <Box gap={1}>
90
90
  <Text color={ACCENT}>
91
- <Spinner />
91
+ <Spinner type="triangle" />
92
92
  </Text>
93
93
  <Text color={ACCENT}>{phrase}</Text>
94
94
  <Text color="gray">"{prompt}"</Text>
@@ -270,7 +270,7 @@ export const PromptRunner = ({
270
270
  return (
271
271
  <Box marginTop={1} gap={1}>
272
272
  <Text color={ACCENT}>
273
- <Spinner />
273
+ <Spinner type="dots2" />
274
274
  </Text>
275
275
  <Text>Reading codebase...</Text>
276
276
  </Box>
@@ -322,7 +322,7 @@ export const PromptRunner = ({
322
322
  return (
323
323
  <Box marginTop={1} gap={1}>
324
324
  <Text color={ACCENT}>
325
- <Spinner />
325
+ <Spinner type="bouncingBar" />
326
326
  </Text>
327
327
  <Text>Applying changes...</Text>
328
328
  </Box>
package/src/index.tsx CHANGED
@@ -16,6 +16,11 @@ import { loadAddons } from "./utils/addons/loadAddons";
16
16
  registerBuiltins();
17
17
  await loadAddons();
18
18
 
19
+ if (process.stdout.isTTY) {
20
+ process.stdout.write("\x1b[>4;1m");
21
+ process.on("exit", () => process.stdout.write("\x1b[>4;0m"));
22
+ }
23
+
19
24
  const program = new Command();
20
25
 
21
26
  program
@@ -51,8 +56,14 @@ program
51
56
  .command("chat")
52
57
  .description("Chat with your codebase — ask questions or make changes")
53
58
  .option("-p, --path <path>", "Path to the repo", ".")
54
- .action((opts: { path: string }) => {
55
- render(<ChatCommand path={opts.path} />);
59
+ .option(
60
+ "--auto-force",
61
+ "Start with force-all mode enabled (auto-approves all tools)",
62
+ )
63
+ .action((opts: { path: string; autoForce: boolean }) => {
64
+ render(
65
+ <ChatCommand path={opts.path} autoForce={opts.autoForce ?? false} />,
66
+ );
56
67
  });
57
68
 
58
69
  program
@@ -46,23 +46,22 @@ Use memory-delete when the user asks you to forget something or a memory is outd
46
46
 
47
47
  1. ONE tool per response — emit the XML tag, then stop. Never chain tools in one response except when scaffolding (see below).
48
48
  2. NEVER call a tool more than once for the same path in a session. If write-file or shell returned a result, it succeeded. Move on immediately.
49
- 3. NEVER write the same file twice in one session. One write per file, period. If you already wrote it, it is done.
50
- 4. shell is ONLY for running code, installing packages, building, and testing. NEVER use shell to inspect the filesystem or read files — use read-file, read-folder, or grep instead.
51
- 5. write-file content must be the COMPLETE file content, never a placeholder or partial.
52
- 6. NEVER read a file you just wrote. The write output confirms success.
53
- 7. NEVER apologize and redo a tool callone attempt is enough, trust the output.
54
- 8. NEVER use shell to run git clone — use the clone tag instead.
55
- 9. When the user asks you to CREATE a new file, write it immediately do NOT read first.
56
- 10. When the user asks you to MODIFY or FIX an existing file, read it first, then write the complete updated version ONCE.
57
- 11. When fixing multiple files, use read-files to read ALL of them first, then write each one ONCE sequentially never rewrite a file already written this session.
58
- 12. If a read-folder or read-file returns not found, accept it and move on do NOT retry the same path.
59
- 13. Every shell command runs from the repo root cd has no persistent effect. Use full paths or combine with && e.g. cd myapp && bun run index.ts
60
- 14. write-file paths are relative to the repo rootuse full relative paths e.g. myapp/src/index.tsx not src/index.tsx
61
- 15. When explaining how to use a tool in text, use [tag] bracket notation NEVER emit a real XML tool tag as part of an explanation.
62
- 16. NEVER use markdown formatting in plain text responses no bold, no headings, no bullet points. Only use fenced code blocks when showing actual code.
63
- 17. When scaffolding multiple files, emit ONE write-file tag per response and wait for the result before writing the next file.
64
- 18. When you identify a bug or error, ALWAYS write the fix immediately using write-file or changes. Never describe the fix without writing it.
65
- 19. NEVER use shell for filesystem inspection or searching — always use grep, read-file, or read-folder instead.
49
+ 3. shell is ONLY for running code, installing packages, building, and testing. NEVER use shell to inspect the filesystem or read files — use read-file, read-folder, or grep instead.
50
+ 4. NEVER use shell to run git clone — use the clone tag instead.
51
+ 5. NEVER read a file you just wrote. The write output confirms success.
52
+ 6. NEVER apologize and redo a tool call one attempt is enough, trust the output.
53
+ 7. When the user asks you to CREATE a brand new file, use write-file immediately do NOT read first. write-file content must be the COMPLETE file content.
54
+ 8. When the user asks you to MODIFY or FIX an existing file, read it first, then propose the edit using changes NEVER use write-file on an existing file.
55
+ 9. When fixing multiple files, use read-files to read ALL of them first, then emit one changes tag with all patches together.
56
+ 10. If a read-folder or read-file returns not found, accept it and move on do NOT retry the same path.
57
+ 11. Every shell command runs from the repo root cd has no persistent effect. Use full paths or combine with && e.g. cd myapp && bun run index.ts
58
+ 12. write-file paths are relative to the repo rootuse full relative paths e.g. myapp/src/index.tsx not src/index.tsx
59
+ 13. When explaining how to use a tool in text, use [tag] bracket notation NEVER emit a real XML tool tag as part of an explanation.
60
+ 14. NEVER use markdown formatting in plain text responsesno bold, no headings, no bullet points. Only use fenced code blocks when showing actual code.
61
+ 15. When scaffolding multiple NEW files, emit ONE write-file per response and wait for the result before writing the next file.
62
+ 16. When you identify a bug or error, ALWAYS propose the fix immediately using changes. Never describe the fix without proposing it.
63
+ 17. NEVER use shell for filesystem inspection or searching always use grep, read-file, or read-folder instead.
64
+ 18. changes patches must include the COMPLETE new file content for each path never partial content.
66
65
 
67
66
  ## ADDON FORMAT
68
67
 
package/src/types/chat.ts CHANGED
@@ -33,7 +33,8 @@ export type Message =
33
33
  | "delete-folder"
34
34
  | "open-url"
35
35
  | "generate-pdf"
36
- | "search";
36
+ | "search"
37
+ | "changes";
37
38
  content: string;
38
39
  result: string;
39
40
  approved: boolean;
@@ -43,6 +44,7 @@ export type Message =
43
44
  type: "plan";
44
45
  content: string;
45
46
  patches: FilePatch[];
47
+ diffLines?: DiffLine[][];
46
48
  applied: boolean;
47
49
  };
48
50
 
package/src/utils/chat.ts CHANGED
@@ -195,12 +195,22 @@ export function toCloneUrl(url: string): string {
195
195
  return clean.endsWith(".git") ? clean : `${clean}.git`;
196
196
  }
197
197
 
198
+ // Replace buildApiMessages in src/utils/chat.ts
199
+
198
200
  function buildApiMessages(
199
201
  messages: Message[],
200
202
  ): { role: string; content: string }[] {
201
203
  const recent = messages.slice(-MAX_HISTORY);
202
204
 
203
- return recent.map((m) => {
205
+ const writtenFiles: string[] = [];
206
+ for (const m of recent) {
207
+ if (m.type === "tool" && m.toolName === "write-file" && m.approved) {
208
+ const pathMatch = m.content.match(/^(.+?)\s*\(/);
209
+ if (pathMatch?.[1]) writtenFiles.push(pathMatch[1]);
210
+ }
211
+ }
212
+
213
+ return recent.map((m, idx) => {
204
214
  if (m.type === "tool") {
205
215
  if (!m.approved) {
206
216
  return {
@@ -209,11 +219,39 @@ function buildApiMessages(
209
219
  "The tool call was denied by the user. Please respond without using that tool.",
210
220
  };
211
221
  }
222
+
223
+ if (m.toolName === "write-file") {
224
+ const remaining = writtenFiles.slice(
225
+ writtenFiles.indexOf(m.content.split(" (")[0]!) + 1,
226
+ );
227
+ const doneList = writtenFiles
228
+ .slice(0, writtenFiles.indexOf(m.content.split(" (")[0]!) + 1)
229
+ .map((f) => `- ${f}`)
230
+ .join("\n");
231
+
232
+ return {
233
+ role: "user",
234
+ content: [
235
+ `Tool result for write-file (${m.content}):`,
236
+ "",
237
+ m.result,
238
+ "",
239
+ `Files written so far:`,
240
+ doneList || `- ${m.content.split(" (")[0]}`,
241
+ "",
242
+ remaining.length > 0
243
+ ? `Continue with the NEXT file. Do NOT rewrite any file already written above.`
244
+ : `All files written. Summarize what was created.`,
245
+ ].join("\n"),
246
+ };
247
+ }
248
+
212
249
  return {
213
250
  role: "user",
214
- content: `Here is the output from the ${m.toolName} of ${m.content}:\n\n${m.result}\n\nPlease continue your response based on this output.`,
251
+ content: `Tool result for ${m.toolName} (${m.content}):\n\n${m.result}\n\nPlease continue your response based on this output.`,
215
252
  };
216
253
  }
254
+
217
255
  return { role: m.role, content: m.content };
218
256
  });
219
257
  }
@@ -262,7 +300,7 @@ export async function callChat(
262
300
  }
263
301
 
264
302
  const controller = new AbortController();
265
- const timer = setTimeout(() => controller.abort(), 60_000);
303
+ const timer = setTimeout(() => controller.abort(), 180_000);
266
304
  abortSignal?.addEventListener("abort", () => controller.abort());
267
305
 
268
306
  try {
@@ -310,7 +348,22 @@ export async function callChat(
310
348
  }
311
349
  } catch (err) {
312
350
  clearTimeout(timer);
313
- if (err instanceof Error && err.name === "AbortError") throw err;
351
+ if (err instanceof Error && err.name === "AbortError") {
352
+ if (abortSignal?.aborted) throw err;
353
+ if (retries > 0) {
354
+ await new Promise((r) => setTimeout(r, 1500));
355
+ return callChat(
356
+ provider,
357
+ systemPrompt,
358
+ messages,
359
+ abortSignal,
360
+ retries - 1,
361
+ );
362
+ }
363
+ throw new Error(
364
+ "Request timed out. The model took too long to respond (>3 min).",
365
+ );
366
+ }
314
367
  if (retries > 0) {
315
368
  await new Promise((r) => setTimeout(r, 1500));
316
369
  return callChat(
@@ -1,48 +1,33 @@
1
- /**
2
- * Classifies user message intent to scope which tools the LLM is allowed to use.
3
- *
4
- * readonly → only read/search/fetch tools exposed (no write, delete, shell)
5
- * mutating → all tools exposed
6
- * any → all tools exposed (ambiguous / can't tell)
7
- */
8
1
  export type Intent = "readonly" | "mutating" | "any";
9
2
 
10
3
  const READONLY_PATTERNS: RegExp[] = [
11
- // listing / exploring
12
4
  /\b(list|ls|dir|show|display|print|dump)\b/i,
13
5
  /\bwhat(('?s| is| are| does)\b| files| folder)/i,
14
6
  /\b(folder|directory|file) (structure|tree|layout|contents?)\b/i,
15
7
  /\bexplore\b/i,
16
8
 
17
- // reading / explaining
18
9
  /\b(read|open|view|look at|check out|inspect|peek)\b/i,
19
10
  /\b(explain|describe|summarize|summarise|tell me about|walk me through)\b/i,
20
11
  /\bhow does\b/i,
21
12
  /\bwhat('?s| is) (in|inside|this|that|the)\b/i,
22
13
 
23
- // searching
24
14
  /\b(find|search|grep|locate|where is|where are)\b/i,
25
15
  /\b(look for|scan|trace)\b/i,
26
16
 
27
- // understanding
28
17
  /\bunderstand\b/i,
29
18
  /\bshow me (how|what|where|why)\b/i,
30
19
  ];
31
20
 
32
21
  const MUTATING_PATTERNS: RegExp[] = [
33
- // writing
34
22
  /\b(write|create|make|generate|add|build|scaffold|init|initialize|setup|set up)\b/i,
35
23
  /\b(new file|new folder|new component|new page|new route)\b/i,
36
24
 
37
- // editing
38
25
  /\b(edit|modify|update|change|refactor|rename|move|migrate)\b/i,
39
26
  /\b(fix|patch|resolve|correct|debug|repair)\b/i,
40
27
  /\b(implement|add .+ to|insert|inject|append|prepend)\b/i,
41
28
 
42
- // deleting
43
29
  /\b(delete|remove|drop|clean ?up|purge|wipe)\b/i,
44
30
 
45
- // running
46
31
  /\b(run|execute|install|deploy|build|test|start|launch|compile|lint|format)\b/i,
47
32
  ];
48
33
 
@@ -1,5 +1,23 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
 
3
+ const TIPS = [
4
+ "use /auto to toggle auto-approve for safe tools",
5
+ "ctrl+f to toggle force-all mode",
6
+ "shift+enter for a new line in the input",
7
+ "↑ / ↓ arrows navigate message history",
8
+ "ctrl+w deletes the previous word",
9
+ "ctrl+delete deletes the next word",
10
+ "/timeline to browse commit history",
11
+ "/review to analyze the current codebase",
12
+ "/memory list to view stored memories",
13
+ "/chat list to see saved conversations",
14
+ "esc cancels a running response",
15
+ "/clear history resets session memory",
16
+ "tab autocompletes slash commands",
17
+ "lens commit --auto for fast AI commits",
18
+ "lens run \"bun dev\" to watch and auto-fix errors",
19
+ ];
20
+
3
21
  const PHRASES: Record<string, string[]> = {
4
22
  general: [
5
23
  "marinating on that... 🍖",
@@ -321,6 +339,52 @@ const PHRASES: Record<string, string[]> = {
321
339
 
322
340
  export type ThinkingKind = keyof typeof PHRASES;
323
341
 
342
+ export function useThinkingTip(active: boolean, intervalMs = 8000): string {
343
+ const [index, setIndex] = useState(() => Math.floor(Math.random() * TIPS.length));
344
+ const usedRef = useRef<Set<number>>(new Set());
345
+
346
+ useEffect(() => {
347
+ if (!active) return;
348
+ const pickUnused = () => {
349
+ if (usedRef.current.size >= TIPS.length) usedRef.current.clear();
350
+ let next: number;
351
+ do {
352
+ next = Math.floor(Math.random() * TIPS.length);
353
+ } while (usedRef.current.has(next));
354
+ usedRef.current.add(next);
355
+ return next;
356
+ };
357
+ setIndex(pickUnused());
358
+ const id = setInterval(() => setIndex(pickUnused()), intervalMs);
359
+ return () => clearInterval(id);
360
+ }, [active, intervalMs]);
361
+
362
+ return TIPS[index]!;
363
+ }
364
+
365
+ export function useThinkingTimer(active: boolean): string {
366
+ const [seconds, setSeconds] = useState(0);
367
+ const startRef = useRef<number | null>(null);
368
+
369
+ useEffect(() => {
370
+ if (active) {
371
+ startRef.current = Date.now();
372
+ setSeconds(0);
373
+ const id = setInterval(() => {
374
+ setSeconds(Math.floor((Date.now() - (startRef.current ?? Date.now())) / 1000));
375
+ }, 1000);
376
+ return () => clearInterval(id);
377
+ } else {
378
+ startRef.current = null;
379
+ setSeconds(0);
380
+ }
381
+ }, [active]);
382
+
383
+ if (!active || seconds === 0) return "";
384
+ if (seconds < 60) return `${seconds}s`;
385
+ return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
386
+ }
387
+
324
388
  export function useThinkingPhrase(
325
389
  active: boolean,
326
390
  kind: ThinkingKind = "general",
@@ -136,41 +136,56 @@ export const writeFileTool: Tool<WriteFileInput> = {
136
136
  systemPromptEntry: (i) =>
137
137
  `### ${i}. write-file — create or overwrite a file\n<write-file>\n{"path": "data/output.csv", "content": "col1,col2\\nval1,val2"}\n</write-file>`,
138
138
  parseInput: (body) => {
139
- const tryParse = (s: string) => {
139
+ const tryParse = (s: string): WriteFileInput | null => {
140
140
  try {
141
- const parsed = JSON.parse(s) as { path: string; content: string };
142
- if (!parsed.path || parsed.content === undefined) return null;
143
- return { ...parsed, path: parsed.path.replace(/\\/g, "/") };
141
+ const parsed = JSON.parse(s) as { path?: unknown; content?: unknown };
142
+ if (
143
+ typeof parsed.path !== "string" ||
144
+ typeof parsed.content !== "string"
145
+ )
146
+ return null;
147
+ return {
148
+ path: parsed.path.replace(/\\/g, "/"),
149
+ content: parsed.content,
150
+ };
144
151
  } catch {
145
152
  return null;
146
153
  }
147
154
  };
148
155
 
156
+ // attempt 1: parse as-is
149
157
  const first = tryParse(body.trim());
150
158
  if (first) return first;
151
159
 
160
+ // attempt 2: escape unescaped control characters
152
161
  try {
153
162
  const sanitized = body
154
163
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "")
155
- .replace(/\n/g, "\\n")
156
- .replace(/\r/g, "\\r")
157
- .replace(/\t/g, "\\t");
164
+ .replace(/(?<!\\)\n/g, "\\n")
165
+ .replace(/(?<!\\)\r/g, "\\r")
166
+ .replace(/(?<!\\)\t/g, "\\t");
158
167
  const second = tryParse(sanitized);
159
168
  if (second) return second;
160
169
  } catch {}
161
170
 
171
+ // attempt 3: regex extraction as last resort
162
172
  const pathMatch = body.match(/"path"\s*:\s*"([^"]+)"/);
163
- const contentMatch = body.match(/"content"\s*:\s*"([\s\S]*)"\s*}?\s*$/);
164
- if (pathMatch && contentMatch && contentMatch[1] !== undefined) {
173
+ const contentMatch = body.match(/"content"\s*:\s*"([\s\S]*?)"\s*}?\s*$/);
174
+ if (pathMatch?.[1] && contentMatch?.[1] !== undefined) {
165
175
  return {
166
- path: pathMatch[1]!.replace(/\\/g, "/"),
167
- content: contentMatch[1]!.replace(/\\n/g, "\n").replace(/\\t/g, "\t"),
176
+ path: pathMatch[1].replace(/\\/g, "/"),
177
+ content: contentMatch[1]
178
+ .replace(/\\n/g, "\n")
179
+ .replace(/\\r/g, "\r")
180
+ .replace(/\\t/g, "\t"),
168
181
  };
169
182
  }
170
183
 
171
184
  return null;
172
185
  },
173
- summariseInput: ({ path, content }) => `${path} (${content.length} bytes)`,
186
+ // null-safe parseInput can return null if AI sends malformed JSON
187
+ summariseInput: (input) =>
188
+ input ? `${input.path} (${input.content.length} bytes)` : "unknown file",
174
189
  execute: ({ path: filePath, content }, ctx) => ({
175
190
  kind: "text",
176
191
  value: writeFile(filePath, content, ctx.repoPath),