import { existsSync } from "node:fs"; import { open } from "node:fs/promises"; import { basename, resolve } from "node:path"; import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import { applyVisibleReplacements, buildVisibleMap, type VisibleReplacement } from "./ansi.js"; import { IMAGE_EXTENSIONS, IMAGE_PATH_PATTERN } from "./constants.js"; import { expandHome } from "./package-config.js"; import { settingBoolean } from "./settings.js"; function stripAtPrefix(path: string): string { return path.startsWith("@") ? path.slice(1) : path; } export function resolveMaybeImagePath(path: string, cwd: string): string | undefined { const clean = stripAtPrefix(path); const expanded = expandHome(clean); const resolved = expanded.startsWith("/") ? expanded : resolve(cwd, expanded); const lower = resolved.toLowerCase(); const dot = lower.lastIndexOf("."); if (dot < 0 || !IMAGE_EXTENSIONS.has(lower.slice(dot))) return undefined; if (!existsSync(resolved)) return undefined; return resolved; } function imagePathLabels(text: string, cwd: string): string[] { const seen = new Set(); for (const match of text.matchAll(IMAGE_PATH_PATTERN)) { const resolved = resolveMaybeImagePath(match[2] ?? "", cwd); if (resolved) seen.add(`Image ${basename(resolved)}`); } return [...seen].sort(); } function mimeTypeForPath(path: string): string { const lower = path.toLowerCase(); if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; if (lower.endsWith(".gif")) return "image/gif"; if (lower.endsWith(".webp")) return "image/webp"; if (lower.endsWith(".bmp")) return "image/bmp"; if (lower.endsWith(".tif") || lower.endsWith(".tiff")) return "image/tiff"; if (lower.endsWith(".heic")) return "image/heic"; if (lower.endsWith(".heif")) return "image/heif"; return "image/png"; } /** Submitted image paths and clipboard images share this raw-byte budget. */ export const SUBMITTED_IMAGES_MAX_BYTES = 20 * 1024 * 1024; /** Stat every open file before reading any of them. Read only the observed * bytes and one growth probe, so a growing file cannot bypass the budget. */ export async function imageContentsForPaths(paths: string[], existing: Array<{ data: string }> = []): Promise> { const files: Array<{ path: string; handle: Awaited>; size: number }> = []; let total = existing.reduce((sum, image) => sum + Buffer.byteLength(image.data, "base64"), 0); const checkSize = () => { if (total > SUBMITTED_IMAGES_MAX_BYTES) throw new Error(`IMAGE_SIZE_LIMIT: ${total} bytes exceeds ${SUBMITTED_IMAGES_MAX_BYTES}`); }; try { checkSize(); for (const path of paths) { const handle = await open(path, "r"); const file = { path, handle, size: 0 }; files.push(file); const stat = await handle.stat(); if (!stat.isFile()) throw new Error(`IMAGE_NOT_FILE: ${path}`); file.size = stat.size; total += stat.size; checkSize(); } const buffers: Buffer[] = []; for (const file of files) { const buffer = Buffer.alloc(file.size + 1); let offset = 0; while (offset < buffer.length) { const { bytesRead } = await file.handle.read(buffer, offset, buffer.length - offset, offset); if (bytesRead === 0) break; offset += bytesRead; } if (offset > file.size) throw new Error(`IMAGE_SIZE_CHANGED: ${file.path}`); buffers.push(buffer.subarray(0, offset)); } return files.map((file, index) => ({ type: "image", data: buffers[index]!.toString("base64"), mimeType: mimeTypeForPath(file.path) })); } finally { await Promise.all(files.map((file) => file.handle.close())); } } export function attachmentLabels(text: string, cwd = process.cwd()): string[] { const seen = new Set(); for (const match of text.matchAll(/\[Image\s+#(\d+)\]/gi)) { seen.add(`Image #${match[1]}`); } for (const label of imagePathLabels(text, cwd)) seen.add(label); return [...seen].sort((a, b) => Number(a.replace(/\D/g, "")) - Number(b.replace(/\D/g, "")) || a.localeCompare(b)); } function chip(label: string, theme?: Theme): string { const text = ` ${label} `; if (theme) return theme.fg("accent", theme.inverse(text)); return `[${label}]`; } function imageChipReplacements(visibleText: string, cwd: string, theme?: Theme): VisibleReplacement[] { const replacements: VisibleReplacement[] = []; for (const match of visibleText.matchAll(/\[Image\s+#(\d+)\]/gi)) { const start = match.index ?? 0; replacements.push({ start, end: start + match[0].length, text: chip(`Image #${match[1]}`, theme), }); } let imageIndex = 0; for (const match of visibleText.matchAll(IMAGE_PATH_PATTERN)) { const prefix = match[1] ?? ""; const rawPath = match[2] ?? ""; const resolved = resolveMaybeImagePath(rawPath, cwd); if (!resolved) continue; imageIndex += 1; const start = (match.index ?? 0) + prefix.length; replacements.push({ start, end: start + rawPath.length, text: chip(`Image ${imageIndex}`, theme), }); } return replacements; } export function styleImageChips(line: string, cwd: string, theme?: Theme): string { if (!settingBoolean("showImageChips", true, cwd)) return line; const map = buildVisibleMap(line); const replacements = imageChipReplacements(map.text, cwd, theme); return replacements.length === 0 ? line : applyVisibleReplacements(line, map, replacements); } export function statusText(ctx: ExtensionContext, text: string): string | undefined { if (!settingBoolean("showAttachmentCountInStatus", true, ctx.cwd)) return undefined; const count = attachmentLabels(text, ctx.cwd).length; return count > 0 ? `images:${count}` : undefined; } /// Resolve every image-path-shaped substring in submitted text to an /// absolute file path. Used by the `input` event to replace the previous /// approach of mutating the editor buffer to `[Image #N]` aliases via a /// 250ms polling timer (which raced with resize/input handling). export function resolveSubmittedImagePaths(text: string, cwd: string): string[] { const paths = new Set(); for (const match of text.matchAll(IMAGE_PATH_PATTERN)) { const resolved = resolveMaybeImagePath(match[2] ?? "", cwd); if (resolved) paths.add(resolved); } return [...paths]; }