@ridit/lens 0.3.6 → 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.
@@ -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,15 +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): WriteFileInput | null => {
140
+ try {
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
+ };
151
+ } catch {
152
+ return null;
153
+ }
154
+ };
155
+
156
+ // attempt 1: parse as-is
157
+ const first = tryParse(body.trim());
158
+ if (first) return first;
159
+
160
+ // attempt 2: escape unescaped control characters
139
161
  try {
140
- const parsed = JSON.parse(body) as { path: string; content: string };
141
- if (!parsed.path) return null;
142
- return { ...parsed, path: parsed.path.replace(/\\/g, "/") };
143
- } catch {
144
- return null;
162
+ const sanitized = body
163
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "")
164
+ .replace(/(?<!\\)\n/g, "\\n")
165
+ .replace(/(?<!\\)\r/g, "\\r")
166
+ .replace(/(?<!\\)\t/g, "\\t");
167
+ const second = tryParse(sanitized);
168
+ if (second) return second;
169
+ } catch {}
170
+
171
+ // attempt 3: regex extraction as last resort
172
+ const pathMatch = body.match(/"path"\s*:\s*"([^"]+)"/);
173
+ const contentMatch = body.match(/"content"\s*:\s*"([\s\S]*?)"\s*}?\s*$/);
174
+ if (pathMatch?.[1] && contentMatch?.[1] !== undefined) {
175
+ return {
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"),
181
+ };
145
182
  }
183
+
184
+ return null;
146
185
  },
147
- 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",
148
189
  execute: ({ path: filePath, content }, ctx) => ({
149
190
  kind: "text",
150
191
  value: writeFile(filePath, content, ctx.repoPath),