@ridit/lens 0.2.4 → 0.2.6

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.
Files changed (41) hide show
  1. package/LENS.md +32 -68
  2. package/README.md +91 -0
  3. package/addons/README.md +3 -0
  4. package/addons/run-tests.js +127 -0
  5. package/dist/index.mjs +226459 -2638
  6. package/package.json +13 -4
  7. package/src/colors.ts +5 -0
  8. package/src/commands/commit.tsx +686 -0
  9. package/src/commands/provider.tsx +36 -22
  10. package/src/components/__tests__/Header.test.tsx +9 -0
  11. package/src/components/chat/ChatMessage.tsx +6 -6
  12. package/src/components/chat/ChatOverlays.tsx +20 -10
  13. package/src/components/chat/ChatRunner.tsx +197 -31
  14. package/src/components/provider/ApiKeyStep.tsx +77 -121
  15. package/src/components/provider/ModelStep.tsx +35 -20
  16. package/src/components/{repo → provider}/ProviderPicker.tsx +1 -1
  17. package/src/components/provider/ProviderTypeStep.tsx +12 -5
  18. package/src/components/provider/RemoveProviderStep.tsx +7 -8
  19. package/src/components/repo/RepoAnalysis.tsx +1 -1
  20. package/src/components/task/TaskRunner.tsx +1 -1
  21. package/src/components/timeline/CommitDetail.tsx +2 -4
  22. package/src/components/timeline/CommitList.tsx +2 -14
  23. package/src/components/timeline/TimelineChat.tsx +1 -2
  24. package/src/components/timeline/TimelineRunner.tsx +506 -423
  25. package/src/index.tsx +38 -0
  26. package/src/prompts/fewshot.ts +144 -47
  27. package/src/prompts/system.ts +25 -21
  28. package/src/tools/chart.ts +210 -0
  29. package/src/tools/convert-image.ts +312 -0
  30. package/src/tools/files.ts +1 -9
  31. package/src/tools/git.ts +577 -0
  32. package/src/tools/index.ts +17 -13
  33. package/src/tools/pdf.ts +136 -78
  34. package/src/tools/view-image.ts +335 -0
  35. package/src/tools/web.ts +0 -4
  36. package/src/utils/addons/loadAddons.ts +6 -3
  37. package/src/utils/chat.ts +38 -23
  38. package/src/utils/thinking.tsx +275 -162
  39. package/src/utils/tools/builtins.ts +39 -32
  40. package/src/utils/tools/registry.ts +0 -14
  41. package/tsconfig.json +2 -2
package/src/tools/pdf.ts CHANGED
@@ -1,12 +1,13 @@
1
+ // Generates PDFs from markdown content — useful for exporting codebase reports and documentation.
2
+
1
3
  import path from "path";
2
- import os from "os";
3
4
  import { existsSync, mkdirSync, writeFileSync } from "fs";
4
5
 
5
- export function generatePdf(
6
+ export async function generatePdf(
6
7
  filePath: string,
7
8
  content: string,
8
9
  repoPath: string,
9
- ): string {
10
+ ): Promise<string> {
10
11
  const fullPath = path.isAbsolute(filePath)
11
12
  ? filePath
12
13
  : path.join(repoPath, filePath);
@@ -15,91 +16,148 @@ export function generatePdf(
15
16
  const dir = path.dirname(fullPath);
16
17
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
17
18
 
18
- const escaped = content
19
- .replace(/\\/g, "\\\\")
20
- .replace(/"""/g, '\\"\\"\\"')
21
- .replace(/\r/g, "");
19
+ const PDFDocument = require("pdfkit");
20
+ const doc = new PDFDocument({ margin: 72 });
21
+ const chunks: Buffer[] = [];
22
22
 
23
- const script = `
24
- import sys
25
- try:
26
- from reportlab.lib.pagesizes import letter
27
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, HRFlowable
28
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
29
- from reportlab.lib.units import inch
30
- from reportlab.lib import colors
31
- except ImportError:
32
- import subprocess
33
- subprocess.check_call([sys.executable, "-m", "pip", "install", "reportlab", "--break-system-packages", "-q"])
34
- from reportlab.lib.pagesizes import letter
35
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, HRFlowable
36
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
37
- from reportlab.lib.units import inch
38
- from reportlab.lib import colors
23
+ doc.on("data", (chunk: Buffer) => chunks.push(chunk));
39
24
 
40
- doc = SimpleDocTemplate(
41
- r"""${fullPath}""",
42
- pagesize=letter,
43
- rightMargin=inch,
44
- leftMargin=inch,
45
- topMargin=inch,
46
- bottomMargin=inch,
47
- )
25
+ return new Promise((resolve) => {
26
+ doc.on("end", () => {
27
+ try {
28
+ writeFileSync(fullPath, Buffer.concat(chunks));
29
+ resolve(`PDF generated: ${fullPath}`);
30
+ } catch (err) {
31
+ resolve(
32
+ `Error writing PDF: ${err instanceof Error ? err.message : String(err)}`,
33
+ );
34
+ }
35
+ });
48
36
 
49
- styles = getSampleStyleSheet()
50
- styles.add(ParagraphStyle(name="H1", parent=styles["Heading1"], fontSize=22, spaceAfter=10))
51
- styles.add(ParagraphStyle(name="H2", parent=styles["Heading2"], fontSize=16, spaceAfter=8))
52
- styles.add(ParagraphStyle(name="H3", parent=styles["Heading3"], fontSize=13, spaceAfter=6))
53
- styles.add(ParagraphStyle(name="Body", parent=styles["Normal"], fontSize=11, leading=16, spaceAfter=8))
54
- styles.add(ParagraphStyle(name="Bullet", parent=styles["Normal"], fontSize=11, leading=16, leftIndent=20, spaceAfter=4, bulletIndent=10))
37
+ doc.on("error", (err: Error) => {
38
+ resolve(`Error generating PDF: ${err.message}`);
39
+ });
55
40
 
56
- raw = """${escaped}"""
41
+ const lines = content.split("\n");
42
+ let firstElement = true;
57
43
 
58
- story = []
59
- for line in raw.split("\\n"):
60
- s = line.rstrip()
61
- if s.startswith("### "):
62
- story.append(Paragraph(s[4:], styles["H3"]))
63
- elif s.startswith("## "):
64
- story.append(Spacer(1, 6))
65
- story.append(Paragraph(s[3:], styles["H2"]))
66
- story.append(HRFlowable(width="100%", thickness=0.5, color=colors.grey, spaceAfter=4))
67
- elif s.startswith("# "):
68
- story.append(Paragraph(s[2:], styles["H1"]))
69
- story.append(HRFlowable(width="100%", thickness=1, color=colors.black, spaceAfter=6))
70
- elif s.startswith("- ") or s.startswith("* "):
71
- story.append(Paragraph(u"\\u2022 " + s[2:], styles["Bullet"]))
72
- elif s.startswith("---"):
73
- story.append(HRFlowable(width="100%", thickness=0.5, color=colors.grey, spaceAfter=4))
74
- elif s == "":
75
- story.append(Spacer(1, 6))
76
- else:
77
- import re
78
- s = re.sub(r"\\*\\*(.+?)\\*\\*", r"<b>\\1</b>", s)
79
- s = re.sub(r"\\*(.+?)\\*", r"<i>\\1</i>", s)
80
- s = re.sub(r"\`(.+?)\`", r"<font name='Courier'>\\1</font>", s)
81
- story.append(Paragraph(s, styles["Body"]))
44
+ const addSpacingBefore = () => {
45
+ if (!firstElement) doc.moveDown(0.5);
46
+ firstElement = false;
47
+ };
82
48
 
83
- doc.build(story)
84
- print("OK")
85
- `
86
- .replace("${fullPath}", fullPath.replace(/\\/g, "/"))
87
- .replace("${escaped}", escaped);
49
+ const bullets: string[] = [];
88
50
 
89
- const tmpFile = path.join(os.tmpdir(), `lens_pdf_${Date.now()}.py`);
90
- writeFileSync(tmpFile, script, "utf-8");
51
+ const flushBullets = () => {
52
+ if (bullets.length === 0) return;
53
+ addSpacingBefore();
54
+ for (const bullet of bullets) {
55
+ doc
56
+ .fontSize(11)
57
+ .fillColor("#333333")
58
+ .text(`• ${bullet}`, { indent: 20, lineGap: 3 });
59
+ }
60
+ bullets.length = 0;
61
+ };
91
62
 
92
- const { execSync } =
93
- require("child_process") as typeof import("child_process");
94
- execSync(`python "${tmpFile}"`, { stdio: "pipe" });
63
+ for (const raw of lines) {
64
+ const line = raw.trimEnd();
95
65
 
96
- try {
97
- require("fs").unlinkSync(tmpFile);
98
- } catch {
99
- /* ignore */
100
- }
66
+ if (line.startsWith("# ")) {
67
+ flushBullets();
68
+ addSpacingBefore();
69
+ doc
70
+ .fontSize(22)
71
+ .fillColor("#000000")
72
+ .font("Helvetica-Bold")
73
+ .text(line.slice(2));
74
+ doc.moveDown(0.2);
75
+ doc
76
+ .moveTo(72, doc.y)
77
+ .lineTo(doc.page.width - 72, doc.y)
78
+ .strokeColor("#000000")
79
+ .lineWidth(1)
80
+ .stroke();
81
+ doc.moveDown(0.4);
82
+ doc.font("Helvetica");
83
+ } else if (line.startsWith("## ")) {
84
+ flushBullets();
85
+ addSpacingBefore();
86
+ doc
87
+ .fontSize(16)
88
+ .fillColor("#111111")
89
+ .font("Helvetica-Bold")
90
+ .text(line.slice(3));
91
+ doc.moveDown(0.2);
92
+ doc
93
+ .moveTo(72, doc.y)
94
+ .lineTo(doc.page.width - 72, doc.y)
95
+ .strokeColor("#999999")
96
+ .lineWidth(0.5)
97
+ .stroke();
98
+ doc.moveDown(0.3);
99
+ doc.font("Helvetica");
100
+ } else if (line.startsWith("### ")) {
101
+ flushBullets();
102
+ addSpacingBefore();
103
+ doc
104
+ .fontSize(13)
105
+ .fillColor("#222222")
106
+ .font("Helvetica-Bold")
107
+ .text(line.slice(4));
108
+ doc.font("Helvetica");
109
+ } else if (line.startsWith("- ") || line.startsWith("* ")) {
110
+ bullets.push(line.slice(2));
111
+ } else if (line.startsWith("---")) {
112
+ flushBullets();
113
+ doc.moveDown(0.5);
114
+ doc
115
+ .moveTo(72, doc.y)
116
+ .lineTo(doc.page.width - 72, doc.y)
117
+ .strokeColor("#cccccc")
118
+ .lineWidth(0.5)
119
+ .stroke();
120
+ doc.moveDown(0.5);
121
+ } else if (line === "") {
122
+ flushBullets();
123
+ } else {
124
+ flushBullets();
125
+ addSpacingBefore();
126
+ // handle inline bold (**text**) and inline code (`text`)
127
+ const segments = line.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
128
+ let isFirst = true;
129
+ for (const seg of segments) {
130
+ if (seg.startsWith("**") && seg.endsWith("**")) {
131
+ doc
132
+ .fontSize(11)
133
+ .fillColor("#333333")
134
+ .font("Helvetica-Bold")
135
+ .text(seg.slice(2, -2), { continued: true, lineGap: 3 });
136
+ doc.font("Helvetica");
137
+ } else if (seg.startsWith("`") && seg.endsWith("`")) {
138
+ doc
139
+ .fontSize(10)
140
+ .fillColor("#c0392b")
141
+ .font("Courier")
142
+ .text(seg.slice(1, -1), { continued: true, lineGap: 3 });
143
+ doc.font("Helvetica").fontSize(11).fillColor("#333333");
144
+ } else if (seg.length > 0) {
145
+ doc
146
+ .fontSize(11)
147
+ .fillColor("#333333")
148
+ .font("Helvetica")
149
+ .text(seg, { continued: true, lineGap: 3 });
150
+ }
151
+ isFirst = false;
152
+ }
153
+ // end the continued text
154
+ doc.text("", { continued: false });
155
+ }
156
+ }
101
157
 
102
- return `PDF generated: ${fullPath}`;
158
+ flushBullets();
159
+ doc.end();
160
+ });
103
161
  } catch (err) {
104
162
  return `Error generating PDF: ${err instanceof Error ? err.message : String(err)}`;
105
163
  }
@@ -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: {
@@ -6,7 +6,6 @@ const ADDONS_DIR = path.join(os.homedir(), ".lens", "addons");
6
6
 
7
7
  export async function loadAddons(): Promise<void> {
8
8
  if (!existsSync(ADDONS_DIR)) {
9
- // Silently skip — no addons directory yet
10
9
  return;
11
10
  }
12
11
 
@@ -14,11 +13,15 @@ export async function loadAddons(): Promise<void> {
14
13
  (f) => f.endsWith(".js") && !f.startsWith("_"),
15
14
  );
16
15
 
17
- for (const file of files) {
16
+ for (let i = 0; i < files.length; i++) {
17
+ const file = files[i];
18
+ if (!file) return;
19
+
18
20
  const fullPath = path.join(ADDONS_DIR, file);
21
+ const isLast = i === files.length - 1;
19
22
  try {
20
23
  await import(fullPath);
21
- console.log(`[addons] loaded: ${file}\n`);
24
+ console.log(`[addons] loaded: ${file}${isLast ? "\n" : ""}`);
22
25
  } catch (err) {
23
26
  console.error(
24
27
  `[addons] failed to load ${file}:`,