/** * pi-image-placeholder (zentui-compatible) * * Instant path → short [image n] without replacing the editor. * Only pure image-path pastes are rewritten (never "read path.png" / history text). * The real path lives in a hidden TUI-only session mapping (path/mime only), not in editor text. * * - Prototype-patch Editor.insertTextAtCursor (clipboard paste under zentui) * - Terminal path paste / drag-drop via onTerminalInput * - Poll editor text to clear draft widget when placeholders are deleted * - Reuse attachments by resolved path (no [image 2] for same file) * - On submit: attach real image content; keep placeholders in text * * NEVER calls setEditorComponent — that breaks pi-zentui. * - Tool/MCP screenshots: SIXEL preview even when caps.images is null (WT). * - Supports Playwright MCP, agent-browser CLI, and pi-agent-browser-native (`agent_browser`). */ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { platform } from "node:process"; import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent"; import { Editor, Image, Spacer, type Component, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; import { createRequire } from "node:module"; import { thumbnailLinesFromBase64 } from "./halfblock.ts"; import { shouldPreferSixel, sixelThumbnailLines } from "./sixel.ts"; const require = createRequire(import.meta.url); const PASTE_START = "\x1b[200~"; const PASTE_END = "\x1b[201~"; const MAX_IMAGE_BYTES = 64 * 1024 * 1024; const WIDGET_KEY = "image-placeholder-preview"; const PREVIEW_TYPE = "image-placeholder-preview"; // TUI-only hidden mapping. Never store image base64 here — Pi already keeps // image content on the user message; we only map number → path/mime. const STATE_ENTRY_TYPE = "pi-image-placeholder-attachment-v1"; const PATCH_FLAG = Symbol.for("pi-image-placeholder.insertTextAtCursor"); const PLACEHOLDER_RE = /\[image (\d+)\]/g; const POLL_MS = 200; // ─── user settings (two independent switches) ─────────────────────────────── // ~/.pi/agent/image-placeholder.json // { // "wrapPlaceholders": true, // paste → [image N] // "showThumbnails": true // draft/tool SIXEL/half-block previews // } interface ImagePlaceholderSettings { /** Convert pi-clipboard paste paths into short [image N] tokens. */ wrapPlaceholders: boolean; /** Show draft/submitted/tool bitmap previews. */ showThumbnails: boolean; } const DEFAULT_SETTINGS: ImagePlaceholderSettings = { wrapPlaceholders: true, showThumbnails: true, }; function settingsPath(): string { // Keep next to keybindings/settings under the agent dir. return join(homedir(), ".pi", "agent", "image-placeholder.json"); } function loadSettings(): ImagePlaceholderSettings { const path = settingsPath(); try { if (!existsSync(path)) return { ...DEFAULT_SETTINGS }; const raw = JSON.parse(readFileSync(path, "utf8")) as Partial; return { wrapPlaceholders: typeof raw.wrapPlaceholders === "boolean" ? raw.wrapPlaceholders : DEFAULT_SETTINGS.wrapPlaceholders, showThumbnails: typeof raw.showThumbnails === "boolean" ? raw.showThumbnails : DEFAULT_SETTINGS.showThumbnails, }; } catch { return { ...DEFAULT_SETTINGS }; } } function saveSettings(next: ImagePlaceholderSettings): void { const path = settingsPath(); try { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, "utf8"); } catch { // non-fatal } } function parseOnOff(raw: string | undefined): boolean | undefined { if (!raw) return undefined; const v = raw.trim().toLowerCase(); if (["on", "true", "1", "yes", "enable", "enabled"].includes(v)) return true; if (["off", "false", "0", "no", "disable", "disabled"].includes(v)) return false; return undefined; } /** Mutable live settings for this process. */ let settings: ImagePlaceholderSettings = loadSettings(); const IS_IMAGE_LINE_PATCH = Symbol.for("pi-image-placeholder.isImageLine"); /** Pi TUI only treats Kitty/iTerm as image lines; SIXEL must be recognized too or width checks crash. */ function installIsImageLinePatch(): void { try { // Resolve the same module instance pi-tui uses (multiple possible roots). const candidates = [ "@earendil-works/pi-tui/dist/terminal-image.js", // When extension resolves from its own node_modules peer symlink: new URL("../node_modules/@earendil-works/pi-tui/dist/terminal-image.js", import.meta.url).pathname, ]; let mod: { isImageLine?: (line: string) => boolean; [key: symbol]: unknown } | null = null; for (const id of candidates) { try { mod = require(id) as { isImageLine?: (line: string) => boolean; [key: symbol]: unknown }; if (mod && typeof mod.isImageLine === "function") break; } catch { mod = null; } } if (!mod) return; if (!mod || typeof mod.isImageLine !== "function") return; if (mod[IS_IMAGE_LINE_PATCH]) return; const original = mod.isImageLine.bind(mod); mod.isImageLine = (line: string) => { if (original(line)) return true; // DEC SIXEL DCS: ESC P ... q ... ESC \ if (line.includes("P") || line.includes("P")) return true; // Reserved blank rows used for graphics placement if (line === "") return true; return false; }; mod[IS_IMAGE_LINE_PATCH] = true; } catch { // If patch fails, SIXEL previews stay disabled (see renderThumbnail). } } let sixelPatchOk = false; try { installIsImageLinePatch(); sixelPatchOk = true; } catch { sixelPatchOk = false; } const SMART_CLIP_PATCH = Symbol.for("pi-image-placeholder.smartClipboardPaste"); /** * Pi's handleClipboardPaste prefers image over text. On Windows/WSL the clipboard * often still holds a previous screenshot while CF_TEXT has a path. That makes * Ctrl+V/Alt+V paste a pi-clipboard image (then [image N]) instead of the path. * * Prefer non-empty clipboard text when present; only paste image if there is no text. */ function installSmartClipboardPaste(): boolean { try { let imgMod: any = null; let textMod: any = null; for (const id of [ "@earendil-works/pi-coding-agent/dist/utils/clipboard-image.js", new URL( "../node_modules/@earendil-works/pi-coding-agent/dist/utils/clipboard-image.js", import.meta.url, ).pathname, ]) { try { imgMod = require(id); if (imgMod && typeof imgMod.readClipboardImage === "function") break; } catch { imgMod = null; } } for (const id of [ "@earendil-works/pi-coding-agent/dist/utils/clipboard.js", new URL( "../node_modules/@earendil-works/pi-coding-agent/dist/utils/clipboard.js", import.meta.url, ).pathname, ]) { try { textMod = require(id); if (textMod && typeof textMod.readClipboardText === "function") break; } catch { textMod = null; } } if (!imgMod || typeof imgMod.readClipboardImage !== "function") return false; if (!textMod || typeof textMod.readClipboardText !== "function") return false; if (imgMod[SMART_CLIP_PATCH]) return true; const original = imgMod.readClipboardImage.bind(imgMod); imgMod.readClipboardImage = async function patchedReadClipboardImage( ...args: unknown[] ) { try { const text = await textMod.readClipboardText(); const trimmed = typeof text === "string" ? text.trim() : ""; // Any non-empty text wins over a residual image (paths, commands, prose). if (trimmed.length > 0) return undefined; } catch { // fall through to image } return original(...args); }; imgMod[SMART_CLIP_PATCH] = true; return true; } catch { return false; } } let smartClipOk = false; try { smartClipOk = installSmartClipboardPaste(); } catch { smartClipOk = false; } type Mime = "image/png" | "image/jpeg" | "image/webp" | "image/gif"; interface Attachment { id: number; placeholder: string; path: string; mimeType: Mime; data: string; } interface PersistedAttachment { version: 1; id: number; path: string; mimeType: Mime; } function isMime(value: unknown): value is Mime { return ( value === "image/png" || value === "image/jpeg" || value === "image/webp" || value === "image/gif" ); } class Store { private nextId = 1; /** placeholder -> attachment */ private readonly byPlaceholder = new Map(); /** resolved path -> placeholder */ private readonly byPath = new Map(); /** Submitted aliases must survive editor clearing/pruning. */ private readonly committed = new Set(); clear(): void { this.nextId = 1; this.byPlaceholder.clear(); this.byPath.clear(); this.committed.clear(); } list(): Attachment[] { return [...this.byPlaceholder.values()].sort((a, b) => a.id - b.id); } committedList(): Attachment[] { return this.list().filter((a) => this.committed.has(a.placeholder)); } hasDrafts(): boolean { return this.list().some((a) => !this.committed.has(a.placeholder)); } draftList(): Attachment[] { return this.list().filter((a) => !this.committed.has(a.placeholder)); } /** Reuse existing attachment for the same resolved path. */ addOrGet(input: Omit): Attachment { const existingPh = this.byPath.get(input.path); if (existingPh) { const existing = this.byPlaceholder.get(existingPh); if (existing) { // Refresh bytes if file changed. existing.mimeType = input.mimeType; existing.data = input.data; return existing; } } const id = this.nextId++; const att: Attachment = { ...input, id, placeholder: `[image ${id}]`, }; this.byPlaceholder.set(att.placeholder, att); this.byPath.set(input.path, att.placeholder); return att; } /** Restore a hidden session mapping; the editor token stays short. */ restore(value: unknown, cwd: string, imageFallback?: { data: string; mimeType: Mime }): boolean { if (!value || typeof value !== "object") return false; const raw = value as Partial & { data?: string }; if ( raw.version !== 1 || !Number.isInteger(raw.id) || (raw.id ?? 0) <= 0 || typeof raw.path !== "string" || !raw.path || !isMime(raw.mimeType) ) { return false; } const id = raw.id as number; const placeholder = `[image ${id}]`; if (this.byPlaceholder.has(placeholder)) return false; let path = resolvePath(raw.path, cwd); let mimeType = raw.mimeType as Mime; let data = ""; // Prefer live file — no base64 duplication in session state. const loaded = loadImage(raw.path, cwd); if (loaded.ok) { path = loaded.path; mimeType = loaded.mimeType; data = loaded.data; } else if (imageFallback?.data) { // Clipboard temp may be gone; use paired user-message image if available. try { const bytes = Buffer.from(imageFallback.data, "base64"); const detected = detectMime(bytes); if (detected && bytes.length > 0 && bytes.length <= MAX_IMAGE_BYTES) { mimeType = detected; data = imageFallback.data; } } catch { // ignore } } else if (typeof raw.data === "string" && raw.data) { // Legacy entries that embedded data (older experimental builds). try { const bytes = Buffer.from(raw.data, "base64"); const detected = detectMime(bytes); if (detected && bytes.length > 0 && bytes.length <= MAX_IMAGE_BYTES) { mimeType = detected; data = raw.data; } } catch { // ignore } } if (!data) return false; const att: Attachment = { id, placeholder, path, mimeType, data }; this.byPlaceholder.set(placeholder, att); this.byPath.set(path, placeholder); this.committed.add(placeholder); this.nextId = Math.max(this.nextId, id + 1); return true; } get(placeholder: string): Attachment | undefined { return this.byPlaceholder.get(placeholder); } matching(text: string): Attachment[] { const seen = new Set(); const hits: Array<{ att: Attachment; index: number }> = []; for (const match of text.matchAll(PLACEHOLDER_RE)) { const ph = match[0]; if (seen.has(ph)) continue; const att = this.byPlaceholder.get(ph); if (!att) continue; // Unknown alias stays unchanged; never renumber/invent. seen.add(ph); hits.push({ att, index: match.index ?? 0 }); } return hits.sort((a, b) => a.index - b.index).map((h) => h.att); } /** Mark submitted mappings as durable and return only newly committed ones. */ markCommitted(attachments: Attachment[]): Attachment[] { const newly: Attachment[] = []; for (const att of attachments) { if (this.committed.has(att.placeholder)) continue; this.committed.add(att.placeholder); newly.push(att); } return newly; } /** Drop only unsent drafts whose placeholders are gone from the editor. */ pruneToText(text: string): boolean { const live = new Set([...text.matchAll(PLACEHOLDER_RE)].map((m) => m[0])); let changed = false; for (const [ph, att] of [...this.byPlaceholder.entries()]) { if (this.committed.has(ph) || live.has(ph)) continue; this.byPlaceholder.delete(ph); if (this.byPath.get(att.path) === ph) this.byPath.delete(att.path); changed = true; } return changed; } } // ─── helpers ───────────────────────────────────────────────────────────────── function detectMime(bytes: Uint8Array): Mime | undefined { if ( bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 ) { return "image/png"; } if ( bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff ) { return "image/jpeg"; } if ( bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38 ) { return "image/gif"; } if ( bytes.length >= 12 && bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50 ) { return "image/webp"; } return undefined; } function isWsl(): boolean { if (platform !== "linux") return false; if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return true; try { return /microsoft|wsl/i.test(readFileSync("/proc/version", "utf8")); } catch { return false; } } function windowsToWsl(path: string): string { // \\wsl.localhost\Ubuntu-24.04\home\lan\... → /home/lan/... const wslUnc = /^\\\\wsl(?:\.localhost)?\\[^\\]+\\(.*)$/i.exec( path.replace(/\//g, "\\"), ); if (wslUnc) { return `/${wslUnc[1]!.replace(/\\/g, "/")}`; } const m = /^([a-zA-Z]):[\\/](.*)$/.exec(path); if (!m) { if (path.startsWith("\\\\")) return path.replace(/\\/g, "/"); return path; } const drive = m[1]!.toLowerCase(); const rest = m[2]!.replace(/\\/g, "/"); return rest ? `/mnt/${drive}/${rest}` : `/mnt/${drive}`; } /** Normalize file:// URIs, quotes, Windows paths. */ function normalizeIncomingPath(raw: string): string { let s = raw.trim(); // strip surrounding quotes if ( (s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")) ) { s = s.slice(1, -1); } // file URI if (s.startsWith("file://")) { try { const u = new URL(s); // file:///C:/Users/... or file://wsl.localhost/... let pathname = decodeURIComponent(u.pathname); // URL on Windows-style: /C:/Users → C:/Users if (/^\/[a-zA-Z]:\//.test(pathname)) pathname = pathname.slice(1); // file://wsl.localhost/Ubuntu-24.04/home/... if (u.hostname && /wsl/i.test(u.hostname)) { // pathname like /Ubuntu-24.04/home/lan/... const parts = pathname.replace(/^\/+/, "").split("/"); if (parts.length >= 2) { // drop distro name pathname = `/${parts.slice(1).join("/")}`; } } s = pathname; } catch { // keep raw } } return s; } function resolvePath(input: string, cwd: string): string { const normalized = normalizeIncomingPath(input); if (normalized === "~") return homedir(); if (normalized.startsWith("~/")) return resolve(homedir(), normalized.slice(2)); if (/^[a-zA-Z]:[\\/]/.test(normalized) || normalized.startsWith("\\\\")) { if (platform === "win32") return normalized; if (isWsl()) return windowsToWsl(normalized); return normalized; } if (isAbsolute(normalized)) return normalized; return resolve(cwd, normalized); } function shellUnescape(input: string): string { let out = ""; for (let i = 0; i < input.length; i++) { if (input[i] === "\\" && i + 1 < input.length) out += input[++i]!; else out += input[i]!; } return out; } function isPathLike(value: string): boolean { const v = normalizeIncomingPath(value); return ( v.startsWith("/") || v.startsWith("~/") || v === "~" || v.startsWith("./") || v.startsWith("../") || /^[a-zA-Z]:[\\/]/.test(v) || v.startsWith("\\\\") || v.startsWith("file://") ); } function looksLikeImagePath(value: string): boolean { const v = normalizeIncomingPath(value); return /\.(png|jpe?g|gif|webp)$/i.test(v) || /pi-clipboard-/i.test(v); } function loadImage( inputPath: string, cwd: string, ): { ok: true; path: string; mimeType: Mime; data: string } | { ok: false } { const path = resolvePath(inputPath, cwd); try { if (!existsSync(path)) return { ok: false }; const st = statSync(path); if (!st.isFile() || st.size <= 0 || st.size > MAX_IMAGE_BYTES) return { ok: false }; const buf = readFileSync(path); const mimeType = detectMime(buf); if (!mimeType) return { ok: false }; return { ok: true, path, mimeType, data: buf.toString("base64") }; } catch { return { ok: false }; } } interface PathToken { value: string; start: number; end: number; } function tokenizePaths(text: string): PathToken[] { const tokens: PathToken[] = []; let i = 0; while (i < text.length) { const ch = text[i]!; if (/\s/.test(ch)) { i++; continue; } const start = i; // file://... tokens (may contain no spaces usually) if (text.startsWith("file://", i)) { let j = i; while (j < text.length && !/\s/.test(text[j]!)) j++; const value = text.slice(i, j); tokens.push({ value, start, end: j }); i = j; continue; } if (ch === "'" || ch === '"') { const quote = ch; i++; let value = ""; while (i < text.length && text[i] !== quote) { if (text[i] === "\\" && quote === '"' && i + 1 < text.length) { value += text[i + 1]!; i += 2; continue; } value += text[i]!; i++; } if (i < text.length && text[i] === quote) i++; if (isPathLike(value)) tokens.push({ value, start, end: i }); continue; } const win = i + 2 < text.length && /[a-zA-Z]/.test(text[i]!) && text[i + 1] === ":" && (text[i + 2] === "\\" || text[i + 2] === "/"); const unc = text.startsWith("\\\\", i) || text.startsWith("//", i); let raw = ""; while (i < text.length && !/\s/.test(text[i]!)) { // On Windows paths, keep backslashes literal. if (!win && !unc && text[i] === "\\" && i + 1 < text.length) { raw += text[i]! + text[i + 1]!; i += 2; continue; } raw += text[i]!; i++; } const value = win || unc ? raw : shellUnescape(raw); if (isPathLike(value)) tokens.push({ value, start, end: i }); } return tokens; } function replacePaths( text: string, cwd: string, store: Store, ): { text: string; accepted: Attachment[]; replaced: number } { const tokens = tokenizePaths(text); if (tokens.length === 0) return { text, accepted: [], replaced: 0 }; let out = ""; let cursor = 0; let replaced = 0; const accepted: Attachment[] = []; for (const token of tokens) { if (token.start < cursor) continue; if (!looksLikeImagePath(token.value)) continue; const loaded = loadImage(token.value, cwd); if (!loaded.ok) continue; const att = store.addOrGet({ path: loaded.path, mimeType: loaded.mimeType, data: loaded.data, }); accepted.push(att); out += text.slice(cursor, token.start) + att.placeholder; cursor = token.end; replaced++; } if (replaced === 0) return { text, accepted: [], replaced: 0 }; out += text.slice(cursor); return { text: out, accepted, replaced }; } /** * Auto-rewrite is ONLY for Pi clipboard-image temp files from Alt+V / * app.clipboard.pasteImage (paths contain "pi-clipboard-"). * * Never rewrite: * - Ctrl+V of a user-copied absolute path (/home/.../a.png) * - command text: read /tmp/a.png * - history text: read [image 5] * - drag/drop or typed ordinary image paths (keep the real path visible) */ function isClipboardTempImagePath(value: string): boolean { return /pi-clipboard-/i.test(value) && looksLikeImagePath(value); } function shouldAutoRewritePaths(text: string): boolean { if (!settings.wrapPlaceholders) return false; const trimmed = text.trim(); if (!trimmed) return false; // Never touch text that already has placeholders. if (PLACEHOLDER_RE.test(trimmed)) { PLACEHOLDER_RE.lastIndex = 0; return false; } PLACEHOLDER_RE.lastIndex = 0; // Single clipboard temp token (typical Alt+V insert). if (!/\s/.test(trimmed) && isClipboardTempImagePath(trimmed)) return true; // Multiple clipboard temps only (rare multi-image paste). const tokens = tokenizePaths(trimmed).filter((t) => looksLikeImagePath(t.value)); if (tokens.length === 0) return false; if (!tokens.every((t) => isClipboardTempImagePath(t.value))) return false; // Entire payload must be only those clipboard path tokens + whitespace. const covered = Array.from({ length: trimmed.length }, () => false); for (const token of tokens) { for (let i = token.start; i < token.end; i++) covered[i] = true; } for (let i = 0; i < trimmed.length; i++) { if (/\s/.test(trimmed[i]!)) continue; if (!covered[i]) return false; } return true; } function renderThumbnail( att: Attachment, width: number, maxRows: number, fallbackColor: (s: string) => string, ): string[] { const cols = Math.min(56, Math.max(12, width - 2)); const rows = Math.max(4, maxRows); // 1) SIXEL for Windows Terminal / explicit opt-in (readable bitmap). // Lines are prefixed with a Kitty marker so pi-tui isImageLine() skips width checks. if (shouldPreferSixel()) { const six = sixelThumbnailLines(att.data, att.mimeType, cols, rows); if (six && six.lines.length > 0) return six.lines; } // 2) Native Kitty/iTerm if pi-tui detects them try { const img = new Image( att.data, att.mimeType, { fallbackColor }, { maxWidthCells: cols, maxHeightCells: rows, filename: att.placeholder, }, ); const rendered = img.render(width); const joined = rendered.join(""); if (rendered.length > 0 && !joined.includes("[Image:")) return [...rendered]; } catch { // ignore } // 3) Half-block last resort (low fidelity) const thumb = thumbnailLinesFromBase64(att.data, att.mimeType, cols, rows); if (thumb && thumb.length > 0) return thumb; return [fallbackColor(`(preview unavailable; ${att.mimeType})`)]; } /** Runtime status for /image-placeholder-status (local experiment). */ const runtimeStatus = { toolPatch: false as boolean, toolPatchReason: "not-attempted" as string, sixelLinePatch: sixelPatchOk, smartClipPaste: smartClipOk, toolImagesRendered: 0, updateDisplayHits: 0, lastImageBlockCount: 0, lastInjectedCount: 0, lastShowImages: true as boolean | undefined, lastSkipReason: "none" as string, restoredMappings: 0, lastResolvedPaths: [] as string[], lastRawPathCandidates: [] as string[], }; /** Updated on session_start so tool path resolution uses Pi session cwd. */ let activeSessionCwd = process.cwd(); /** SIXEL/half-block component for tool/MCP screenshots when caps.images is null. */ class SixelToolImage implements Component { constructor( private readonly data: string, private readonly mimeType: string, private readonly maxWidthCells: number, private readonly fallbackColor: (s: string) => string, ) {} render(width: number): string[] { const att: Attachment = { id: 0, placeholder: "tool-image", path: "", mimeType: this.mimeType as Mime, data: this.data, }; const lines = renderThumbnail( att, Math.min(width, this.maxWidthCells + 4), 18, this.fallbackColor, ); runtimeStatus.toolImagesRendered += 1; return lines; } invalidate(): void {} } // Path extraction is intentionally STRICT. // Do NOT treat every ls/find/read hit of *.png as a screenshot to render. // Native tool name is agent_browser (underscore). CLI package is agent-browser (hyphen). const SCREENSHOT_TOOL_RE = /(screenshot|playwright|browser_take_screenshot|agent[_-]?browser|puppeteer|browser_)/i; const SCREENSHOT_TEXT_RE = /(screenshot of|take a screenshot|browser_take_screenshot|page\.screenshot|agent[_-]?browser screenshot|Saved image:|Absolute path:|Requested path:|Artifact type:\s*image|"data"\s*:\s*\{\s*"path")/i; const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp)$/i; const SCREENSHOT_NAME_RE = /screenshot|playwright|page-\d*|viewport|full-page|agent[_-]?browser|pi-agent-browser/i; function detectMimeFromPath(filePath: string): Mime | null { const lower = filePath.toLowerCase(); if (lower.endsWith(".png")) return "image/png"; if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; if (lower.endsWith(".gif")) return "image/gif"; if (lower.endsWith(".webp")) return "image/webp"; return null; } function toolResultText(result: any): string { const content = Array.isArray(result?.content) ? result.content : []; const parts: string[] = []; for (const block of content) { if (block && block.type === "text" && typeof block.text === "string") { parts.push(block.text); } } return parts.join("\n"); } function isImagePathCandidate(value: unknown): value is string { return typeof value === "string" && IMAGE_EXT_RE.test(value.trim()); } /** Paths from pi-agent-browser-native details (imagePath / artifacts). */ function collectDetailImagePaths(result: any, push: (raw: string) => void): void { const details = result?.details; if (!details || typeof details !== "object") return; if (isImagePathCandidate(details.imagePath)) push(details.imagePath); if (isImagePathCandidate(details.savedFilePath)) push(details.savedFilePath); if (Array.isArray(details.imagePaths)) { for (const p of details.imagePaths) { if (isImagePathCandidate(p)) push(p); } } const artifacts = Array.isArray(details.artifacts) ? details.artifacts : []; for (const art of artifacts) { if (!art || typeof art !== "object") continue; const kind = String((art as any).kind ?? (art as any).artifactType ?? "").toLowerCase(); const media = String((art as any).mediaType ?? "").toLowerCase(); const looksImage = kind === "image" || media.startsWith("image/") || isImagePathCandidate((art as any).absolutePath) || isImagePathCandidate((art as any).path) || isImagePathCandidate((art as any).requestedPath); if (!looksImage) continue; if (isImagePathCandidate((art as any).absolutePath)) push((art as any).absolutePath); else if (isImagePathCandidate((art as any).path)) push((art as any).path); else if (isImagePathCandidate((art as any).requestedPath)) push((art as any).requestedPath); } } function collectTextImagePaths(textBlock: string, push: (raw: string) => void): void { // 1) Markdown image/file links (Playwright MCP style) // [Screenshot of viewport](./file.png) for (const m of textBlock.matchAll( /\[[^\]]*(?:Screenshot|screenshot|Image|image)[^\]]*\]\(([^)\s]+)\)/g, )) { push(m[1]!); } // Generic markdown link only if path looks like screenshot artifact for (const m of textBlock.matchAll(/\[[^\]]*\]\(([^)\s]+\.(?:png|jpe?g|gif|webp))\)/gi)) { const link = m[1]!; if (SCREENSHOT_NAME_RE.test(link) || /screenshot|viewport|full page/i.test(m[0]!)) { push(link); } } // 2) page.screenshot({ path: '...' }) in code fences for (const m of textBlock.matchAll(/path\s*:\s*['"]([^'"]+\.(?:png|jpe?g|gif|webp))['"]/gi)) { push(m[1]!); } // 3) pi-agent-browser-native prose summary // Saved image: /tmp/foo.png // Absolute path: /tmp/foo.png // Requested path: /tmp/foo.png for (const m of textBlock.matchAll( /(?:Saved image|Absolute path|Requested path|Saved diff image)\s*:\s*(\S+\.(?:png|jpe?g|gif|webp))/gi, )) { push(m[1]!); } // 4) agent-browser / native JSON try { const maybe = JSON.parse(textBlock.trim()); const pathVal = maybe?.data?.path ?? maybe?.path ?? maybe?.imagePath ?? maybe?.absolutePath ?? maybe?.savedFilePath; if (typeof pathVal === "string") push(pathVal); if (Array.isArray(maybe?.imagePaths)) { for (const p of maybe.imagePaths) if (typeof p === "string") push(p); } if (Array.isArray(maybe?.artifacts)) { for (const art of maybe.artifacts) { if (!art || typeof art !== "object") continue; const p = art.absolutePath ?? art.path ?? art.requestedPath; if (typeof p === "string") push(p); } } } catch { // ignore } } function shouldLoadImagesFromPaths(toolName: unknown, result: any): boolean { const name = typeof toolName === "string" ? toolName : ""; if (SCREENSHOT_TOOL_RE.test(name)) return true; // Structured details from pi-agent-browser-native const details = result?.details; if (details && typeof details === "object") { if (isImagePathCandidate(details.imagePath) || isImagePathCandidate(details.savedFilePath)) { return true; } if (Array.isArray(details.imagePaths) && details.imagePaths.some(isImagePathCandidate)) { return true; } if ( Array.isArray(details.artifacts) && details.artifacts.some((art: any) => { if (!art || typeof art !== "object") return false; const kind = String(art.kind ?? art.artifactType ?? "").toLowerCase(); const media = String(art.mediaType ?? "").toLowerCase(); return ( kind === "image" || media.startsWith("image/") || isImagePathCandidate(art.absolutePath) || isImagePathCandidate(art.path) ); }) ) { return true; } } const text = toolResultText(result); if (!text) return false; // Explicit screenshot markers only — never plain directory listings. if (SCREENSHOT_TEXT_RE.test(text)) return true; // agent-browser pure JSON success with path try { const maybe = JSON.parse(text.trim()); if (maybe && maybe.success === true && typeof maybe?.data?.path === "string") { return IMAGE_EXT_RE.test(maybe.data.path); } } catch { // ignore } return false; } function extractImagePathsFromToolResult(result: any, cwdHint?: string): string[] { const found: string[] = []; const push = (raw: string) => { let cleaned = raw.trim().replace(/^['"`]|['"`]$/g, ""); cleaned = cleaned.replace(/^file:\/\/\//i, "/").replace(/^file:\/\//i, ""); cleaned = cleaned.replace(/[)\].,;]+$/g, ""); if (!cleaned || !IMAGE_EXT_RE.test(cleaned)) return; // ignore obvious non-files / globs if (cleaned.includes("*") || cleaned.includes("?")) return; if (!found.includes(cleaned)) found.push(cleaned); }; // Prefer structured details first (pi-agent-browser-native). collectDetailImagePaths(result, push); const content = Array.isArray(result?.content) ? result.content : []; for (const block of content) { if (!block || block.type !== "text" || typeof block.text !== "string") continue; collectTextImagePaths(block.text, push); } runtimeStatus.lastRawPathCandidates = [...found]; const bases = Array.from( new Set( [ cwdHint, activeSessionCwd, process.cwd(), homedir(), resolve(homedir(), "workspace"), resolve(homedir(), "workspace/pi-image-placeholder"), resolve(activeSessionCwd || "", ".playwright-mcp"), resolve(process.cwd(), ".playwright-mcp"), resolve(homedir(), ".playwright-mcp"), ].filter(Boolean) as string[], ), ); const resolved: string[] = []; for (const item of found) { const candidates: string[] = []; if (isAbsolute(item) || /^[A-Za-z]:[\\/]/.test(item)) { candidates.push(item); } else if (item.startsWith("~/")) { candidates.push(resolve(homedir(), item.slice(2))); } else { const rel = item.replace(/^\.\//, ""); for (const base of bases) { candidates.push(resolve(base, rel)); candidates.push(resolve(base, item)); } } const baseName = item.split(/[\\/]/).pop() || item; // basename fallback only for screenshot-ish names, not every foo.png from ls if (SCREENSHOT_NAME_RE.test(baseName)) { for (const base of bases) { candidates.push(resolve(base, baseName)); } } for (const c of candidates) { try { if (existsSync(c) && statSync(c).isFile()) { if (!resolved.includes(c)) resolved.push(c); break; } } catch { // ignore } } } // Hard cap: one screenshot preview per tool card is enough for path mode const capped = resolved.slice(0, 1); runtimeStatus.lastResolvedPaths = [...capped]; return capped; } function loadImageFileAsBlock(filePath: string): { data: string; mimeType: Mime } | null { try { const mime = detectMimeFromPath(filePath); if (!mime) return null; const st = statSync(filePath); if (!st.isFile() || st.size <= 0 || st.size > MAX_IMAGE_BYTES) return null; const data = readFileSync(filePath).toString("base64"); return { data, mimeType: mime }; } catch { return null; } } function dedupeImageBlocks( blocks: Array<{ data: string; mimeType: string; source: string }>, ): Array<{ data: string; mimeType: string; source: string }> { const seen = new Set(); const out: Array<{ data: string; mimeType: string; source: string }> = []; for (const b of blocks) { // path source preferred key; else short hash of data prefix const key = b.source.startsWith("path:") ? b.source : `${b.mimeType}:${b.data.slice(0, 64)}:${b.data.length}`; if (seen.has(key)) continue; seen.add(key); out.push(b); } return out; } const TOOL_IMAGE_PATCH = Symbol.for("pi-image-placeholder.toolExecution.updateDisplay"); /** * Official ToolExecutionComponent only mounts Image when caps.images is set. * On Windows Terminal caps.images is null, so browser/MCP screenshots never show. * After stock updateDisplay, inject SIXEL previews for remaining image blocks. * * Important: when loaded via Pi jiti, `import { ToolExecutionComponent }` is * aliased to the SAME runtime class Pi uses. Call this from session_start. */ function installToolExecutionImagePatch(): boolean { try { const proto = ToolExecutionComponent.prototype as unknown as { updateDisplay?: () => void; [key: symbol]: unknown; }; if (!proto || typeof proto.updateDisplay !== "function") { runtimeStatus.toolPatch = false; runtimeStatus.toolPatchReason = "no-updateDisplay-on-prototype"; return false; } if (proto[TOOL_IMAGE_PATCH]) { runtimeStatus.toolPatch = true; runtimeStatus.toolPatchReason = "already-patched"; return true; } const original = proto.updateDisplay; proto.updateDisplay = function patchedUpdateDisplay(this: any) { original.call(this); runtimeStatus.updateDisplayHits += 1; try { runtimeStatus.lastShowImages = this?.showImages; if (!this?.result) { runtimeStatus.lastSkipReason = "no-result"; runtimeStatus.lastImageBlockCount = 0; runtimeStatus.lastInjectedCount = 0; return; } if (this.showImages === false) { runtimeStatus.lastSkipReason = "showImages-false"; runtimeStatus.lastImageBlockCount = 0; runtimeStatus.lastInjectedCount = 0; return; } if (!settings.showThumbnails) { runtimeStatus.lastSkipReason = "thumbs-off"; runtimeStatus.lastImageBlockCount = 0; runtimeStatus.lastInjectedCount = 0; return; } // Stock path already attached native Image children (Kitty/iTerm). if (Array.isArray(this.imageComponents) && this.imageComponents.length > 0) { runtimeStatus.lastSkipReason = "stock-images-present"; runtimeStatus.lastImageBlockCount = this.imageComponents.length; runtimeStatus.lastInjectedCount = 0; return; } const content = Array.isArray(this.result.content) ? this.result.content : []; const blocks: Array<{ data: string; mimeType: string; source: string }> = content .filter( (c: any) => c && c.type === "image" && typeof c.data === "string" && c.data.length > 0 && typeof c.mimeType === "string", ) .map((c: any) => ({ data: c.data, mimeType: c.mimeType, source: "content-image", })); // Path mode: only for screenshot-like tools/results. // Never turn bash/ls/find/read listings into galleries. if (blocks.length === 0) { if (!shouldLoadImagesFromPaths(this.toolName, this.result)) { runtimeStatus.lastSkipReason = `skip-path-mode:${typeof this.toolName === "string" ? this.toolName : "unknown-tool"}`; runtimeStatus.lastImageBlockCount = 0; runtimeStatus.lastInjectedCount = 0; runtimeStatus.lastRawPathCandidates = []; runtimeStatus.lastResolvedPaths = []; return; } const cwdHint = typeof this.cwd === "string" ? this.cwd : typeof this.sessionCwd === "string" ? this.sessionCwd : activeSessionCwd || process.cwd(); const paths = extractImagePathsFromToolResult(this.result, cwdHint); for (const filePath of paths) { const loaded = loadImageFileAsBlock(filePath); if (!loaded) continue; blocks.push({ data: loaded.data, mimeType: loaded.mimeType, source: `path:${filePath}`, }); } if (blocks.length > 0) { runtimeStatus.lastSkipReason = `loaded-from-path:${blocks.length}`; } } // Dedupe + cap native image blocks too (models sometimes attach duplicates) const unique = dedupeImageBlocks(blocks).slice(0, 2); blocks.length = 0; blocks.push(...unique); runtimeStatus.lastImageBlockCount = blocks.length; if (blocks.length === 0) { runtimeStatus.lastSkipReason = "no-image-blocks-or-paths"; runtimeStatus.lastInjectedCount = 0; return; } if (!Array.isArray(this.imageComponents)) this.imageComponents = []; if (!Array.isArray(this.imageSpacers)) this.imageSpacers = []; const maxW = Math.max(1, Math.floor(this.imageWidthCells ?? 60)); const fallback = (s: string) => s; for (const img of blocks) { const spacer = new Spacer(1); this.addChild(spacer); this.imageSpacers.push(spacer); const comp = new SixelToolImage(img.data, img.mimeType, maxW, fallback); this.imageComponents.push(comp); this.addChild(comp); } runtimeStatus.lastInjectedCount = blocks.length; if (!String(runtimeStatus.lastSkipReason).startsWith("loaded-from-path")) { runtimeStatus.lastSkipReason = "injected"; } if (this.imageComponents.length > 0) { this.hideComponent = false; } } catch (err) { runtimeStatus.lastSkipReason = err instanceof Error ? `error:${err.message}` : "error"; // never break tool UI } }; proto[TOOL_IMAGE_PATCH] = true; runtimeStatus.toolPatch = true; runtimeStatus.toolPatchReason = "patched"; return true; } catch (err) { runtimeStatus.toolPatch = false; runtimeStatus.toolPatchReason = err instanceof Error ? err.message : String(err); return false; } } // ─── widgets ───────────────────────────────────────────────────────────────── class DraftPreviewWidget implements Component { constructor( private readonly attachments: Attachment[], private readonly theme: { title: (s: string) => string; muted: (s: string) => string; }, ) {} render(width: number): string[] { const w = Math.max(1, width); const lines: string[] = []; const header = this.theme.title( this.attachments.length === 1 ? "Image draft (1)" : `Image drafts (${this.attachments.length})`, ); lines.push(visibleWidth(header) > w ? truncateToWidth(header, w) : header); for (const att of this.attachments) { const title = this.theme.title(att.placeholder); const pathLine = this.theme.muted(att.path || "(path unavailable)"); lines.push(visibleWidth(title) > w ? truncateToWidth(title, w) : title); lines.push(visibleWidth(pathLine) > w ? truncateToWidth(pathLine, w) : pathLine); if (settings.showThumbnails) { lines.push(...renderThumbnail(att, w, 14, this.theme.muted)); } } return lines; } invalidate(): void {} } class SubmittedPreview implements Component { constructor( private readonly attachments: Attachment[], private readonly theme: { muted: (s: string) => string; fallback: (s: string) => string; title: (s: string) => string; }, ) {} render(width: number): string[] { const w = Math.max(1, width); const lines: string[] = []; for (const att of this.attachments) { const label = this.theme.title(`Attached ${att.placeholder}`); const pathLine = this.theme.muted(att.path); lines.push(visibleWidth(label) > w ? truncateToWidth(label, w) : label); lines.push( visibleWidth(pathLine) > w ? truncateToWidth(pathLine, w) : pathLine, ); if (settings.showThumbnails) { lines.push(...renderThumbnail(att, w, 16, this.theme.fallback)); } } return lines; } invalidate(): void {} } // ─── extension ─────────────────────────────────────────────────────────────── export default function imagePlaceholder(pi: ExtensionAPI): void { const store = new Store(); let sessionCwd = process.cwd(); let activeUi: ExtensionContext["ui"] | undefined; let pendingPreview: Attachment[] = []; let unsubTerminal: (() => void) | undefined; let pasteBuffer: string | undefined; let removeInsertPatch: (() => void) | undefined; let pollTimer: ReturnType | undefined; let lastEditorText = ""; function getEditorText(): string { if (!activeUi || typeof activeUi.getEditorText !== "function") return ""; try { return activeUi.getEditorText() ?? ""; } catch { return ""; } } function refreshDraftWidget(force = false): void { if (!activeUi) return; const text = getEditorText(); // If editor already contains placeholders, prune orphans safely then match. // If editor text is briefly stale after paste (no [image yet]), do NOT prune — // keep drafts so the widget can still show path + thumbnail. let show: Attachment[]; if (/\[image \d+\]/.test(text)) { store.pruneToText(text); show = store.matching(text); } else if (store.hasDrafts()) { // Stale editor text right after insertTextAtCursor rewrite. show = store.draftList(); } else { show = []; } if (show.length === 0) { activeUi.setWidget(WIDGET_KEY, undefined, { placement: "aboveEditor" }); lastEditorText = text; return; } // Avoid thrashing widget identity every poll if text unchanged. if (!force && text === lastEditorText) return; lastEditorText = text; activeUi.setWidget( WIDGET_KEY, (_tui, theme) => new DraftPreviewWidget(show, { title: (t) => theme.fg("accent", theme.bold(t)), muted: (t) => theme.fg("muted", t), }), { placement: "aboveEditor" }, ); } function startPoll(): void { stopPoll(); pollTimer = setInterval(() => { const text = getEditorText(); if (text === lastEditorText) { // Still prune if store has orphans (e.g. lastEditorText already updated). if (store.hasDrafts()) { const changed = store.pruneToText(text); if (changed) refreshDraftWidget(true); } return; } refreshDraftWidget(true); }, POLL_MS); // Don't keep process alive solely for poll. pollTimer.unref?.(); } function stopPoll(): void { if (pollTimer) { clearInterval(pollTimer); pollTimer = undefined; } } function installInsertTextPatch(): void { if (removeInsertPatch) return; const proto = Editor.prototype as unknown as { insertTextAtCursor: (text: string) => void; [key: symbol]: unknown; }; if (proto[PATCH_FLAG]) return; const original = proto.insertTextAtCursor; if (typeof original !== "function") return; function patchedInsertTextAtCursor(this: unknown, text: string) { let next = text; let didRewrite = false; try { // Only rewrite Pi clipboard temps (pi-clipboard-*). if (shouldAutoRewritePaths(text)) { const transformed = replacePaths(text, sessionCwd, store); if (transformed.replaced > 0) { next = transformed.text; didRewrite = true; } } } catch { // never break typing } const ret = original.call(this, next); // IMPORTANT: refresh AFTER the editor actually contains [image N]. // Refreshing before insert lets pruneToText drop the brand-new mapping, // leaving a bare [image N] with no path/thumbnail. if (didRewrite) { try { refreshDraftWidget(true); } catch { // ignore } queueMicrotask(() => { try { refreshDraftWidget(true); } catch { // ignore } }); } return ret; } proto.insertTextAtCursor = patchedInsertTextAtCursor; proto[PATCH_FLAG] = true; removeInsertPatch = () => { if (proto.insertTextAtCursor === patchedInsertTextAtCursor) { proto.insertTextAtCursor = original; } delete proto[PATCH_FLAG]; removeInsertPatch = undefined; }; } pi.registerMessageRenderer<{ placeholders: string[] }>( PREVIEW_TYPE, (message, _options, theme) => { const placeholders = message.details?.placeholders ?? []; // Prefer live store; if pruned after submit, rebuild is not possible — // keep store entries until session_shutdown so preview can render. const atts = placeholders .map((ph) => store.get(ph)) .filter((a): a is Attachment => Boolean(a)); if (atts.length === 0) return undefined; return new SubmittedPreview(atts, { muted: (t) => theme.fg("muted", t), fallback: (t) => theme.fg("muted", t), title: (t) => theme.fg("accent", theme.bold(t)), }); }, ); pi.on("session_start", (_event, ctx) => { settings = loadSettings(); store.clear(); pendingPreview = []; pasteBuffer = undefined; lastEditorText = ""; sessionCwd = ctx.cwd || process.cwd(); activeSessionCwd = sessionCwd; const branch = ctx.sessionManager.getBranch(); // Pair [image N] in user text with image content blocks from the same message // (ordered). Used only when the original path file is gone (clipboard temps). const imageById = new Map(); for (const entry of branch) { if (entry.type !== "message") continue; const msg = (entry as { message?: any }).message; if (!msg || msg.role !== "user" || !Array.isArray(msg.content)) continue; const texts: string[] = []; const images: Array<{ data: string; mimeType: Mime }> = []; for (const block of msg.content) { if (!block || typeof block !== "object") continue; if (block.type === "text" && typeof block.text === "string") texts.push(block.text); if ( block.type === "image" && typeof block.data === "string" && block.data && isMime(block.mimeType) ) { images.push({ data: block.data, mimeType: block.mimeType }); } } if (images.length === 0) continue; const ids: number[] = []; for (const text of texts) { for (const m of text.matchAll(PLACEHOLDER_RE)) { ids.push(Number(m[1])); } } const n = Math.min(ids.length, images.length); for (let i = 0; i < n; i++) { if (!imageById.has(ids[i]!)) imageById.set(ids[i]!, images[i]!); } } let restoredMappings = 0; for (const entry of branch) { if (entry.type !== "custom" || entry.customType !== STATE_ENTRY_TYPE) continue; const data = entry.data as Partial | undefined; const id = typeof data?.id === "number" ? data.id : undefined; const fallback = id !== undefined ? imageById.get(id) : undefined; if (store.restore(entry.data, sessionCwd, fallback)) restoredMappings++; } runtimeStatus.restoredMappings = restoredMappings; activeUi = ctx.hasUI ? ctx.ui : undefined; unsubTerminal?.(); unsubTerminal = undefined; stopPoll(); installInsertTextPatch(); installToolExecutionImagePatch(); smartClipOk = installSmartClipboardPaste() || smartClipOk; runtimeStatus.smartClipPaste = smartClipOk; if (!ctx.hasUI) return; startPoll(); unsubTerminal = ctx.ui.onTerminalInput((data: string) => { let prefix = ""; if (pasteBuffer === undefined) { const start = data.indexOf(PASTE_START); if (start === -1) { // Raw single-token path (some drag-drop paths). // Only pure path drops — never rewrite mixed command pastes. if (shouldAutoRewritePaths(data)) { const transformed = replacePaths(data, sessionCwd, store); if (transformed.replaced > 0) { refreshDraftWidget(true); return { data: transformed.text }; } } return undefined; } prefix = data.slice(0, start); pasteBuffer = data.slice(start + PASTE_START.length); if (!pasteBuffer.includes(PASTE_END)) { return prefix ? { data: prefix } : { consume: true }; } } else { pasteBuffer += data; if (!pasteBuffer.includes(PASTE_END)) return { consume: true }; } const end = pasteBuffer.indexOf(PASTE_END); const content = pasteBuffer.slice(0, end); const remaining = pasteBuffer.slice(end + PASTE_END.length); pasteBuffer = undefined; // Bracketed paste: only convert pure image path payload. if (!shouldAutoRewritePaths(content)) { return { data: `${prefix}${PASTE_START}${content}${PASTE_END}${remaining}`, }; } const transformed = replacePaths(content, sessionCwd, store); if (transformed.replaced === 0) { return { data: `${prefix}${PASTE_START}${content}${PASTE_END}${remaining}`, }; } refreshDraftWidget(true); return { data: `${prefix}${transformed.text}${remaining}` }; }); }); pi.on("session_shutdown", (_event, ctx) => { pendingPreview = []; pasteBuffer = undefined; unsubTerminal?.(); unsubTerminal = undefined; stopPoll(); activeUi = undefined; lastEditorText = ""; if (ctx.hasUI) { ctx.ui.setWidget(WIDGET_KEY, undefined, { placement: "aboveEditor" }); } store.clear(); }); pi.on("input", async (event, ctx) => { if (event.source === "extension") return { action: "continue" as const }; // Convert leftover clipboard temp paths ONLY (pi-clipboard-*). // Do not rewrite ordinary absolute paths the user pasted with Ctrl+V. const pathPass = shouldAutoRewritePaths(event.text) ? replacePaths(event.text, ctx.cwd, store) : { text: event.text, accepted: [] as Attachment[], replaced: 0 }; const textAfter = pathPass.replaced > 0 ? pathPass.text : event.text; // Prune deleted placeholders so we only attach what's still present. store.pruneToText(textAfter); const attachments = store.matching(textAfter); const newlyCommitted = store.markCommitted(attachments); for (const att of newlyCommitted) { const persisted: PersistedAttachment = { version: 1, id: att.id, path: att.path, mimeType: att.mimeType, }; // TUI-only hidden state; path/mime only — no base64 duplication. pi.appendEntry(STATE_ENTRY_TYPE, persisted); } if (attachments.length === 0 && pathPass.replaced === 0) { if (ctx.hasUI) { ctx.ui.setWidget(WIDGET_KEY, undefined, { placement: "aboveEditor" }); } return { action: "continue" as const }; } if (ctx.hasUI) { ctx.ui.setWidget(WIDGET_KEY, undefined, { placement: "aboveEditor" }); } if (attachments.length > 0) { if (ctx.isIdle()) pendingPreview = attachments; else { pi.sendMessage( { customType: PREVIEW_TYPE, content: `(attached: ${attachments.map((a) => a.placeholder).join(", ")})`, display: true, details: { placeholders: attachments.map((a) => a.placeholder) }, }, { deliverAs: "followUp" }, ); } } const existing = (event.images ?? []) as Array<{ type: "image"; mimeType: string; data: string; }>; const images = [ ...existing, ...attachments.map((a) => ({ type: "image" as const, mimeType: a.mimeType, data: a.data, })), ]; // Keep placeholders in the user text. Do NOT append a fragile // "Attached images:" block (it was producing broken empty lines and // confusing history). Paths are available via the preview message. return { action: "transform" as const, text: textAfter, images, }; }); pi.on("before_agent_start", () => { if (pendingPreview.length === 0) return; const attachments = pendingPreview; pendingPreview = []; return { message: { customType: PREVIEW_TYPE, content: `(attached: ${attachments.map((a) => a.placeholder).join(", ")})`, display: true, details: { placeholders: attachments.map((a) => a.placeholder) }, }, }; }); pi.registerCommand("image-placeholder-status", { description: "Show image-placeholder draft + tool-image patch status", handler: async (_args, ctx) => { const text = getEditorText(); store.pruneToText(text); const list = store.matching(text); const draft = list.length === 0 ? "drafts: none" : `drafts:\n${list.map((a) => ` ${a.placeholder} → ${a.path}`).join("\n")}`; const lines = [ `wrapPlaceholders: ${settings.wrapPlaceholders ? "on" : "off"}`, `showThumbnails: ${settings.showThumbnails ? "on" : "off"}`, `config: ${settingsPath()}`, `source: local ${import.meta.url}`, `toolPatch: ${runtimeStatus.toolPatch} (${runtimeStatus.toolPatchReason})`, `sixelLinePatch: ${runtimeStatus.sixelLinePatch}`, `smartClipPaste: ${runtimeStatus.smartClipPaste}`, `updateDisplayHits: ${runtimeStatus.updateDisplayHits}`, `lastImageBlockCount: ${runtimeStatus.lastImageBlockCount}`, `lastInjectedCount: ${runtimeStatus.lastInjectedCount}`, `lastSkipReason: ${runtimeStatus.lastSkipReason}`, `lastShowImages: ${String(runtimeStatus.lastShowImages)}`, `restoredMappings: ${runtimeStatus.restoredMappings}`, `committedMappings: ${store.committedList().length}`, `sessionCwd: ${activeSessionCwd}`, `lastRawPaths: ${runtimeStatus.lastRawPathCandidates.join(" | ") || "(none)"}`, `lastResolvedPaths: ${runtimeStatus.lastResolvedPaths.join(" | ") || "(none)"}`, `toolImagesRendered: ${runtimeStatus.toolImagesRendered}`, `preferSixel: ${shouldPreferSixel()}`, `WT_SESSION: ${process.env.WT_SESSION ? "set" : "unset"}`, draft, ]; ctx.ui.notify(lines.join("\n"), "info"); }, }); pi.registerCommand("image-placeholder", { description: "Toggle image-placeholder: wrap on|off, thumbs on|off, status, or no args for help", handler: async (args, ctx) => { const parts = (args ?? "").trim().split(/\s+/).filter(Boolean); const sub = (parts[0] ?? "").toLowerCase(); const val = parts[1]; const help = [ "Usage:", " /image-placeholder status", " /image-placeholder wrap on|off # [image N] conversion", " /image-placeholder thumbs on|off # draft/tool thumbnails", " /image-placeholder on # wrap+thumbs both on", " /image-placeholder off # wrap+thumbs both off", `Config file: ${settingsPath()}`, ].join("\n"); if (!sub || sub === "help" || sub === "-h" || sub === "--help") { ctx.ui.notify(help, "info"); return; } if (sub === "status") { ctx.ui.notify( [ `wrapPlaceholders: ${settings.wrapPlaceholders ? "on" : "off"}`, `showThumbnails: ${settings.showThumbnails ? "on" : "off"}`, `config: ${settingsPath()}`, ].join("\n"), "info", ); return; } if (sub === "on" || sub === "off") { const on = sub === "on"; settings = { wrapPlaceholders: on, showThumbnails: on }; saveSettings(settings); ctx.ui.notify( `image-placeholder: wrap=${on ? "on" : "off"}, thumbs=${on ? "on" : "off"}`, "info", ); refreshDraftWidget(true); return; } if (sub === "wrap" || sub === "placeholder" || sub === "placeholders") { const on = parseOnOff(val); if (on === undefined) { ctx.ui.notify( `wrapPlaceholders is ${settings.wrapPlaceholders ? "on" : "off"}\nUsage: /image-placeholder wrap on|off`, "info", ); return; } settings = { ...settings, wrapPlaceholders: on }; saveSettings(settings); ctx.ui.notify(`wrapPlaceholders: ${on ? "on" : "off"}`, "info"); return; } if (sub === "thumbs" || sub === "thumb" || sub === "thumbnail" || sub === "thumbnails" || sub === "preview") { const on = parseOnOff(val); if (on === undefined) { ctx.ui.notify( `showThumbnails is ${settings.showThumbnails ? "on" : "off"}\nUsage: /image-placeholder thumbs on|off`, "info", ); return; } settings = { ...settings, showThumbnails: on }; saveSettings(settings); ctx.ui.notify(`showThumbnails: ${on ? "on" : "off"}`, "info"); refreshDraftWidget(true); return; } ctx.ui.notify(`Unknown args: ${args}\n${help}`, "error"); }, }); // Local-only self-test: inject a tiny PNG through the same thumbnail path. // Does not invoke browser tools; only exercises SIXEL/half-block rendering. pi.registerCommand("image-placeholder-demo", { description: "Render a local demo thumbnail (no browser tools)", handler: async (_args, ctx) => { if (!ctx.hasUI) { ctx.ui.notify("No UI", "error"); return; } // 1x1 red PNG const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; // Prefer a slightly larger generated gradient if available via halfblock path; // SIXEL/halfblock both accept arbitrary PNG base64. const att: Attachment = { id: 999, placeholder: "[image demo]", path: "(demo)", mimeType: "image/png", data, }; const widgetKey = "image-placeholder-demo"; ctx.ui.setWidget( widgetKey, (_tui, theme) => ({ render(width: number) { const title = theme.fg("accent", theme.bold("Demo tool-image preview")); const note = theme.fg( "muted", "If you see a red/colored block below, tool thumbnail path works.", ); const lines = [title, note]; if (settings.showThumbnails) { lines.push( ...renderThumbnail(att, width, 12, (s) => theme.fg("muted", s)), ); } else { lines.push(theme.fg("muted", "(thumbnails off — /image-placeholder thumbs on)")); } return lines; }, invalidate() {}, }) as Component, { placement: "aboveEditor" }, ); ctx.ui.notify( "Demo widget set above editor. Run /image-placeholder-status after looking.", "info", ); }, }); }