@ridit/lens 0.2.2 → 0.2.5

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.
@@ -0,0 +1,335 @@
1
+ // ── tools/view-image.ts ───────────────────────────────────────────────────────
2
+ //
3
+ // Display images in the terminal using the best available protocol:
4
+ //
5
+ // 1. iTerm2 inline image protocol (OSC 1337)
6
+ // Supported by: iTerm2, WezTerm, VSCode integrated terminal,
7
+ // Windows Terminal (via some builds), Tabby, Hyper
8
+ //
9
+ // 2. Kitty graphics protocol
10
+ // Supported by: Kitty, WezTerm
11
+ //
12
+ // 3. Sixel
13
+ // Supported by: Windows Terminal 1.22+, xterm, mlterm, WezTerm
14
+ // Requires: chafa or img2sixel on PATH (auto-detected)
15
+ //
16
+ // 4. Half-block fallback via terminal-image
17
+ // Works everywhere that supports 256 colors.
18
+ // Quality is limited to block characters (▄▀) — better than nothing.
19
+ //
20
+ // Protocol is auto-detected from environment variables and terminal responses.
21
+ // Can be overridden by passing "protocol" in the JSON input.
22
+
23
+ import path from "path";
24
+ import { existsSync, readFileSync } from "fs";
25
+ import type { Tool } from "@ridit/lens-sdk";
26
+ import { execSync } from "child_process";
27
+
28
+ // ── input ─────────────────────────────────────────────────────────────────────
29
+
30
+ type Protocol = "iterm2" | "kitty" | "sixel" | "halfblock" | "auto";
31
+
32
+ interface ViewImageInput {
33
+ /** File path (repo-relative or absolute) or URL */
34
+ src: string;
35
+ /** Width: percentage "50%" or column count 40. Default "80%" */
36
+ width?: string | number;
37
+ /** Force a specific protocol instead of auto-detecting */
38
+ protocol?: Protocol;
39
+ }
40
+
41
+ function parseViewImageInput(body: string): ViewImageInput | null {
42
+ const trimmed = body.trim();
43
+ if (!trimmed) return null;
44
+
45
+ if (trimmed.startsWith("{")) {
46
+ try {
47
+ const p = JSON.parse(trimmed) as {
48
+ src?: string;
49
+ path?: string;
50
+ url?: string;
51
+ width?: string | number;
52
+ protocol?: Protocol;
53
+ };
54
+ const src = p.src ?? p.path ?? p.url ?? "";
55
+ if (!src) return null;
56
+ return { src, width: p.width ?? "80%", protocol: p.protocol ?? "auto" };
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ return { src: trimmed, width: "80%", protocol: "auto" };
63
+ }
64
+
65
+ // ── fetch image bytes ─────────────────────────────────────────────────────────
66
+
67
+ async function fetchBytes(src: string, repoPath: string): Promise<Buffer> {
68
+ if (src.startsWith("http://") || src.startsWith("https://")) {
69
+ const res = await fetch(src, { signal: AbortSignal.timeout(20_000) });
70
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
71
+ return Buffer.from(await res.arrayBuffer());
72
+ }
73
+ const resolved = path.isAbsolute(src) ? src : path.join(repoPath, src);
74
+ if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
75
+ return readFileSync(resolved);
76
+ }
77
+
78
+ // ── protocol detection ────────────────────────────────────────────────────────
79
+
80
+ function detectProtocol(): Exclude<Protocol, "auto"> {
81
+ const term = (process.env["TERM"] ?? "").toLowerCase();
82
+ const termProgram = (process.env["TERM_PROGRAM"] ?? "").toLowerCase();
83
+ const termEmulator = (process.env["TERM_EMULATOR"] ?? "").toLowerCase();
84
+
85
+ // Kitty
86
+ if (term === "xterm-kitty" || process.env["KITTY_WINDOW_ID"]) {
87
+ return "kitty";
88
+ }
89
+
90
+ // iTerm2
91
+ if (
92
+ termProgram === "iterm.app" ||
93
+ termProgram === "iterm2" ||
94
+ process.env["ITERM_SESSION_ID"]
95
+ ) {
96
+ return "iterm2";
97
+ }
98
+
99
+ // WezTerm — supports both kitty and iterm2; prefer kitty
100
+ if (termProgram === "wezterm" || process.env["WEZTERM_EXECUTABLE"]) {
101
+ return "kitty";
102
+ }
103
+
104
+ // VSCode integrated terminal — supports iTerm2 protocol
105
+ if (
106
+ process.env["TERM_PROGRAM"] === "vscode" ||
107
+ process.env["VSCODE_INJECTION"] ||
108
+ process.env["VSCODE_GIT_ASKPASS_NODE"]
109
+ ) {
110
+ return "iterm2";
111
+ }
112
+
113
+ // Windows Terminal 1.22+ supports Sixel
114
+ // WT_SESSION is set in Windows Terminal
115
+ if (process.env["WT_SESSION"]) {
116
+ // Check if chafa or img2sixel is available for sixel encoding
117
+ if (commandExists("chafa") || commandExists("img2sixel")) {
118
+ return "sixel";
119
+ }
120
+ // Fall through to halfblock — Windows Terminal also does colors fine
121
+ }
122
+
123
+ // Tabby / Hyper typically support iTerm2
124
+ if (termEmulator.includes("tabby") || termEmulator.includes("hyper")) {
125
+ return "iterm2";
126
+ }
127
+
128
+ // xterm with sixel support
129
+ if (term.includes("xterm") && commandExists("img2sixel")) {
130
+ return "sixel";
131
+ }
132
+
133
+ return "halfblock";
134
+ }
135
+
136
+ function commandExists(cmd: string): boolean {
137
+ try {
138
+ execSync(process.platform === "win32" ? `where ${cmd}` : `which ${cmd}`, {
139
+ stdio: "pipe",
140
+ timeout: 2000,
141
+ });
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ // ── renderers ─────────────────────────────────────────────────────────────────
149
+
150
+ // iTerm2 inline image protocol (OSC 1337)
151
+ function renderITerm2(buf: Buffer, width: string | number): string {
152
+ const b64 = buf.toString("base64");
153
+ const widthParam =
154
+ typeof width === "number" ? `width=${width}` : `width=${width}`;
155
+ // ESC ] 1337 ; File=inline=1;width=...: <base64> BEL
156
+ return `\x1b]1337;File=inline=1;${widthParam};preserveAspectRatio=1:${b64}\x07\n`;
157
+ }
158
+
159
+ // Kitty graphics protocol (APC, chunked base64)
160
+ function renderKitty(buf: Buffer, width: string | number): string {
161
+ const b64 = buf.toString("base64");
162
+ const chunkSize = 4096;
163
+ const chunks: string[] = [];
164
+ for (let i = 0; i < b64.length; i += chunkSize) {
165
+ chunks.push(b64.slice(i, i + chunkSize));
166
+ }
167
+
168
+ const cols =
169
+ typeof width === "number"
170
+ ? width
171
+ : width.endsWith("%")
172
+ ? Math.floor((process.stdout.columns ?? 80) * (parseInt(width) / 100))
173
+ : parseInt(width);
174
+
175
+ let result = "";
176
+ chunks.forEach((chunk, idx) => {
177
+ const isLast = idx === chunks.length - 1;
178
+ const more = isLast ? 0 : 1;
179
+ if (idx === 0) {
180
+ // First chunk: f=100 (PNG), a=T (transmit+display), c=columns
181
+ result += `\x1b_Ga=T,f=100,m=${more},c=${cols};${chunk}\x1b\\`;
182
+ } else {
183
+ result += `\x1b_Gm=${more};${chunk}\x1b\\`;
184
+ }
185
+ });
186
+ return result + "\n";
187
+ }
188
+
189
+ // Sixel via chafa (preferred, auto-detects format) or img2sixel
190
+ function renderSixel(
191
+ buf: Buffer,
192
+ src: string,
193
+ repoPath: string,
194
+ width: string | number,
195
+ ): string {
196
+ const widthCols =
197
+ typeof width === "number"
198
+ ? width
199
+ : width.endsWith("%")
200
+ ? Math.floor((process.stdout.columns ?? 80) * (parseInt(width) / 100))
201
+ : parseInt(width);
202
+
203
+ // Write buf to a temp file so we can pass it to CLI tools
204
+ const tmpPath = path.join(
205
+ process.env["TEMP"] ?? process.env["TMPDIR"] ?? "/tmp",
206
+ `lens_img_${Date.now()}.bin`,
207
+ );
208
+ const { writeFileSync, unlinkSync } = require("fs") as typeof import("fs");
209
+ writeFileSync(tmpPath, buf);
210
+
211
+ try {
212
+ if (commandExists("chafa")) {
213
+ const result = execSync(
214
+ `chafa --format sixel --size ${widthCols}x40 "${tmpPath}"`,
215
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 15_000 },
216
+ );
217
+ return result + "\n";
218
+ }
219
+ if (commandExists("img2sixel")) {
220
+ const result = execSync(`img2sixel -w ${widthCols * 8} "${tmpPath}"`, {
221
+ encoding: "utf-8",
222
+ stdio: ["pipe", "pipe", "pipe"],
223
+ timeout: 15_000,
224
+ });
225
+ return result + "\n";
226
+ }
227
+ throw new Error("no sixel encoder found");
228
+ } finally {
229
+ try {
230
+ unlinkSync(tmpPath);
231
+ } catch {
232
+ /* ignore */
233
+ }
234
+ }
235
+ }
236
+
237
+ // Half-block fallback via terminal-image
238
+ async function renderHalfBlock(
239
+ buf: Buffer,
240
+ width: string | number,
241
+ ): Promise<string> {
242
+ let terminalImage: any;
243
+ try {
244
+ terminalImage = await import("terminal-image");
245
+ terminalImage = terminalImage.default ?? terminalImage;
246
+ } catch {
247
+ return (
248
+ "Error: terminal-image not installed (npm install terminal-image).\n" +
249
+ "For better image display, install chafa: https://hpjansson.org/chafa/\n" +
250
+ " Windows: winget install hpjansson.chafa\n" +
251
+ " macOS: brew install chafa\n" +
252
+ " Linux: sudo apt install chafa"
253
+ );
254
+ }
255
+ return await terminalImage.buffer(buf, {
256
+ width,
257
+ preserveAspectRatio: true,
258
+ });
259
+ }
260
+
261
+ // ── main render ───────────────────────────────────────────────────────────────
262
+
263
+ async function renderImage(
264
+ input: ViewImageInput,
265
+ repoPath: string,
266
+ ): Promise<string> {
267
+ const buf = await fetchBytes(input.src, repoPath);
268
+ const width = input.width ?? "80%";
269
+ const protocol =
270
+ input.protocol === "auto" || !input.protocol
271
+ ? detectProtocol()
272
+ : input.protocol;
273
+
274
+ switch (protocol) {
275
+ case "iterm2":
276
+ return renderITerm2(buf, width);
277
+
278
+ case "kitty":
279
+ return renderKitty(buf, width);
280
+
281
+ case "sixel":
282
+ try {
283
+ return renderSixel(buf, input.src, repoPath, width);
284
+ } catch (e: any) {
285
+ // Sixel encoder missing — fall through to halfblock
286
+ return (
287
+ `(sixel encoder not found — install chafa for better quality)\n` +
288
+ (await renderHalfBlock(buf, width))
289
+ );
290
+ }
291
+
292
+ case "halfblock":
293
+ default:
294
+ return await renderHalfBlock(buf, width);
295
+ }
296
+ }
297
+
298
+ // ── tool ──────────────────────────────────────────────────────────────────────
299
+
300
+ export const viewImageTool: Tool<ViewImageInput> = {
301
+ name: "view-image",
302
+ description:
303
+ "display an image in the terminal (auto-detects iTerm2/Kitty/Sixel/half-block)",
304
+ safe: true,
305
+ permissionLabel: "view image",
306
+
307
+ systemPromptEntry: (i) =>
308
+ [
309
+ `### ${i}. view-image — display an image in the terminal`,
310
+ `<view-image>screenshot.png</view-image>`,
311
+ `<view-image>https://example.com/banner.jpg</view-image>`,
312
+ `<view-image>{"src": "assets/logo.png", "width": "60%"}</view-image>`,
313
+ `<view-image>{"src": "photo.jpg", "width": "80%", "protocol": "iterm2"}</view-image>`,
314
+ `Protocols: auto (default), iterm2, kitty, sixel, halfblock`,
315
+ `Width: percentage "50%" or column count 40. Default "80%"`,
316
+ `Supported formats: PNG, JPG, GIF, WebP, BMP, TIFF`,
317
+ ].join("\n"),
318
+
319
+ parseInput: parseViewImageInput,
320
+
321
+ summariseInput: ({ src, width }) =>
322
+ `${path.basename(src)} (${width ?? "80%"})`,
323
+
324
+ execute: async (input, ctx) => {
325
+ try {
326
+ const ansi = await renderImage(input, ctx.repoPath);
327
+ return { kind: "image" as any, value: ansi };
328
+ } catch (err) {
329
+ return {
330
+ kind: "text",
331
+ value: `Error: ${err instanceof Error ? err.message : String(err)}`,
332
+ };
333
+ }
334
+ },
335
+ };
package/src/tools/web.ts CHANGED
@@ -1,5 +1,3 @@
1
- // ── HTML helpers ──────────────────────────────────────────────────────────────
2
-
3
1
  function stripTags(html: string): string {
4
2
  return html
5
3
  .replace(/<[^>]+>/g, " ")
@@ -73,8 +71,6 @@ function extractLists(html: string): string {
73
71
  : "";
74
72
  }
75
73
 
76
- // ── Fetch ─────────────────────────────────────────────────────────────────────
77
-
78
74
  export async function fetchUrl(url: string): Promise<string> {
79
75
  const res = await fetch(url, {
80
76
  headers: {
@@ -0,0 +1,29 @@
1
+ import path from "path";
2
+ import os from "os";
3
+ import { existsSync, readdirSync } from "fs";
4
+
5
+ const ADDONS_DIR = path.join(os.homedir(), ".lens", "addons");
6
+
7
+ export async function loadAddons(): Promise<void> {
8
+ if (!existsSync(ADDONS_DIR)) {
9
+ // Silently skip — no addons directory yet
10
+ return;
11
+ }
12
+
13
+ const files = readdirSync(ADDONS_DIR).filter(
14
+ (f) => f.endsWith(".js") && !f.startsWith("_"),
15
+ );
16
+
17
+ for (const file of files) {
18
+ const fullPath = path.join(ADDONS_DIR, file);
19
+ try {
20
+ await import(fullPath);
21
+ console.log(`[addons] loaded: ${file}\n`);
22
+ } catch (err) {
23
+ console.error(
24
+ `[addons] failed to load ${file}:`,
25
+ err instanceof Error ? err.message : String(err),
26
+ );
27
+ }
28
+ }
29
+ }
package/src/utils/chat.ts CHANGED
@@ -1,8 +1,4 @@
1
- // ── chat.ts ───────────────────────────────────────────────────────────────────
2
1
  //
3
- // Response parsing and API calls.
4
- // Tool parsing is now fully driven by the ToolRegistry — adding a new tool
5
- // to the registry automatically makes it parseable here.
6
2
 
7
3
  export {
8
4
  walkDir,
@@ -22,7 +18,7 @@ export { buildSystemPrompt, FEW_SHOT_MESSAGES } from "../prompts";
22
18
  import type { Message } from "../types/chat";
23
19
  import type { Provider } from "../types/config";
24
20
  import { FEW_SHOT_MESSAGES } from "../prompts";
25
- import { registry } from "./tools/registry";
21
+ import { registry } from "../utils/tools/registry";
26
22
  import type { FilePatch } from "../components/repo/DiffViewer";
27
23
 
28
24
  export type ParsedResponse =
@@ -43,6 +39,8 @@ export type ParsedResponse =
43
39
  remainder?: string;
44
40
  };
45
41
 
42
+ const MAX_HISTORY = 30;
43
+
46
44
  export function parseResponse(text: string): ParsedResponse {
47
45
  const scanText = text.replace(/```[\s\S]*?```/g, (m) => " ".repeat(m.length));
48
46
 
@@ -56,7 +54,6 @@ export function parseResponse(text: string): ParsedResponse {
56
54
  for (const toolName of registry.names()) {
57
55
  const escaped = toolName.replace(/[-]/g, "\\-");
58
56
 
59
- // XML tag
60
57
  const xmlRe = new RegExp(`<${escaped}>([\\s\\S]*?)<\\/${escaped}>`, "g");
61
58
  xmlRe.lastIndex = 0;
62
59
  const xmlM = xmlRe.exec(scanText);
@@ -78,7 +75,6 @@ export function parseResponse(text: string): ParsedResponse {
78
75
  }
79
76
  }
80
77
 
81
- // Fenced code block fallback
82
78
  const fencedRe = new RegExp(
83
79
  `\`\`\`${escaped}\\r?\\n([\\s\\S]*?)\\r?\\n\`\`\``,
84
80
  "g",
@@ -114,7 +110,6 @@ export function parseResponse(text: string): ParsedResponse {
114
110
  const afterMatch = text.slice(match.index + match[0].length).trim();
115
111
  const remainder = afterMatch.length > 0 ? afterMatch : undefined;
116
112
 
117
- // Special UI variants
118
113
  if (toolName === "changes") {
119
114
  try {
120
115
  const parsed = JSON.parse(body) as {
@@ -142,7 +137,6 @@ export function parseResponse(text: string): ParsedResponse {
142
137
  };
143
138
  }
144
139
 
145
- // Generic tool
146
140
  const tool = registry.get(toolName);
147
141
  if (!tool) return { kind: "text", content: text.trim() };
148
142
 
@@ -159,15 +153,11 @@ export function parseResponse(text: string): ParsedResponse {
159
153
  };
160
154
  }
161
155
 
162
- // ── Clone tag helper ──────────────────────────────────────────────────────────
163
-
164
156
  export function parseCloneTag(text: string): string | null {
165
157
  const m = text.match(/<clone>([\s\S]*?)<\/clone>/);
166
158
  return m ? m[1]!.trim() : null;
167
159
  }
168
160
 
169
- // ── GitHub URL detection ──────────────────────────────────────────────────────
170
-
171
161
  export function extractGithubUrl(text: string): string | null {
172
162
  const match = text.match(/https?:\/\/github\.com\/[\w.-]+\/[\w.-]+/);
173
163
  return match ? match[0]! : null;
@@ -178,12 +168,12 @@ export function toCloneUrl(url: string): string {
178
168
  return clean.endsWith(".git") ? clean : `${clean}.git`;
179
169
  }
180
170
 
181
- // ── API call ──────────────────────────────────────────────────────────────────
182
-
183
171
  function buildApiMessages(
184
172
  messages: Message[],
185
173
  ): { role: string; content: string }[] {
186
- return messages.map((m) => {
174
+ const recent = messages.slice(-MAX_HISTORY);
175
+
176
+ return recent.map((m) => {
187
177
  if (m.type === "tool") {
188
178
  if (!m.approved) {
189
179
  return {
@@ -222,7 +212,7 @@ export async function callChat(
222
212
  };
223
213
  body = {
224
214
  model: provider.model,
225
- max_tokens: 4096,
215
+ max_tokens: 16384,
226
216
  system: systemPrompt,
227
217
  messages: apiMessages,
228
218
  };
@@ -235,7 +225,7 @@ export async function callChat(
235
225
  };
236
226
  body = {
237
227
  model: provider.model,
238
- max_tokens: 4096,
228
+ max_tokens: 16384,
239
229
  messages: [{ role: "system", content: systemPrompt }, ...apiMessages],
240
230
  };
241
231
  }
@@ -15,14 +15,12 @@ export type MemoryEntry = {
15
15
  detail: string;
16
16
  summary: string;
17
17
  timestamp: string;
18
- repoPath: string;
19
18
  };
20
19
 
21
20
  export type Memory = {
22
21
  id: string;
23
22
  content: string;
24
23
  timestamp: string;
25
- repoPath: string;
26
24
  };
27
25
 
28
26
  export type MemoryFile = {
@@ -64,9 +62,9 @@ export function appendMemory(entry: Omit<MemoryEntry, "timestamp">): void {
64
62
 
65
63
  export function buildMemorySummary(repoPath: string): string {
66
64
  const m = loadMemoryFile();
67
- const relevant = m.entries.filter((e) => e.repoPath === repoPath).slice(-50);
65
+ const relevant = m.entries.slice(-50);
68
66
 
69
- const memories = m.memories.filter((mem) => mem.repoPath === repoPath);
67
+ const memories = m.memories;
70
68
 
71
69
  const parts: string[] = [];
72
70
 
@@ -92,13 +90,13 @@ export function buildMemorySummary(repoPath: string): string {
92
90
  }
93
91
 
94
92
  export function getRepoMemory(repoPath: string): MemoryEntry[] {
95
- return loadMemoryFile().entries.filter((e) => e.repoPath === repoPath);
93
+ return loadMemoryFile().entries;
96
94
  }
97
95
 
98
96
  export function clearRepoMemory(repoPath: string): void {
99
97
  const m = loadMemoryFile();
100
- m.entries = m.entries.filter((e) => e.repoPath !== repoPath);
101
- m.memories = m.memories.filter((mem) => mem.repoPath !== repoPath);
98
+ m.entries = m.entries = [];
99
+ m.memories = m.memories = [];
102
100
  saveMemoryFile(m);
103
101
  }
104
102
 
@@ -114,7 +112,6 @@ export function addMemory(content: string, repoPath: string): Memory {
114
112
  id: generateId(),
115
113
  content,
116
114
  timestamp: new Date().toISOString(),
117
- repoPath,
118
115
  };
119
116
  m.memories.push(memory);
120
117
  saveMemoryFile(m);
@@ -124,14 +121,12 @@ export function addMemory(content: string, repoPath: string): Memory {
124
121
  export function deleteMemory(id: string, repoPath: string): boolean {
125
122
  const m = loadMemoryFile();
126
123
  const before = m.memories.length;
127
- m.memories = m.memories.filter(
128
- (mem) => !(mem.id === id && mem.repoPath === repoPath),
129
- );
124
+ m.memories = m.memories.filter((mem) => !(mem.id === id));
130
125
  if (m.memories.length === before) return false;
131
126
  saveMemoryFile(m);
132
127
  return true;
133
128
  }
134
129
 
135
130
  export function listMemories(repoPath: string): Memory[] {
136
- return loadMemoryFile().memories.filter((mem) => mem.repoPath === repoPath);
131
+ return loadMemoryFile().memories;
137
132
  }