@ridit/lens 0.1.7 → 0.1.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.
@@ -107,6 +107,10 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
107
107
  const [showReview, setShowReview] = useState(false);
108
108
  const [autoApprove, setAutoApprove] = useState(false);
109
109
 
110
+ // Abort controller for the currently in-flight API call.
111
+ // Pressing ESC while thinking aborts the request and drops the response.
112
+ const abortControllerRef = useRef<AbortController | null>(null);
113
+
110
114
  // Cache of tool results within a single conversation turn to prevent
111
115
  // the model from re-calling tools it already ran with the same args
112
116
  const toolResultCache = useRef<Map<string, string>>(new Map());
@@ -131,6 +135,11 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
131
135
  };
132
136
 
133
137
  const handleError = (currentAll: Message[]) => (err: unknown) => {
138
+ // Silently drop aborted requests — user pressed ESC intentionally
139
+ if (err instanceof Error && err.name === "AbortError") {
140
+ setStage({ type: "idle" });
141
+ return;
142
+ }
134
143
  const errMsg: Message = {
135
144
  role: "assistant",
136
145
  content: `Error: ${err instanceof Error ? err.message : "Something went wrong"}`,
@@ -141,7 +150,17 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
141
150
  setStage({ type: "idle" });
142
151
  };
143
152
 
144
- const processResponse = (raw: string, currentAll: Message[]) => {
153
+ const processResponse = (
154
+ raw: string,
155
+ currentAll: Message[],
156
+ signal: AbortSignal,
157
+ ) => {
158
+ // If ESC was pressed before we got here, silently drop the response
159
+ if (signal.aborted) {
160
+ setStage({ type: "idle" });
161
+ return;
162
+ }
163
+
145
164
  const parsed = parseResponse(raw);
146
165
 
147
166
  if (parsed.kind === "changes") {
@@ -245,7 +264,6 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
245
264
  const executeAndContinue = async (approved: boolean) => {
246
265
  let result = "(denied by user)";
247
266
  if (approved) {
248
- // Build a cache key for idempotent read-only tools
249
267
  const cacheKey =
250
268
  parsed.kind === "read-file"
251
269
  ? `read-file:${parsed.filePath}`
@@ -256,7 +274,6 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
256
274
  : null;
257
275
 
258
276
  if (cacheKey && toolResultCache.current.has(cacheKey)) {
259
- // Return cached result with a note so the model stops retrying
260
277
  result =
261
278
  toolResultCache.current.get(cacheKey)! +
262
279
  "\n\n[NOTE: This result was already retrieved earlier. Do not request it again.]";
@@ -294,7 +311,6 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
294
311
  } else if (parsed.kind === "search") {
295
312
  result = await searchWeb(parsed.query);
296
313
  }
297
- // Store result in cache for cacheable tools
298
314
  if (cacheKey) {
299
315
  toolResultCache.current.set(cacheKey, result);
300
316
  }
@@ -402,9 +418,13 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
402
418
  setAllMessages(withTool);
403
419
  setCommitted((prev) => [...prev, toolMsg]);
404
420
 
421
+ // Create a fresh abort controller for the follow-up call
422
+ const nextAbort = new AbortController();
423
+ abortControllerRef.current = nextAbort;
424
+
405
425
  setStage({ type: "thinking" });
406
- callChat(provider!, systemPrompt, withTool)
407
- .then((r: string) => processResponse(r, withTool))
426
+ callChat(provider!, systemPrompt, withTool, nextAbort.signal)
427
+ .then((r: string) => processResponse(r, withTool, nextAbort.signal))
408
428
  .catch(handleError(withTool));
409
429
  };
410
430
 
@@ -510,15 +530,28 @@ export const ChatRunner = ({ repoPath }: { repoPath: string }) => {
510
530
  setCommitted((prev) => [...prev, userMsg]);
511
531
  setAllMessages(nextAll);
512
532
  toolResultCache.current.clear();
533
+
534
+ // Create a fresh abort controller for this request
535
+ const abort = new AbortController();
536
+ abortControllerRef.current = abort;
537
+
513
538
  setStage({ type: "thinking" });
514
- callChat(provider, systemPrompt, nextAll)
515
- .then((raw: string) => processResponse(raw, nextAll))
539
+ callChat(provider, systemPrompt, nextAll, abort.signal)
540
+ .then((raw: string) => processResponse(raw, nextAll, abort.signal))
516
541
  .catch(handleError(nextAll));
517
542
  };
518
543
 
519
544
  useInput((input, key) => {
520
545
  if (showTimeline) return;
521
546
 
547
+ // ESC while thinking → abort the in-flight request and go idle
548
+ if (stage.type === "thinking" && key.escape) {
549
+ abortControllerRef.current?.abort();
550
+ abortControllerRef.current = null;
551
+ setStage({ type: "idle" });
552
+ return;
553
+ }
554
+
522
555
  if (stage.type === "idle") {
523
556
  if (key.ctrl && input === "c") {
524
557
  process.exit(0);
@@ -844,6 +877,9 @@ Suggestions: ${lensFile.suggestions.slice(0, 3).join("; ")}`
844
877
  <Box gap={1}>
845
878
  <Text color={ACCENT}>●</Text>
846
879
  <TypewriterText text={thinkingPhrase} />
880
+ <Text color="gray" dimColor>
881
+ · esc cancel
882
+ </Text>
847
883
  </Box>
848
884
  )}
849
885
 
package/src/utils/chat.ts CHANGED
@@ -69,10 +69,10 @@ You have exactly eleven tools. To use a tool you MUST wrap it in the exact XML t
69
69
  ### 11. search — search the internet for anything you are unsure about
70
70
  <search>how to use React useEffect cleanup function</search>
71
71
 
72
- ### 11. clone — clone a GitHub repo so you can explore and discuss it
72
+ ### 12. clone — clone a GitHub repo so you can explore and discuss it
73
73
  <clone>https://github.com/owner/repo</clone>
74
74
 
75
- ### 12. changes — propose code edits (shown as a diff for user approval)
75
+ ### 13. changes — propose code edits (shown as a diff for user approval)
76
76
  <changes>
77
77
  {"summary": "what changed and why", "patches": [{"path": "src/foo.ts", "content": "COMPLETE file content", "isNew": false}]}
78
78
  </changes>
@@ -91,16 +91,19 @@ You have exactly eleven tools. To use a tool you MUST wrap it in the exact XML t
91
91
  10. shell is ONLY for running code, installing packages, building, testing — not for filesystem inspection
92
92
  11. write-file content field must be the COMPLETE file content, never empty or placeholder
93
93
  12. After a write-file succeeds, do NOT repeat it — trust the result and move on
94
- 13. After a write-file succeeds, use read-file to verify the content before telling the user it is done
94
+ 13. After a write-file succeeds, tell the user it is done immediately do NOT auto-read the file back to verify
95
95
  14. NEVER apologize and redo a tool call you already made — if write-file or shell ran and returned a result, it worked, do not run it again
96
96
  15. NEVER say "I made a mistake" and repeat the same tool — one attempt is enough, trust the output
97
97
  16. NEVER second-guess yourself mid-response — commit to your answer
98
98
  17. If a read-folder or read-file returns "not found", accept it and move on — do NOT retry the same path
99
99
  18. If you have already retrieved a result for a path in this conversation, do NOT request it again — use the result you already have
100
- 17. Every shell command runs from the repo root — \`cd\` has NO persistent effect. NEVER use \`cd\` alone. Use full paths or combine with && e.g. \`cd list && bun run index.ts\`
101
- 18. write-file paths are relative to the repo root — if creating files in a subfolder write the full relative path e.g. \`list/src/index.tsx\` NOT \`src/index.tsx\`
102
- 19. When scaffolding a new project in a subfolder, ALL write-file paths must start with that subfolder name e.g. \`list/package.json\`, \`list/src/index.tsx\`
103
- 20. For JSX/TSX files always use \`.tsx\` extension and include \`/** @jsxImportSource react */\` or ensure tsconfig has jsx set — bun needs this to parse JSX
100
+ 19. Every shell command runs from the repo root — \`cd\` has NO persistent effect. NEVER use \`cd\` alone. Use full paths or combine with && e.g. \`cd list && bun run index.ts\`
101
+ 20. write-file paths are relative to the repo root — if creating files in a subfolder write the full relative path e.g. \`list/src/index.tsx\` NOT \`src/index.tsx\`
102
+ 21. When scaffolding a new project in a subfolder, ALL write-file paths must start with that subfolder name e.g. \`list/package.json\`, \`list/src/index.tsx\`
103
+ 22. For JSX/TSX files always use \`.tsx\` extension and include \`/** @jsxImportSource react */\` or ensure tsconfig has jsx set — bun needs this to parse JSX
104
+ 23. When explaining how to use a tool in text, use [tag] bracket notation or a fenced code block — NEVER emit a real XML tool tag as part of an explanation or example
105
+ 24. NEVER chain tool calls unless the user's request explicitly requires multiple steps
106
+ 25. NEVER read files, list folders, or run tools that were not asked for in the current user message
104
107
 
105
108
  ## CRITICAL: READ BEFORE YOU WRITE
106
109
 
@@ -179,9 +182,8 @@ ${historySummary}`;
179
182
  }
180
183
 
181
184
  // ── Few-shot examples ─────────────────────────────────────────────────────────
182
-
183
185
  export const FEW_SHOT_MESSAGES: { role: string; content: string }[] = [
184
- // read-folder examples FIRST highest priority pattern to establish
186
+ // ── delete / open / pdf ───────────────────────────────────────────────────
185
187
  {
186
188
  role: "user",
187
189
  content: "delete src/old-component.tsx",
@@ -253,6 +255,8 @@ export const FEW_SHOT_MESSAGES: { role: string; content: string }[] = [
253
255
  role: "assistant",
254
256
  content: "Done — the PDF report has been saved to `docs/report.pdf`.",
255
257
  },
258
+
259
+ // ── grep ──────────────────────────────────────────────────────────────────
256
260
  {
257
261
  role: "user",
258
262
  content: 'grep -R "ChatRunner" -n src',
@@ -289,6 +293,28 @@ export const FEW_SHOT_MESSAGES: { role: string; content: string }[] = [
289
293
  content:
290
294
  "`useInput` is used in `src/components/chat/ChatRunner.tsx` — imported on line 5 and called on line 210.",
291
295
  },
296
+
297
+ // ── showing tool usage as an example (bracket notation, NOT real tags) ────
298
+ {
299
+ role: "user",
300
+ content: "show me how to use the read-file tool",
301
+ },
302
+ {
303
+ role: "assistant",
304
+ content:
305
+ "To read a file, emit the tag alone in your response — nothing else on that turn:\n\n```text\n[read-file]src/components/Header.tsx[/read-file]\n```\n\nThe result comes back as the next user message and you can then analyze it.",
306
+ },
307
+ {
308
+ role: "user",
309
+ content: "show me an example of the shell tool",
310
+ },
311
+ {
312
+ role: "assistant",
313
+ content:
314
+ "To run a shell command, emit only the tag:\n\n```text\n[shell]node -v[/shell]\n```\n\nThe stdout/stderr is returned and you can read it in the next turn.",
315
+ },
316
+
317
+ // ── read-folder ───────────────────────────────────────────────────────────
292
318
  {
293
319
  role: "user",
294
320
  content: "read src folder",
@@ -415,6 +441,8 @@ export const FEW_SHOT_MESSAGES: { role: string; content: string }[] = [
415
441
  content:
416
442
  "The repo root contains `package.json` and `README.md`, with source code in `src/` and compiled output in `dist/`.",
417
443
  },
444
+
445
+ // ── fetch → write → done (no auto-verify) ────────────────────────────────
418
446
  {
419
447
  role: "user",
420
448
  content: "fetch https://api.github.com/repos/microsoft/typescript",
@@ -449,17 +477,30 @@ export const FEW_SHOT_MESSAGES: { role: string; content: string }[] = [
449
477
  },
450
478
  {
451
479
  role: "assistant",
452
- content: "<read-file>ts-info.json</read-file>",
480
+ content: "Done — `ts-info.json` has been saved.",
453
481
  },
482
+
483
+ // ── write it to a file → done, nothing else ───────────────────────────────
454
484
  {
455
485
  role: "user",
486
+ content: "write it to a file called hello.py",
487
+ },
488
+ {
489
+ role: "assistant",
456
490
  content:
457
- 'Here is the output from read-file of ts-info.json:\n\nFile: ts-info.json (1 lines)\n\n{"name":"TypeScript","stars":100000}\n\nPlease continue your response based on this output.',
491
+ '<write-file>\n{"path": "hello.py", "content": "# hello.py\\nprint(\'hello\')"}\n</write-file>',
492
+ },
493
+ {
494
+ role: "user",
495
+ content:
496
+ "Here is the output from write-file to hello.py:\n\nWritten: /repo/hello.py (2 lines, 32 bytes)\n\nPlease continue your response based on this output.",
458
497
  },
459
498
  {
460
499
  role: "assistant",
461
- content: "Done — saved and verified `ts-info.json`. Data looks correct.",
500
+ content: "Done — `hello.py` has been written.",
462
501
  },
502
+
503
+ // ── read before write ─────────────────────────────────────────────────────
463
504
  {
464
505
  role: "user",
465
506
  content: "add a logout button to src/components/Header.tsx",
@@ -478,6 +519,8 @@ export const FEW_SHOT_MESSAGES: { role: string; content: string }[] = [
478
519
  content:
479
520
  '<changes>\n{"summary": "Add logout button to Header — preserves all existing nav items and imports", "patches": [{"path": "src/components/Header.tsx", "content": "// complete file with logout button added", "isNew": false}]}\n</changes>',
480
521
  },
522
+
523
+ // ── shell ─────────────────────────────────────────────────────────────────
481
524
  {
482
525
  role: "user",
483
526
  content: "what node version am I on",
@@ -495,6 +538,8 @@ export const FEW_SHOT_MESSAGES: { role: string; content: string }[] = [
495
538
  role: "assistant",
496
539
  content: "You're running Node.js v20.11.0.",
497
540
  },
541
+
542
+ // ── clone ─────────────────────────────────────────────────────────────────
498
543
  {
499
544
  role: "user",
500
545
  content: "clone https://github.com/facebook/react",
@@ -513,6 +558,8 @@ export const FEW_SHOT_MESSAGES: { role: string; content: string }[] = [
513
558
  content:
514
559
  "Cloned! The React repo has 2847 files. I can read source files, explain how it works, or suggest improvements — just ask.",
515
560
  },
561
+
562
+ // ── search ────────────────────────────────────────────────────────────────
516
563
  {
517
564
  role: "user",
518
565
  content: "what does the ?? operator do in typescript",
@@ -532,7 +579,6 @@ export const FEW_SHOT_MESSAGES: { role: string; content: string }[] = [
532
579
  "The `??` operator is the nullish coalescing operator. It returns the right side only when the left side is `null` or `undefined`.",
533
580
  },
534
581
  ];
535
-
536
582
  // ── Response parser ───────────────────────────────────────────────────────────
537
583
 
538
584
  export type ParsedResponse =
@@ -562,6 +608,10 @@ export type ParsedResponse =
562
608
  | { kind: "clone"; content: string; repoUrl: string };
563
609
 
564
610
  export function parseResponse(text: string): ParsedResponse {
611
+ // Strip fenced code blocks before scanning for tool tags so that tags shown
612
+ // as examples inside ``` ... ``` blocks are never executed.
613
+ const scanText = text.replace(/```[\s\S]*?```/g, (m) => " ".repeat(m.length));
614
+
565
615
  type Candidate = {
566
616
  index: number;
567
617
  kind:
@@ -610,15 +660,32 @@ export function parseResponse(text: string): ParsedResponse {
610
660
 
611
661
  for (const { kind, re } of patterns) {
612
662
  re.lastIndex = 0;
613
- const m = re.exec(text);
614
- if (m) candidates.push({ index: m.index, kind, match: m });
663
+ // Scan against the code-block-stripped text so tags inside ``` are ignored
664
+ const m = re.exec(scanText);
665
+ if (m) {
666
+ // Re-extract the match body from the original text at the same position
667
+ const originalRe = new RegExp(re.source, re.flags.replace("g", ""));
668
+ const originalMatch = originalRe.exec(text.slice(m.index));
669
+ if (originalMatch) {
670
+ // Reconstruct a match-like object with the correct index
671
+ const fakeMatch = Object.assign(
672
+ [
673
+ text.slice(m.index, m.index + originalMatch[0].length),
674
+ originalMatch[1],
675
+ ] as unknown as RegExpExecArray,
676
+ { index: m.index, input: text, groups: undefined },
677
+ );
678
+ candidates.push({ index: m.index, kind, match: fakeMatch });
679
+ }
680
+ }
615
681
  }
616
682
 
617
683
  if (candidates.length === 0) return { kind: "text", content: text.trim() };
618
684
 
619
685
  candidates.sort((a, b) => a.index - b.index);
620
686
  const { kind, match } = candidates[0]!;
621
- // Strip any leaked tool tags from preamble (e.g. model emits tag twice or mid-sentence)
687
+
688
+ // Strip any leaked tool tags from preamble text
622
689
  const before = text
623
690
  .slice(0, match.index)
624
691
  .replace(
@@ -626,7 +693,7 @@ export function parseResponse(text: string): ParsedResponse {
626
693
  "",
627
694
  )
628
695
  .trim();
629
- const body = match[1]!.trim();
696
+ const body = (match[1] ?? "").trim();
630
697
 
631
698
  if (kind === "changes") {
632
699
  try {
@@ -690,7 +757,6 @@ export function parseResponse(text: string): ParsedResponse {
690
757
  glob: parsed.glob ?? "**/*",
691
758
  };
692
759
  } catch {
693
- // treat body as plain pattern with no glob
694
760
  return { kind: "grep", content: before, pattern: body, glob: "**/*" };
695
761
  }
696
762
  }
@@ -788,6 +854,7 @@ export async function callChat(
788
854
  provider: Provider,
789
855
  systemPrompt: string,
790
856
  messages: Message[],
857
+ abortSignal?: AbortSignal,
791
858
  ): Promise<string> {
792
859
  const apiMessages = [...FEW_SHOT_MESSAGES, ...buildApiMessages(messages)];
793
860
 
@@ -825,6 +892,8 @@ export async function callChat(
825
892
  const controller = new AbortController();
826
893
  const timer = setTimeout(() => controller.abort(), 60_000);
827
894
 
895
+ abortSignal?.addEventListener("abort", () => controller.abort());
896
+
828
897
  const res = await fetch(url, {
829
898
  method: "POST",
830
899
  headers,
@@ -1251,7 +1320,7 @@ export function readFolder(folderPath: string, repoPath: string): string {
1251
1320
  const subfolders: string[] = [];
1252
1321
 
1253
1322
  for (const entry of entries) {
1254
- if (entry.startsWith(".") && entry !== ".env") continue; // skip hidden except .env hint
1323
+ if (entry.startsWith(".") && entry !== ".env") continue;
1255
1324
  const full = path.join(candidate, entry);
1256
1325
  try {
1257
1326
  if (statSync(full).isDirectory()) {
@@ -1297,29 +1366,22 @@ export function grepFiles(
1297
1366
  try {
1298
1367
  regex = new RegExp(pattern, "i");
1299
1368
  } catch {
1300
- // fall back to literal string match if pattern is not valid regex
1301
1369
  regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
1302
1370
  }
1303
1371
 
1304
- // Convert glob to a simple path prefix/suffix filter
1305
- // Supports patterns like: src/**/*.tsx, **/*.ts, src/utils/*
1306
1372
  const globToFilter = (g: string): ((rel: string) => boolean) => {
1307
- // strip leading **/
1308
1373
  const cleaned = g.replace(/^\*\*\//, "");
1309
1374
  const parts = cleaned.split("/");
1310
1375
  const ext = parts[parts.length - 1];
1311
1376
  const prefix = parts.slice(0, -1).join("/");
1312
1377
 
1313
1378
  return (rel: string) => {
1314
- // extension match (e.g. *.tsx)
1315
1379
  if (ext?.startsWith("*.")) {
1316
- const extSuffix = ext.slice(1); // e.g. .tsx
1380
+ const extSuffix = ext.slice(1);
1317
1381
  if (!rel.endsWith(extSuffix)) return false;
1318
1382
  } else if (ext && !ext.includes("*")) {
1319
- // exact filename
1320
1383
  if (!rel.endsWith(ext)) return false;
1321
1384
  }
1322
- // prefix match
1323
1385
  if (prefix && !prefix.includes("*")) {
1324
1386
  if (!rel.startsWith(prefix)) return false;
1325
1387
  }
@@ -1452,7 +1514,6 @@ export function generatePdf(
1452
1514
  const dir = path.dirname(fullPath);
1453
1515
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1454
1516
 
1455
- // Escape content for embedding in a Python string literal
1456
1517
  const escaped = content
1457
1518
  .replace(/\\/g, "\\\\")
1458
1519
  .replace(/"""/g, '\\"\\"\\"')
@@ -1512,7 +1573,6 @@ for line in raw.split("\\n"):
1512
1573
  elif s == "":
1513
1574
  story.append(Spacer(1, 6))
1514
1575
  else:
1515
- # handle **bold** inline
1516
1576
  import re
1517
1577
  s = re.sub(r"\\*\\*(.+?)\\*\\*", r"<b>\\1</b>", s)
1518
1578
  s = re.sub(r"\\*(.+?)\\*", r"<i>\\1</i>", s)
package/skills.json DELETED
@@ -1,7 +0,0 @@
1
- [
2
- {
3
- "id": "1773310757090-o6sri",
4
- "name": "this is a skill",
5
- "description": "this is a skill"
6
- }
7
- ]