mini-coder 0.4.1 → 0.5.0

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 (51) hide show
  1. package/README.md +87 -48
  2. package/assets/icon-1-minimal.svg +31 -0
  3. package/assets/icon-2-dark-terminal.svg +48 -0
  4. package/assets/icon-3-gradient-modern.svg +45 -0
  5. package/assets/icon-4-filled-bold.svg +54 -0
  6. package/assets/icon-5-community-badge.svg +63 -0
  7. package/assets/preview-0-5-0.png +0 -0
  8. package/assets/preview.gif +0 -0
  9. package/bin/mc.ts +14 -0
  10. package/bun.lock +438 -0
  11. package/package.json +12 -29
  12. package/src/agent.ts +592 -0
  13. package/src/cli.ts +124 -0
  14. package/src/git.ts +164 -0
  15. package/src/headless.ts +140 -0
  16. package/src/index.ts +645 -0
  17. package/src/input.ts +155 -0
  18. package/src/paths.ts +37 -0
  19. package/src/plugins.ts +183 -0
  20. package/src/prompt.ts +294 -0
  21. package/src/session.ts +838 -0
  22. package/src/settings.ts +184 -0
  23. package/src/skills.ts +258 -0
  24. package/src/submit.ts +323 -0
  25. package/src/theme.ts +147 -0
  26. package/src/tools.ts +636 -0
  27. package/src/ui/agent.test.ts +49 -0
  28. package/src/ui/agent.ts +210 -0
  29. package/src/ui/commands.test.ts +610 -0
  30. package/src/ui/commands.ts +638 -0
  31. package/src/ui/conversation.test.ts +892 -0
  32. package/src/ui/conversation.ts +926 -0
  33. package/src/ui/help.test.ts +26 -0
  34. package/src/ui/help.ts +119 -0
  35. package/src/ui/input.test.ts +74 -0
  36. package/src/ui/input.ts +138 -0
  37. package/src/ui/overlay.test.ts +42 -0
  38. package/src/ui/overlay.ts +59 -0
  39. package/src/ui/status.test.ts +450 -0
  40. package/src/ui/status.ts +357 -0
  41. package/src/ui.ts +615 -0
  42. package/.claude/settings.local.json +0 -54
  43. package/.prettierignore +0 -7
  44. package/dist/mc-edit.js +0 -275
  45. package/dist/mc.js +0 -7355
  46. package/docs/KNOWN_ISSUES.md +0 -13
  47. package/docs/design-decisions.md +0 -31
  48. package/docs/mini-coder.1.md +0 -227
  49. package/docs/superpowers/plans/2026-03-30-anthropic-oauth-removal.md +0 -61
  50. package/docs/superpowers/specs/2026-03-30-anthropic-oauth-removal-design.md +0 -47
  51. package/lefthook.yml +0 -4
package/src/tools.ts ADDED
@@ -0,0 +1,636 @@
1
+ /**
2
+ * Built-in tool implementations: `edit`, `shell`, and `readImage`.
3
+ *
4
+ * Each tool is exposed as a pure-ish execute function that takes typed
5
+ * arguments and a working directory, returning a result object. The pi-ai
6
+ * {@link Tool} definitions (TypeBox schemas) are exported separately for
7
+ * registration with the agent context.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { dirname, extname, isAbsolute, join } from "node:path";
14
+ import type { ImageContent, TextContent, Tool } from "@mariozechner/pi-ai";
15
+ import { Type } from "@mariozechner/pi-ai";
16
+ import type { ToolUpdateCallback } from "./agent.ts";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Result type
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Result from executing a tool.
24
+ *
25
+ * Content blocks carry either text or image data. The agent loop
26
+ * maps these directly into {@link ToolResultMessage.content}.
27
+ */
28
+ export interface ToolExecResult {
29
+ /** Content blocks for the tool result (text and/or images). */
30
+ content: (TextContent | ImageContent)[];
31
+ /** Whether the execution encountered an error. */
32
+ isError: boolean;
33
+ }
34
+
35
+ /** Convenience: build a text-only {@link ToolExecResult}. */
36
+ function textResult(text: string, isError: boolean): ToolExecResult {
37
+ return { content: [{ type: "text", text }], isError };
38
+ }
39
+
40
+ function detectLineEnding(content: string): "\n" | "\r\n" | null {
41
+ if (content.includes("\r\n")) {
42
+ return "\r\n";
43
+ }
44
+ if (content.includes("\n")) {
45
+ return "\n";
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function normalizeLineEndings(
51
+ content: string,
52
+ lineEnding: "\n" | "\r\n",
53
+ ): string {
54
+ if (lineEnding === "\r\n") {
55
+ return content.replace(/\r?\n/g, "\r\n");
56
+ }
57
+ return content.replace(/\r\n/g, "\n");
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // edit
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /** Arguments for the `edit` tool. */
65
+ interface EditArgs {
66
+ /** File path (absolute or relative to cwd). */
67
+ path: string;
68
+ /** Exact text to find. Empty string means "create new file". */
69
+ oldText: string;
70
+ /** Replacement text (or full content for new files). */
71
+ newText: string;
72
+ }
73
+
74
+ /**
75
+ * Execute an exact-text replacement in a single file.
76
+ *
77
+ * - If `oldText` is empty, creates a new file (with parent directories).
78
+ * Fails if the file already exists.
79
+ * - Otherwise, reads the file, finds exactly one occurrence of `oldText`,
80
+ * and replaces it with `newText`. Fails if the text is not found or
81
+ * matches multiple locations.
82
+ *
83
+ * @param args - Edit arguments (path, oldText, newText).
84
+ * @param cwd - Working directory for resolving relative paths.
85
+ * @returns A {@link ToolExecResult} with confirmation or error message.
86
+ */
87
+ export function executeEdit(args: EditArgs, cwd: string): ToolExecResult {
88
+ const filePath = isAbsolute(args.path) ? args.path : join(cwd, args.path);
89
+
90
+ // Create new file
91
+ if (args.oldText === "") {
92
+ if (existsSync(filePath)) {
93
+ return textResult(`File already exists: ${args.path}`, true);
94
+ }
95
+ mkdirSync(dirname(filePath), { recursive: true });
96
+ writeFileSync(filePath, args.newText, "utf-8");
97
+ return textResult(`Created ${args.path}`, false);
98
+ }
99
+
100
+ // Replace in existing file
101
+ if (!existsSync(filePath)) {
102
+ return textResult(`File not found: ${args.path}`, true);
103
+ }
104
+
105
+ const content = readFileSync(filePath, "utf-8");
106
+
107
+ // Count occurrences
108
+ let count = 0;
109
+ let idx = 0;
110
+ while (true) {
111
+ idx = content.indexOf(args.oldText, idx);
112
+ if (idx === -1) break;
113
+ count++;
114
+ idx += args.oldText.length;
115
+ }
116
+
117
+ if (count === 0) {
118
+ return textResult(`Old text not found in ${args.path}`, true);
119
+ }
120
+ if (count > 1) {
121
+ return textResult(
122
+ `Old text matches multiple locations (${count}) in ${args.path}`,
123
+ true,
124
+ );
125
+ }
126
+
127
+ // Exactly one match — replace
128
+ const lineEnding = detectLineEnding(content);
129
+ const newText = lineEnding
130
+ ? normalizeLineEndings(args.newText, lineEnding)
131
+ : args.newText;
132
+ const updated = content.replace(args.oldText, newText);
133
+ writeFileSync(filePath, updated, "utf-8");
134
+ return textResult(`Edited ${args.path}`, false);
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // shell
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /** Arguments for the `shell` tool. */
142
+ interface ShellArgs {
143
+ /** The command to run. */
144
+ command: string;
145
+ }
146
+
147
+ /** Options for shell execution. */
148
+ interface ShellOpts {
149
+ /** Maximum output lines before truncation. Default: 1000. */
150
+ maxLines?: number;
151
+ /** Maximum UTF-8 bytes before truncation. Default: 50_000. */
152
+ maxBytes?: number;
153
+ /** Abort signal to cancel the command. */
154
+ signal?: AbortSignal;
155
+ /** Callback for progressive output updates while the command is running. */
156
+ onUpdate?: ToolUpdateCallback;
157
+ }
158
+
159
+ const DEFAULT_MAX_LINES = 1000;
160
+ const DEFAULT_MAX_BYTES = 50_000;
161
+ const SHELL_UPDATE_INTERVAL_MS = 75;
162
+
163
+ /** Format combined stdout/stderr for display in tool results. */
164
+ function formatShellOutput(stdout: string, stderr: string): string {
165
+ if (stdout && stderr) {
166
+ return `${stdout}\n\n[stderr]\n${stderr}`;
167
+ }
168
+ if (stdout) {
169
+ return stdout;
170
+ }
171
+ if (stderr) {
172
+ return `[stderr]\n${stderr}`;
173
+ }
174
+ return "";
175
+ }
176
+
177
+ /** Read a spawned shell stream into a string, reporting progressive updates. */
178
+ async function consumeShellStream(
179
+ stream: ReadableStream<Uint8Array>,
180
+ onChunk: (chunk: string) => void,
181
+ ): Promise<string> {
182
+ const reader = stream.getReader();
183
+ const decoder = new TextDecoder();
184
+ let output = "";
185
+
186
+ while (true) {
187
+ const { done, value } = await reader.read();
188
+ if (done) {
189
+ output += decoder.decode();
190
+ return output;
191
+ }
192
+
193
+ const chunk = decoder.decode(value, { stream: true });
194
+ output += chunk;
195
+ onChunk(chunk);
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Run a command in the user's shell.
201
+ *
202
+ * Executes via `$SHELL -c` (falling back to `/bin/sh`). Returns combined
203
+ * stdout/stderr and the exit code. Large output is truncated to keep
204
+ * head + tail lines with a middle marker.
205
+ *
206
+ * @param args - Shell arguments (command).
207
+ * @param cwd - Working directory to run the command in.
208
+ * @param opts - Optional execution options (maxLines, signal, onUpdate).
209
+ * @returns A {@link ToolExecResult} with the command output.
210
+ */
211
+ export async function executeShell(
212
+ args: ShellArgs,
213
+ cwd: string,
214
+ opts?: ShellOpts,
215
+ ): Promise<ToolExecResult> {
216
+ const shell = process.env.SHELL || "/bin/sh";
217
+ const maxLines = opts?.maxLines ?? DEFAULT_MAX_LINES;
218
+ const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;
219
+ let updateTimer: ReturnType<typeof setTimeout> | null = null;
220
+
221
+ try {
222
+ const spawnOpts: Parameters<typeof Bun.spawn>[1] = {
223
+ cwd,
224
+ stdout: "pipe",
225
+ stderr: "pipe",
226
+ };
227
+ if (opts?.signal) spawnOpts.signal = opts.signal;
228
+ const proc = Bun.spawn([shell, "-c", args.command], spawnOpts);
229
+
230
+ let stdoutBuf = "";
231
+ let stderrBuf = "";
232
+ let lastReportedOutput = "";
233
+ let lastReportAt = 0;
234
+
235
+ const clearPendingUpdate = (): void => {
236
+ if (updateTimer) {
237
+ clearTimeout(updateTimer);
238
+ updateTimer = null;
239
+ }
240
+ };
241
+
242
+ const buildProgressOutput = (): string => {
243
+ return truncateOutput(
244
+ formatShellOutput(stdoutBuf, stderrBuf),
245
+ maxLines,
246
+ maxBytes,
247
+ );
248
+ };
249
+
250
+ const emitUpdate = (): void => {
251
+ clearPendingUpdate();
252
+ if (!opts?.onUpdate) {
253
+ return;
254
+ }
255
+
256
+ const output = buildProgressOutput();
257
+ if (!output || output === lastReportedOutput) {
258
+ return;
259
+ }
260
+
261
+ lastReportedOutput = output;
262
+ lastReportAt = Date.now();
263
+ opts.onUpdate(textResult(output, false));
264
+ };
265
+
266
+ const scheduleUpdate = (): void => {
267
+ if (!opts?.onUpdate) {
268
+ return;
269
+ }
270
+
271
+ const elapsed = Date.now() - lastReportAt;
272
+ if (elapsed >= SHELL_UPDATE_INTERVAL_MS) {
273
+ emitUpdate();
274
+ return;
275
+ }
276
+ if (updateTimer) {
277
+ return;
278
+ }
279
+
280
+ updateTimer = setTimeout(() => {
281
+ emitUpdate();
282
+ }, SHELL_UPDATE_INTERVAL_MS - elapsed);
283
+ };
284
+
285
+ const [stdout, stderr, exitCode] = await Promise.all([
286
+ consumeShellStream(proc.stdout as ReadableStream<Uint8Array>, (chunk) => {
287
+ stdoutBuf += chunk;
288
+ scheduleUpdate();
289
+ }),
290
+ consumeShellStream(proc.stderr as ReadableStream<Uint8Array>, (chunk) => {
291
+ stderrBuf += chunk;
292
+ scheduleUpdate();
293
+ }),
294
+ proc.exited,
295
+ ]);
296
+
297
+ clearPendingUpdate();
298
+ const output = truncateOutput(
299
+ formatShellOutput(stdout.trimEnd(), stderr.trimEnd()),
300
+ maxLines,
301
+ maxBytes,
302
+ );
303
+ if (opts?.onUpdate && output && output !== lastReportedOutput) {
304
+ lastReportedOutput = output;
305
+ opts.onUpdate(textResult(output, false));
306
+ }
307
+
308
+ const isError = exitCode !== 0;
309
+ const body = output || "(no output)";
310
+ return textResult(`Exit code: ${exitCode}\n${body}`, isError);
311
+ } catch (err) {
312
+ if (updateTimer) {
313
+ clearTimeout(updateTimer);
314
+ updateTimer = null;
315
+ }
316
+ const message = err instanceof Error ? err.message : String(err);
317
+ return textResult(`Shell error: ${message}`, true);
318
+ }
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Output truncation
323
+ // ---------------------------------------------------------------------------
324
+
325
+ /** Build line-limited head/tail segments and their truncation marker. */
326
+ function buildLineTruncation(
327
+ output: string,
328
+ maxLines: number,
329
+ ): {
330
+ head: string;
331
+ tail: string;
332
+ marker: string;
333
+ } | null {
334
+ const lines = output.split("\n");
335
+ if (lines.length <= maxLines) {
336
+ return null;
337
+ }
338
+
339
+ const headCount = Math.ceil(maxLines / 2);
340
+ const tailCount = Math.floor(maxLines / 2);
341
+ const omitted = lines.length - headCount - tailCount;
342
+
343
+ return {
344
+ head: lines.slice(0, headCount).join("\n"),
345
+ tail: lines.slice(lines.length - tailCount).join("\n"),
346
+ marker: `\n… truncated ${omitted} lines …\n`,
347
+ };
348
+ }
349
+
350
+ function isHighSurrogate(codeUnit: number): boolean {
351
+ return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
352
+ }
353
+
354
+ function isLowSurrogate(codeUnit: number): boolean {
355
+ return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
356
+ }
357
+
358
+ function findUtf8SliceLength(
359
+ input: string,
360
+ maxBytes: number,
361
+ getCandidate: (length: number) => string,
362
+ ): number {
363
+ if (maxBytes <= 0 || input === "") {
364
+ return 0;
365
+ }
366
+
367
+ let low = 0;
368
+ let high = input.length;
369
+ while (low < high) {
370
+ const mid = Math.ceil((low + high) / 2);
371
+ const candidate = getCandidate(mid);
372
+ if (Buffer.byteLength(candidate, "utf8") <= maxBytes) {
373
+ low = mid;
374
+ } else {
375
+ high = mid - 1;
376
+ }
377
+ }
378
+
379
+ return low;
380
+ }
381
+
382
+ function normalizeUtf8PrefixEnd(input: string, end: number): number {
383
+ if (end <= 0 || end >= input.length) {
384
+ return end;
385
+ }
386
+
387
+ const previousCodeUnit = input.charCodeAt(end - 1);
388
+ const nextCodeUnit = input.charCodeAt(end);
389
+ if (isHighSurrogate(previousCodeUnit) && isLowSurrogate(nextCodeUnit)) {
390
+ return end - 1;
391
+ }
392
+
393
+ return end;
394
+ }
395
+
396
+ function normalizeUtf8SuffixStart(input: string, start: number): number {
397
+ if (start <= 0 || start >= input.length) {
398
+ return start;
399
+ }
400
+
401
+ const previousCodeUnit = input.charCodeAt(start - 1);
402
+ const nextCodeUnit = input.charCodeAt(start);
403
+ if (isHighSurrogate(previousCodeUnit) && isLowSurrogate(nextCodeUnit)) {
404
+ return start + 1;
405
+ }
406
+
407
+ return start;
408
+ }
409
+
410
+ /** Slice the largest UTF-8 prefix that fits within `maxBytes`. */
411
+ function sliceUtf8Prefix(input: string, maxBytes: number): string {
412
+ const end = normalizeUtf8PrefixEnd(
413
+ input,
414
+ findUtf8SliceLength(input, maxBytes, (length) => input.slice(0, length)),
415
+ );
416
+ return input.slice(0, end);
417
+ }
418
+
419
+ /** Slice the largest UTF-8 suffix that fits within `maxBytes`. */
420
+ function sliceUtf8Suffix(input: string, maxBytes: number): string {
421
+ const start = normalizeUtf8SuffixStart(
422
+ input,
423
+ input.length -
424
+ findUtf8SliceLength(input, maxBytes, (length) =>
425
+ input.slice(input.length - length),
426
+ ),
427
+ );
428
+ return input.slice(start);
429
+ }
430
+
431
+ /** Fit disjoint head/tail segments plus a marker within a UTF-8 byte budget. */
432
+ function fitSegmentsWithinBytes(
433
+ headSource: string,
434
+ tailSource: string,
435
+ marker: string,
436
+ maxBytes: number,
437
+ ): string {
438
+ const markerBytes = Buffer.byteLength(marker, "utf8");
439
+ if (markerBytes >= maxBytes) {
440
+ return sliceUtf8Prefix(headSource, maxBytes);
441
+ }
442
+
443
+ const availableBytes = maxBytes - markerBytes;
444
+ const headBudget = Math.ceil(availableBytes / 2);
445
+ const tailBudget = Math.floor(availableBytes / 2);
446
+
447
+ let head = sliceUtf8Prefix(headSource, headBudget);
448
+ let tail = sliceUtf8Suffix(tailSource, tailBudget);
449
+
450
+ const usedBytes =
451
+ Buffer.byteLength(head, "utf8") + Buffer.byteLength(tail, "utf8");
452
+ let remainingBytes = availableBytes - usedBytes;
453
+
454
+ if (remainingBytes > 0) {
455
+ const headBytes = Buffer.byteLength(head, "utf8");
456
+ const expandedHead = sliceUtf8Prefix(
457
+ headSource,
458
+ headBytes + remainingBytes,
459
+ );
460
+ remainingBytes -= Buffer.byteLength(expandedHead, "utf8") - headBytes;
461
+ head = expandedHead;
462
+ }
463
+
464
+ if (remainingBytes > 0) {
465
+ const tailBytes = Buffer.byteLength(tail, "utf8");
466
+ tail = sliceUtf8Suffix(tailSource, tailBytes + remainingBytes);
467
+ }
468
+
469
+ return head + marker + tail;
470
+ }
471
+
472
+ /** Truncate output by UTF-8 byte size, preserving head and tail text. */
473
+ function truncateOutputByBytes(output: string, maxBytes: number): string {
474
+ if (Buffer.byteLength(output, "utf8") <= maxBytes) {
475
+ return output;
476
+ }
477
+
478
+ return fitSegmentsWithinBytes(
479
+ output,
480
+ output,
481
+ "\n… truncated for size …\n",
482
+ maxBytes,
483
+ );
484
+ }
485
+
486
+ /**
487
+ * Truncate output to keep useful head and tail content within line and byte budgets.
488
+ *
489
+ * The line budget avoids flooding the model with very tall outputs, while the
490
+ * byte budget prevents context explosions caused by a small number of very long
491
+ * lines.
492
+ *
493
+ * @param output - The full output string.
494
+ * @param maxLines - Maximum number of content lines to keep.
495
+ * @param maxBytes - Maximum UTF-8 bytes to keep.
496
+ * @returns The (possibly truncated) output string.
497
+ */
498
+ export function truncateOutput(
499
+ output: string,
500
+ maxLines: number,
501
+ maxBytes: number,
502
+ ): string {
503
+ if (!output) return output;
504
+
505
+ const lineTruncation = buildLineTruncation(output, maxLines);
506
+ if (!lineTruncation) {
507
+ return truncateOutputByBytes(output, maxBytes);
508
+ }
509
+
510
+ const lineLimited =
511
+ lineTruncation.head + lineTruncation.marker + lineTruncation.tail;
512
+ if (Buffer.byteLength(lineLimited, "utf8") <= maxBytes) {
513
+ return lineLimited;
514
+ }
515
+
516
+ return fitSegmentsWithinBytes(
517
+ lineTruncation.head,
518
+ lineTruncation.tail,
519
+ lineTruncation.marker,
520
+ maxBytes,
521
+ );
522
+ }
523
+
524
+ // ---------------------------------------------------------------------------
525
+ // Tool definitions (pi-ai Tool schemas)
526
+ // ---------------------------------------------------------------------------
527
+
528
+ /** pi-ai tool definition for `edit`. */
529
+ export const editTool: Tool = {
530
+ name: "edit",
531
+ description:
532
+ "Make an exact-text replacement in a single file. " +
533
+ "Provide the file path, the exact text to find, and the replacement text. " +
534
+ "The old text must match exactly one location in the file. " +
535
+ "To create a new file, use an empty old text and the full file content as new text.",
536
+ parameters: Type.Object({
537
+ path: Type.String({
538
+ description: "File path (absolute or relative to cwd)",
539
+ }),
540
+ oldText: Type.String({
541
+ description:
542
+ 'Exact text to find and replace. Empty string means "create new file".',
543
+ }),
544
+ newText: Type.String({
545
+ description: "Replacement text (or full content for new files)",
546
+ }),
547
+ }),
548
+ };
549
+
550
+ /** pi-ai tool definition for `shell`. */
551
+ export const shellTool: Tool = {
552
+ name: "shell",
553
+ description:
554
+ "Run a command in the user's shell. Returns stdout, stderr, and exit code. " +
555
+ "Use for exploring the codebase (rg, find, ls, cat), running tests, builds, git, etc.",
556
+ parameters: Type.Object({
557
+ command: Type.String({ description: "The shell command to execute" }),
558
+ }),
559
+ };
560
+
561
+ // ---------------------------------------------------------------------------
562
+ // readImage
563
+ // ---------------------------------------------------------------------------
564
+
565
+ /** Supported image extensions and their MIME types. */
566
+ const IMAGE_MIME_TYPES: Record<string, string> = {
567
+ ".png": "image/png",
568
+ ".jpg": "image/jpeg",
569
+ ".jpeg": "image/jpeg",
570
+ ".gif": "image/gif",
571
+ ".webp": "image/webp",
572
+ };
573
+
574
+ /** Arguments for the `readImage` tool. */
575
+ interface ReadImageArgs {
576
+ /** File path (absolute or relative to cwd). */
577
+ path: string;
578
+ }
579
+
580
+ /**
581
+ * Read an image file and return it as base64-encoded content.
582
+ *
583
+ * Supports PNG, JPEG, GIF, and WebP. Returns {@link ImageContent} on
584
+ * success or a text error message on failure. The MIME type is detected
585
+ * from the file extension.
586
+ *
587
+ * @param args - ReadImage arguments (path).
588
+ * @param cwd - Working directory for resolving relative paths.
589
+ * @returns A {@link ToolExecResult} with image content or error message.
590
+ */
591
+ export function executeReadImage(
592
+ args: ReadImageArgs,
593
+ cwd: string,
594
+ ): ToolExecResult {
595
+ const filePath = isAbsolute(args.path) ? args.path : join(cwd, args.path);
596
+ const ext = extname(filePath).toLowerCase();
597
+
598
+ const mimeType = IMAGE_MIME_TYPES[ext];
599
+ if (!mimeType) {
600
+ return textResult(
601
+ `Unsupported image format: ${ext || "(no extension)"}`,
602
+ true,
603
+ );
604
+ }
605
+
606
+ if (!existsSync(filePath)) {
607
+ return textResult(`File not found: ${args.path}`, true);
608
+ }
609
+
610
+ try {
611
+ const data = readFileSync(filePath);
612
+ const base64 = Buffer.from(data).toString("base64");
613
+
614
+ return {
615
+ content: [{ type: "image", data: base64, mimeType }],
616
+ isError: false,
617
+ };
618
+ } catch (error) {
619
+ const message = error instanceof Error ? error.message : String(error);
620
+ return textResult(`Failed to read image ${args.path}: ${message}`, true);
621
+ }
622
+ }
623
+
624
+ /** pi-ai tool definition for `readImage`. */
625
+ export const readImageTool: Tool = {
626
+ name: "readImage",
627
+ description:
628
+ "Read an image file and return its contents. " +
629
+ "Supports PNG, JPEG, GIF, and WebP formats. " +
630
+ "Use this to inspect screenshots, diagrams, or any image in the repo.",
631
+ parameters: Type.Object({
632
+ path: Type.String({
633
+ description: "File path (absolute or relative to cwd)",
634
+ }),
635
+ }),
636
+ };
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isEmptyUserContent, stripSkillFrontmatter } from "./agent.ts";
3
+
4
+ describe("ui/agent", () => {
5
+ test("stripSkillFrontmatter removes frontmatter and keeps the skill body", () => {
6
+ const content = [
7
+ "---",
8
+ "name: code-review",
9
+ 'description: "Review code for issues"',
10
+ "---",
11
+ "# Review Checklist",
12
+ "- Find bugs",
13
+ "",
14
+ ].join("\n");
15
+
16
+ expect(stripSkillFrontmatter(content)).toBe(
17
+ "# Review Checklist\n- Find bugs\n",
18
+ );
19
+ });
20
+
21
+ test("stripSkillFrontmatter leaves content without frontmatter unchanged", () => {
22
+ expect(stripSkillFrontmatter("# Skill\nUse this carefully\n")).toBe(
23
+ "# Skill\nUse this carefully\n",
24
+ );
25
+ });
26
+
27
+ test("isEmptyUserContent returns true for empty text-only content", () => {
28
+ expect(isEmptyUserContent(" \n\t")).toBe(true);
29
+ expect(
30
+ isEmptyUserContent([
31
+ { type: "text", text: " " },
32
+ { type: "text", text: "\n" },
33
+ ]),
34
+ ).toBe(true);
35
+ });
36
+
37
+ test("isEmptyUserContent returns false when multipart content includes an image", () => {
38
+ expect(
39
+ isEmptyUserContent([
40
+ { type: "text", text: " " },
41
+ {
42
+ type: "image",
43
+ data: Buffer.from("fake-png-data").toString("base64"),
44
+ mimeType: "image/png",
45
+ },
46
+ ]),
47
+ ).toBe(false);
48
+ });
49
+ });