@ridit/lens 0.3.5 → 0.3.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ridit/lens",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Know Your Codebase.",
5
5
  "author": "Ridit Jangra <riditjangra09@gmail.com> (https://ridit.space)",
6
6
  "license": "MIT",
@@ -19,7 +19,7 @@
19
19
  "prepublishOnly": "npm run build && npm run tag"
20
20
  },
21
21
  "dependencies": {
22
- "@ridit/lens-sdk": "0.1.6",
22
+ "@ridit/lens-sdk": "^0.2.0",
23
23
  "asciichart": "^1.5.25",
24
24
  "bun": "^1.3.11",
25
25
  "commander": "^14.0.3",
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
- import { Box, Static, Text } from "ink";
2
+ import { Box, Static, Text, useStdout } from "ink";
3
3
  import Spinner from "ink-spinner";
4
- import TextInput from "ink-text-input";
4
+ import { TextArea } from "./TextArea";
5
5
  import { ACCENT, GREEN, RED } from "../../colors";
6
6
  import { DiffViewer } from "../repo/DiffViewer";
7
7
  import { StaticMessage } from "./ChatMessage";
@@ -134,25 +134,24 @@ export function InputBox({
134
134
  onSubmit: (v: string) => void;
135
135
  inputKey?: number;
136
136
  }) {
137
+ const { stdout } = useStdout();
138
+ const cols = stdout?.columns ?? 80;
139
+ const rule = "─".repeat(Math.max(1, cols));
140
+
137
141
  return (
138
- <Box
139
- marginTop={1}
140
- borderBottom
141
- borderTop
142
- borderRight={false}
143
- borderLeft={false}
144
- borderColor={"gray"}
145
- borderStyle="single"
146
- >
142
+ <Box flexDirection="column" marginTop={1}>
143
+ <Text color="gray" dimColor>{rule}</Text>
147
144
  <Box gap={1}>
148
145
  <Text color={ACCENT}>{">"}</Text>
149
- <TextInput
146
+ <TextArea
150
147
  key={inputKey}
151
148
  value={value}
152
149
  onChange={onChange}
153
150
  onSubmit={onSubmit}
151
+ placeholder="ask anything..."
154
152
  />
155
153
  </Box>
154
+ <Text color="gray" dimColor>{rule}</Text>
156
155
  </Box>
157
156
  );
158
157
  }
@@ -196,7 +195,7 @@ export function ShortcutBar({
196
195
  return (
197
196
  <Box gap={3} marginTop={0}>
198
197
  <Text color="gray" dimColor>
199
- enter send · ^v paste · ^c exit
198
+ enter send · alt+enter newline · ^w del word · ^c exit
200
199
  </Text>
201
200
  {forceApprove ? (
202
201
  <Text color={RED}>⚡⚡ force-all</Text>
@@ -4,7 +4,7 @@ import Spinner from "ink-spinner";
4
4
  import { useState } from "react";
5
5
  import path from "path";
6
6
  import os from "os";
7
- import TextInput from "ink-text-input";
7
+ import { TextArea } from "./TextArea";
8
8
  import { ACCENT } from "../../colors";
9
9
  import { ProviderPicker } from "../provider/ProviderPicker";
10
10
  import { startCloneRepo } from "../../utils/repo";
@@ -136,7 +136,7 @@ function ForceAllWarning({
136
136
  esc
137
137
  </Text>
138
138
  <Text color="gray"> to cancel: </Text>
139
- <TextInput
139
+ <TextArea
140
140
  value={input}
141
141
  onChange={setInput}
142
142
  onSubmit={(v) => onConfirm(v.trim().toLowerCase() === "yes")}
@@ -0,0 +1,176 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Text, useInput } from "ink";
3
+ import chalk from "chalk";
4
+
5
+ function isWordChar(ch: string): boolean {
6
+ return /[\w]/.test(ch);
7
+ }
8
+
9
+ function wordBoundaryLeft(text: string, pos: number): number {
10
+ if (pos <= 0) return 0;
11
+ let i = pos - 1;
12
+
13
+ while (i > 0 && !isWordChar(text[i]!)) i--;
14
+
15
+ while (i > 0 && isWordChar(text[i - 1]!)) i--;
16
+ return i;
17
+ }
18
+
19
+ function wordBoundaryRight(text: string, pos: number): number {
20
+ const len = text.length;
21
+ if (pos >= len) return len;
22
+ let i = pos;
23
+
24
+ while (i < len && isWordChar(text[i]!)) i++;
25
+
26
+ while (i < len && !isWordChar(text[i]!)) i++;
27
+ return i;
28
+ }
29
+
30
+ export interface TextAreaProps {
31
+ value: string;
32
+ onChange: (value: string) => void;
33
+ onSubmit: (value: string) => void;
34
+ focus?: boolean;
35
+ placeholder?: string;
36
+ }
37
+
38
+ export function TextArea({
39
+ value,
40
+ onChange,
41
+ onSubmit,
42
+ focus = true,
43
+ placeholder = "",
44
+ }: TextAreaProps) {
45
+ const [cursor, setCursor] = useState(value.length);
46
+ const [prevValue, setPrevValue] = useState(value);
47
+
48
+ if (value !== prevValue) {
49
+ setPrevValue(value);
50
+ const lenDiff = Math.abs(value.length - prevValue.length);
51
+ if (cursor > value.length || lenDiff > 1) {
52
+ setCursor(value.length);
53
+ }
54
+ }
55
+
56
+ useInput(
57
+ (input, key) => {
58
+ if (key.upArrow || key.downArrow) return;
59
+ if (key.tab || (key.shift && key.tab)) return;
60
+ if (key.ctrl && input === "c") return;
61
+
62
+ if (key.return && !key.meta) {
63
+ onSubmit(value);
64
+ return;
65
+ }
66
+
67
+ if ((key.return && key.meta) || (key.ctrl && input === "j")) {
68
+ const next = value.slice(0, cursor) + "\n" + value.slice(cursor);
69
+ onChange(next);
70
+ setCursor((c) => c + 1);
71
+ return;
72
+ }
73
+
74
+ if (key.leftArrow && key.ctrl) {
75
+ setCursor(wordBoundaryLeft(value, cursor));
76
+ return;
77
+ }
78
+
79
+ if (key.rightArrow && key.ctrl) {
80
+ setCursor(wordBoundaryRight(value, cursor));
81
+ return;
82
+ }
83
+
84
+ if (key.leftArrow) {
85
+ setCursor((c) => Math.max(0, c - 1));
86
+ return;
87
+ }
88
+
89
+ if (key.rightArrow) {
90
+ setCursor((c) => Math.min(value.length, c + 1));
91
+ return;
92
+ }
93
+
94
+ if (key.ctrl && input === "a") {
95
+ const lineStart = value.lastIndexOf("\n", cursor - 1) + 1;
96
+ setCursor(lineStart);
97
+ return;
98
+ }
99
+
100
+ if (key.ctrl && input === "e") {
101
+ const lineEnd = value.indexOf("\n", cursor);
102
+ setCursor(lineEnd === -1 ? value.length : lineEnd);
103
+ return;
104
+ }
105
+
106
+ if (key.ctrl && input === "u") {
107
+ const lineStart = value.lastIndexOf("\n", cursor - 1) + 1;
108
+ onChange(value.slice(0, lineStart) + value.slice(cursor));
109
+ setCursor(lineStart);
110
+ return;
111
+ }
112
+
113
+ if (key.ctrl && input === "k") {
114
+ const lineEnd = value.indexOf("\n", cursor);
115
+ onChange(
116
+ value.slice(0, cursor) + (lineEnd === -1 ? "" : value.slice(lineEnd)),
117
+ );
118
+ return;
119
+ }
120
+
121
+ if (key.ctrl && input === "w") {
122
+ const to = wordBoundaryLeft(value, cursor);
123
+ onChange(value.slice(0, to) + value.slice(cursor));
124
+ setCursor(to);
125
+ return;
126
+ }
127
+
128
+ if (key.ctrl && key.delete) {
129
+ const to = wordBoundaryRight(value, cursor);
130
+ onChange(value.slice(0, cursor) + value.slice(to));
131
+ return;
132
+ }
133
+
134
+ if (key.backspace || key.delete) {
135
+ if (cursor > 0) {
136
+ onChange(value.slice(0, cursor - 1) + value.slice(cursor));
137
+ setCursor((c) => c - 1);
138
+ }
139
+ return;
140
+ }
141
+
142
+ if (key.escape) return;
143
+
144
+ if (input) {
145
+ const next = value.slice(0, cursor) + input + value.slice(cursor);
146
+ onChange(next);
147
+ setCursor((c) => c + input.length);
148
+ }
149
+ },
150
+ { isActive: focus },
151
+ );
152
+
153
+ if (value.length === 0 && placeholder) {
154
+ return (
155
+ <Text>
156
+ {chalk.inverse(placeholder[0] ?? " ")}
157
+ {placeholder.length > 1 ? chalk.gray(placeholder.slice(1)) : ""}
158
+ </Text>
159
+ );
160
+ }
161
+
162
+ let rendered = "";
163
+ for (let i = 0; i < value.length; i++) {
164
+ const ch = value[i]!;
165
+ if (i === cursor) {
166
+ rendered += ch === "\n" ? chalk.inverse(" ") + "\n" : chalk.inverse(ch);
167
+ } else {
168
+ rendered += ch;
169
+ }
170
+ }
171
+ if (cursor === value.length) {
172
+ rendered += chalk.inverse(" ");
173
+ }
174
+
175
+ return <Text>{rendered}</Text>;
176
+ }
@@ -1,6 +1,7 @@
1
1
  import { useState, useRef } from "react";
2
2
  import React from "react";
3
3
  import type { Provider } from "../../../types/config";
4
+ import { classifyIntent } from "../../../utils/intentClassifier";
4
5
  import type { Message, ChatStage } from "../../../types/chat";
5
6
  import {
6
7
  saveChat,
@@ -12,6 +13,8 @@ import {
12
13
  buildMemorySummary,
13
14
  addMemory,
14
15
  deleteMemory,
16
+ getSessionToolSummary,
17
+ logToolCall,
15
18
  } from "../../../utils/memory";
16
19
  import { fetchFileTree, readImportantFiles } from "../../../utils/files";
17
20
  import { readLensFile } from "../../../utils/lensfile";
@@ -25,6 +28,7 @@ import {
25
28
  buildSystemPrompt,
26
29
  parseResponse,
27
30
  callChat,
31
+ type ChatResult,
28
32
  } from "../../../utils/chat";
29
33
 
30
34
  export function useChat(repoPath: string) {
@@ -89,32 +93,26 @@ export function useChat(repoPath: string) {
89
93
  setStage({ type: "idle" });
90
94
  };
91
95
 
92
- const TOOL_TAG_NAMES = [
93
- "shell",
94
- "fetch",
95
- "read-file",
96
- "read-folder",
97
- "grep",
98
- "write-file",
99
- "delete-file",
100
- "delete-folder",
101
- "open-url",
102
- "generate-pdf",
103
- "search",
104
- "clone",
105
- "changes",
106
- ];
96
+ const MAX_AUTO_CONTINUES = 3;
107
97
 
108
98
  function isLikelyTruncated(text: string): boolean {
109
- return TOOL_TAG_NAMES.some(
110
- (tag) => text.includes(`<${tag}>`) && !text.includes(`</${tag}>`),
111
- );
99
+ // Check unclosed XML tool tags (dynamic — includes addon tools)
100
+ for (const tag of registry.names()) {
101
+ if (text.includes(`<${tag}>`) && !text.includes(`</${tag}>`))
102
+ return true;
103
+ }
104
+ // Check unclosed fenced code blocks (```tool\n... without closing ```)
105
+ const fences = text.match(/```/g);
106
+ if (fences && fences.length % 2 !== 0) return true;
107
+ return false;
112
108
  }
113
109
 
114
110
  const processResponse = (
115
111
  raw: string,
116
112
  currentAll: Message[],
117
113
  signal: AbortSignal,
114
+ truncated = false,
115
+ continueCount = 0,
118
116
  ) => {
119
117
  if (signal.aborted) {
120
118
  batchApprovedRef.current = false;
@@ -122,16 +120,73 @@ export function useChat(repoPath: string) {
122
120
  return;
123
121
  }
124
122
 
125
- if (isLikelyTruncated(raw)) {
126
- const truncMsg: Message = {
123
+ if (truncated || isLikelyTruncated(raw)) {
124
+ if (continueCount >= MAX_AUTO_CONTINUES) {
125
+ // Give up after max attempts — show whatever we have
126
+ batchApprovedRef.current = false;
127
+ const msg: Message = {
128
+ role: "assistant",
129
+ content:
130
+ raw.trim() ||
131
+ "(response was empty after multiple continuation attempts)",
132
+ type: "text",
133
+ };
134
+ setAllMessages([...currentAll, msg]);
135
+ setCommitted((prev) => [...prev, msg]);
136
+ setStage({ type: "idle" });
137
+ return;
138
+ }
139
+
140
+ // Include the partial response so the model knows where it left off
141
+ const partialMsg: Message = {
127
142
  role: "assistant",
143
+ content: raw,
144
+ type: "text",
145
+ };
146
+ const nudgeMsg: Message = {
147
+ role: "user",
128
148
  content:
129
- "(response cut off — the model hit its output limit mid-tool-call. Try asking it to continue, or simplify the request.)",
149
+ "Your response was cut off. Please continue exactly from where you left off.",
150
+ type: "text",
151
+ };
152
+ const withContext = [...currentAll, partialMsg, nudgeMsg];
153
+
154
+ const truncMsg: Message = {
155
+ role: "assistant",
156
+ content: `(response cut off — auto-continuing ${continueCount + 1}/${MAX_AUTO_CONTINUES}…)`,
130
157
  type: "text",
131
158
  };
132
159
  setAllMessages([...currentAll, truncMsg]);
133
160
  setCommitted((prev) => [...prev, truncMsg]);
134
- setStage({ type: "idle" });
161
+
162
+ const currentProvider = providerRef.current;
163
+ const currentSystemPrompt = systemPromptRef.current;
164
+
165
+ if (!currentProvider) {
166
+ setStage({ type: "idle" });
167
+ return;
168
+ }
169
+
170
+ const nextAbort = new AbortController();
171
+ abortControllerRef.current = nextAbort;
172
+ setStage({ type: "thinking" });
173
+ callChat(
174
+ currentProvider,
175
+ currentSystemPrompt,
176
+ withContext,
177
+ nextAbort.signal,
178
+ )
179
+ .then((result: ChatResult) => {
180
+ if (nextAbort.signal.aborted) return;
181
+ processResponse(
182
+ result.text ?? "",
183
+ withContext,
184
+ nextAbort.signal,
185
+ result.truncated,
186
+ continueCount + 1,
187
+ );
188
+ })
189
+ .catch(handleError(withContext));
135
190
  return;
136
191
  }
137
192
 
@@ -312,13 +367,14 @@ export function useChat(repoPath: string) {
312
367
  }
313
368
 
314
369
  if (approved && !result.startsWith("Error:")) {
315
- appendMemory({
316
- kind: "shell-run",
317
- detail: tool.summariseInput
370
+ logToolCall(
371
+ parsed.toolName,
372
+ tool.summariseInput
318
373
  ? String(tool.summariseInput(parsed.input))
319
374
  : parsed.rawInput,
320
- summary: result.split("\n")[0]?.slice(0, 120) ?? "",
321
- });
375
+ result,
376
+ repoPath,
377
+ );
322
378
  }
323
379
 
324
380
  const displayContent = tool.summariseInput
@@ -339,7 +395,7 @@ export function useChat(repoPath: string) {
339
395
  setCommitted((prev) => [...prev, toolMsg]);
340
396
 
341
397
  if (approved && remainder && remainder.length > 0) {
342
- processResponse(remainder, withTool, signal);
398
+ processResponse(remainder, withTool, signal, truncated, continueCount);
343
399
  return;
344
400
  }
345
401
 
@@ -349,31 +405,55 @@ export function useChat(repoPath: string) {
349
405
  abortControllerRef.current = nextAbort;
350
406
  setStage({ type: "thinking" });
351
407
 
352
- callChat(currentProvider, currentSystemPrompt, withTool, nextAbort.signal)
353
- .then((r: string) => {
354
- if (nextAbort.signal.aborted) return;
355
- if (!r.trim()) {
356
- const nudged: Message[] = [
357
- ...withTool,
358
- { role: "user", content: "Please continue.", type: "text" },
359
- ];
360
- return callChat(
361
- currentProvider,
362
- currentSystemPrompt,
363
- nudged,
364
- nextAbort.signal,
365
- );
366
- }
367
- return r;
368
- })
369
- .then((r: string | undefined) => {
408
+ const callWithAutoContinue = async (
409
+ messages: Message[],
410
+ maxRetries = 3,
411
+ ): Promise<ChatResult> => {
412
+ let currentMessages = messages;
413
+ for (let i = 0; i < maxRetries; i++) {
414
+ if (nextAbort.signal.aborted)
415
+ return { text: "", truncated: false };
416
+ const result = await callChat(
417
+ currentProvider,
418
+ currentSystemPrompt,
419
+ currentMessages,
420
+ nextAbort.signal,
421
+ );
422
+ if (result.text.trim()) return result;
423
+ const nudgeMsg: Message = {
424
+ role: "assistant",
425
+ content: `(model stalled auto-continuing, attempt ${i + 1}/${maxRetries})`,
426
+ type: "text",
427
+ };
428
+ setCommitted((prev) => [...prev, nudgeMsg]);
429
+ setAllMessages((prev) => [...prev, nudgeMsg]);
430
+ currentMessages = [
431
+ ...currentMessages,
432
+ {
433
+ role: "user",
434
+ content:
435
+ "Please continue. Provide your response to the previous tool output.",
436
+ type: "text",
437
+ },
438
+ ];
439
+ }
440
+ return { text: "", truncated: false };
441
+ };
442
+
443
+ callWithAutoContinue(withTool)
444
+ .then((result: ChatResult) => {
370
445
  if (nextAbort.signal.aborted) return;
371
- processResponse(r ?? "", withTool, nextAbort.signal);
446
+ processResponse(
447
+ result.text ?? "",
448
+ withTool,
449
+ nextAbort.signal,
450
+ result.truncated,
451
+ );
372
452
  })
373
453
  .catch(handleError(withTool));
374
454
  };
375
455
 
376
- if (forceApprove || (autoApprove && isSafe) || batchApprovedRef.current) {
456
+ if (forceApprove || isSafe || batchApprovedRef.current) {
377
457
  executeAndContinue(true);
378
458
  return;
379
459
  }
@@ -421,9 +501,27 @@ export function useChat(repoPath: string) {
421
501
  const abort = new AbortController();
422
502
  abortControllerRef.current = abort;
423
503
 
504
+ const intent = classifyIntent(text);
505
+ const scopedToolsSection = registry.buildSystemPromptSection(intent);
506
+ const sessionSummary = getSessionToolSummary(repoPath);
507
+
508
+ let scopedSystemPrompt = currentSystemPrompt.replace(
509
+ /## TOOLS[\s\S]*?(?=\n## (?!TOOLS))/,
510
+ scopedToolsSection + "\n\n",
511
+ );
512
+
513
+ if (sessionSummary) {
514
+ scopedSystemPrompt = scopedSystemPrompt.replace(
515
+ /## CODEBASE/,
516
+ sessionSummary + "\n\n## CODEBASE",
517
+ );
518
+ }
519
+
424
520
  setStage({ type: "thinking" });
425
- callChat(currentProvider, currentSystemPrompt, nextAll, abort.signal)
426
- .then((raw: string) => processResponse(raw, nextAll, abort.signal))
521
+ callChat(currentProvider, scopedSystemPrompt, nextAll, abort.signal)
522
+ .then((result: ChatResult) =>
523
+ processResponse(result.text, nextAll, abort.signal, result.truncated),
524
+ )
427
525
  .catch(handleError(nextAll));
428
526
  };
429
527
 
@@ -468,22 +566,24 @@ export function useChat(repoPath: string) {
468
566
  const applyPatchesAndContinue = (patches: any[]) => {
469
567
  try {
470
568
  applyPatches(repoPath, patches);
471
- appendMemory({
472
- kind: "code-applied",
473
- detail: patches.map((p) => p.path).join(", "),
474
- summary: `Applied changes to ${patches.length} file(s)`,
475
- });
569
+ logToolCall(
570
+ "changes",
571
+ patches.map((p) => p.path).join(", "),
572
+ `Applied changes to ${patches.length} file(s)`,
573
+ repoPath,
574
+ );
476
575
  } catch {
477
576
  /* non-fatal */
478
577
  }
479
578
  };
480
579
 
481
580
  const skipPatches = (patches: any[]) => {
482
- appendMemory({
483
- kind: "code-skipped",
484
- detail: patches.map((p: { path: string }) => p.path).join(", "),
485
- summary: `Skipped changes to ${patches.length} file(s)`,
486
- });
581
+ logToolCall(
582
+ "changes-skipped",
583
+ patches.map((p: { path: string }) => p.path).join(", "),
584
+ `Skipped changes to ${patches.length} file(s)`,
585
+ repoPath,
586
+ );
487
587
  };
488
588
 
489
589
  return {
@@ -174,11 +174,11 @@ function CodebaseQA({
174
174
  abortRef.current = abort;
175
175
 
176
176
  callChat(provider, systemPrompt, nextAll, abort.signal)
177
- .then((answer) => {
177
+ .then((result) => {
178
178
  const assistantMsg: Message = {
179
179
  role: "assistant",
180
180
  type: "text",
181
- content: answer,
181
+ content: result.text,
182
182
  };
183
183
  setCommitted((prev) => [...prev, assistantMsg]);
184
184
  setAllMessages([...nextAll, assistantMsg]);
@@ -777,9 +777,9 @@ ${summarizeTimeline(commits)}`;
777
777
 
778
778
  const runChat = async (history: Message[], signal: AbortSignal) => {
779
779
  try {
780
- const raw = await callChat(provider, systemPrompt, history, signal);
780
+ const result = await callChat(provider, systemPrompt, history, signal);
781
781
  if (signal.aborted) return;
782
- processResponse(raw, history, signal);
782
+ processResponse(result.text, history, signal);
783
783
  } catch (e: any) {
784
784
  if (e?.name === "AbortError") return;
785
785
  setMessages((prev) => [
@@ -602,12 +602,13 @@ ${lensFile.suggestions.length > 0 ? `\nProject suggestions:\n${lensFile.suggesti
602
602
 
603
603
  let raw: string;
604
604
  try {
605
- raw = await callChat(
605
+ const result = await callChat(
606
606
  provider,
607
607
  systemPromptRef.current,
608
608
  messages,
609
609
  combinedSignal,
610
610
  );
611
+ raw = result.text;
611
612
  } finally {
612
613
  clearTimeout(timeoutId);
613
614
  }
@@ -231,4 +231,22 @@ export const FEW_SHOT_MESSAGES: { role: string; content: string }[] = [
231
231
  content:
232
232
  "Done — addons/hello-world.js created using defineTool from @ridit/lens-sdk.",
233
233
  },
234
+ {
235
+ role: "user",
236
+ content: "I ran the app and got this error:\n[ERROR] slice(None, 2, None)",
237
+ },
238
+ {
239
+ role: "assistant",
240
+ content: "<read-file>webfetch/parser.py</read-file>",
241
+ },
242
+ {
243
+ role: "user",
244
+ content:
245
+ "Here is the output from read-file of webfetch/parser.py:\n\n# file content here\n\nPlease continue your response based on this output.",
246
+ },
247
+ {
248
+ role: "assistant",
249
+ content:
250
+ '<write-file>\n{"path": "webfetch/parser.py", "content": "...complete fixed content..."}\n</write-file>',
251
+ },
234
252
  ];