diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json
index c6dfc3db8..ef36b9ded 100644
--- a/packages/app-bundle/manifest.json
+++ b/packages/app-bundle/manifest.json
@@ -18,7 +18,7 @@
"packages/opencode/pasqal-live-test.sh": "0bb0089eb413956f0252939f933a139c2f11520466887ad91fcae362f1698e41",
"packages/session-ui/bunfig.toml": "468114e9b52c3cf926d2385c12d93385992cd70c97ea9e32ed22154617b7799d",
"packages/session-ui/happydom.ts": "d81d6b14e988f058fa502c841df34b55307c57fc94a26fb4f8a5deb6aa0d4834",
- "packages/session-ui/package.json": "f354c8675067dc8dd1493924cd6c8f561fb838ff97ae6cbd7e024868cbf0e0e8",
+ "packages/session-ui/package.json": "9661896519610a5909b1ce9d9b9aa66c113704b1c91f288ecf81207cb1a2fc86",
"packages/session-ui/preload-worker-mock.ts": "b53280029a042575734d7bfdf27713f6c5cd6a6665599dc429d839a3e3372bd1",
"packages/ui/package.json": "b1d168d0371e9094faae1107fc6c00be197f09bc69daa2247a3890d607f4b629",
"packages/app/public/amico.svg": "7c64b145bdd063bacd53318336a423f0654ec702de2b4e0c4a4a384694052804",
@@ -108,7 +108,7 @@
"packages/app/src/components/amicode-default-model.test.ts": "941750d074706bd5f4e433c9737b19ebc0ac0f9ba9cd1faaa599a24b22126e05",
"packages/app/src/components/amicode-default-model.tsx": "16a2de1feb12e07416fe5fbb25879391f912ee0909b553c868c1b531f2ed1f2d",
"packages/app/src/components/amicode-defaults-capsule.tsx": "94dc081bc47abb7e50566fcd8e9e53124f9d7632d61f93eff4f1627e0c2efb2d",
- "packages/app/src/components/amicode-file-ref-bridge.tsx": "bd5c295080ccd80fe89208008c02db209ba301e431f61bc4432d3c8f2a72401f",
+ "packages/app/src/components/amicode-file-ref-bridge.tsx": "b1292ac91ad1d07a374251199fc92392a5cf68ee6933083dbf6d3978fceb5d9d",
"packages/app/src/components/connection-banner.tsx": "82194291181165ed7b1b1b3a6801a1bdcd2c2b7cdd733c28486bd69f86477a24",
"packages/app/src/components/dialog-command-palette-v2.css": "85984957adaf15efcc0828310f3a0b438cedaaba776a9fec2eddb3ad83487fa2",
"packages/app/src/components/dialog-command-palette-v2.tsx": "e12da3e5e9e503089dd9139b20e04f6b566cf74fa445f23a2e193f5883f7c5f3",
@@ -511,13 +511,16 @@
"packages/session-ui/src/components/build-trace.test.ts": "6259c529fa667a3514bfee1c771e8f623bcb8c69e37ceb17ea541aa42e207451",
"packages/session-ui/src/components/build-trace.ts": "153e3182c3a37e4aca9c88bf0e2052b0ff4ec117a60d74d2c81567ab91180066",
"packages/session-ui/src/components/group-parts.test.ts": "a31ce19009e1d62e75e83b682190a4613da0eed334420409ed73a9e21834ae84",
+ "packages/session-ui/src/components/image-lightbox.tsx": "7bf691fe5cfa9360d4203de529c547e01d07ea1c284e32fd8b23cd6af08c5cec",
"packages/session-ui/src/components/line-comment-styles.ts": "2077836c13bd3c3f1c39c67722119e448a2dfa123377f94bfd3676634e955d00",
"packages/session-ui/src/components/markdown-file-refs.test.ts": "2172d17a0ee5e49636c8bd323f4fb797a7f41e469d0098864b82d374f0c10144",
"packages/session-ui/src/components/markdown-file-refs.ts": "417417f3660fe18f04a13ca4765b155b5479d7ce41b3638a57ba1abaa5830054",
+ "packages/session-ui/src/components/markdown-local-images.test.ts": "a7fa83953e2393b570b5d3f0533b482b0ca03d8535e35212e402a7dcfdcb8c69",
+ "packages/session-ui/src/components/markdown-local-images.ts": "221facf13ba914b206f029cce51a68a5198957c25c0d66e8d9ac992af1464c39",
"packages/session-ui/src/components/markdown-stream.test.ts": "b73b1a15ffa2cfea5358b2d6313ce17d26a5a11302b0e3b7d823cde0c98cbc83",
"packages/session-ui/src/components/markdown-stream.ts": "50e38abd5d4ff919564655799678be10463dec58502fc5487c1c97a9a8db5399",
- "packages/session-ui/src/components/markdown.css": "24e7c19218620386647656a5d27e116cdeca911f1916c5d5b8609edca11ff165",
- "packages/session-ui/src/components/markdown.tsx": "00a2248ca301e39eb5f2a9b78abfe9d11068ea5daa970516e1f8700c44878c48",
+ "packages/session-ui/src/components/markdown.css": "45c7b31097358841a07b5191110d9651605bd9ac1eec577f60ac73400f6d4f1e",
+ "packages/session-ui/src/components/markdown.tsx": "3448b3db56047656a677c08b8cd68cd9cf490ced3babcda6c2da11aa4d9a2d73",
"packages/session-ui/src/components/message-part-css.test.ts": "bc164695d0195684d646ebf490ba7bb4ed72adcf2bf3d01b00de47f4f03bcd4f",
"packages/session-ui/src/components/message-part-groups.ts": "37be069bf46e926fcde83d3744ee706911ff002e6b532e279290d841aeee96da",
"packages/session-ui/src/components/message-part-skill.test.ts": "453bdcb25e4df91ad1004635b8766070a60421355adf18ee53f43cd2fa7d0486",
@@ -543,7 +546,7 @@
"packages/session-ui/src/components/tool-status-title.tsx": "ae4561b58f55985d2294a352802e723b18943ac9a6551d97f1c4a6cc95368c8a",
"packages/session-ui/src/context/data.tsx": "7b840b5bf6929445d21da2cbd8e8006316c6950a09d559ea77e8c397e728cd99",
"packages/session-ui/src/pierre/worker.ts": "7f210f931777956567eaeea15f2f5478befc7874a64a4b685504c59c66503fee",
- "packages/session-ui/src/util/clipboard.ts": "c37ba53914714ed04135e5a254e8ea90186f097d7470fa0873a83de82f3fef2d",
+ "packages/session-ui/src/util/clipboard.ts": "5048e1b93374c50ee8c15e86470c10a4efbbcf6be2d29b9a7983e43300a490bf",
"packages/ui/src/amicode/activity-dot.tsx": "fc3c9e620cdd204e930f0d53a0a44456e0c886679174c91f0efbf9707daa7bc6",
"packages/ui/src/amicode/activity.test.ts": "1674ca5138780242767b0eb42fc444a9b8ce7868ac119117dd1487b7209180b2",
"packages/ui/src/amicode/activity.ts": "a2c9b4fe27175a400ea0bcfeb433dfcb572cd793766bf0fd232c741feef5cfbf",
@@ -1049,7 +1052,7 @@
"packages/ui/src/v2/components/text-shimmer-v2.tsx"
],
"exceptions": [],
- "extracted_at": "2026-10-28T20:53:06.380Z",
+ "extracted_at": "2026-10-29T12:31:54.003Z",
"per_package": {
"packages/app": {
"A": 272,
@@ -1087,7 +1090,7 @@
"D": 0
},
"packages/session-ui": {
- "A": 45,
+ "A": 48,
"M": 32,
"D": 0
},
@@ -1098,7 +1101,7 @@
}
},
"counts": {
- "overlay_total": 1027,
+ "overlay_total": 1030,
"deletions": 7,
"server_coupled": 0
},
@@ -1606,9 +1609,12 @@
"packages/session-ui/src/components/build-trace.test.ts": "A",
"packages/session-ui/src/components/build-trace.ts": "A",
"packages/session-ui/src/components/group-parts.test.ts": "A",
+ "packages/session-ui/src/components/image-lightbox.tsx": "A",
"packages/session-ui/src/components/line-comment-styles.ts": "M",
"packages/session-ui/src/components/markdown-file-refs.test.ts": "A",
"packages/session-ui/src/components/markdown-file-refs.ts": "A",
+ "packages/session-ui/src/components/markdown-local-images.test.ts": "A",
+ "packages/session-ui/src/components/markdown-local-images.ts": "A",
"packages/session-ui/src/components/markdown-stream.test.ts": "M",
"packages/session-ui/src/components/markdown-stream.ts": "M",
"packages/session-ui/src/components/markdown.css": "M",
diff --git a/packages/app-bundle/overlay/packages/app/src/components/amicode-file-ref-bridge.tsx b/packages/app-bundle/overlay/packages/app/src/components/amicode-file-ref-bridge.tsx
index 27e4245ef..fb3a58e88 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/amicode-file-ref-bridge.tsx
+++ b/packages/app-bundle/overlay/packages/app/src/components/amicode-file-ref-bridge.tsx
@@ -6,6 +6,7 @@
// with the bridge unmounted no resolver is registered and pills stay pills.
import { onCleanup, onMount } from "solid-js"
import { registerFileRefResolver } from "@opencode-ai/session-ui/markdown-file-refs"
+import { registerLocalImageLoader } from "@opencode-ai/session-ui/markdown-local-images"
import { ServerConnection, useServer } from "@/context/server"
import { useGlobal } from "@/context/global"
import { amicodeGet } from "@/utils/amicode-fetch"
@@ -15,22 +16,42 @@ export function AmicodeFileRefBridge() {
const server = useServer()
const global = useGlobal()
let unregister: (() => void) | undefined
+ let unregisterImages: (() => void) | undefined
+ const pick = () =>
+ pickVaultServer({
+ current: server.current,
+ list: server.list,
+ healthy: (c) => global.servers.health[ServerConnection.key(c)]?.healthy === true,
+ })
onMount(() => {
// #1845: keep the unregister handle — it clears only THIS registration,
// so a remounting shell's late cleanup can't wipe its successor's resolver.
unregister = registerFileRefResolver(async (text) => {
- const conn = pickVaultServer({
- current: server.current,
- list: server.list,
- healthy: (c) => global.servers.health[ServerConnection.key(c)]?.healthy === true,
- })
+ const conn = pick()
if (!conn) return null
const raw = (await amicodeGet(conn, `/amicode/resolve-file?path=${encodeURIComponent(text)}`)) as
| { ok?: boolean; found?: boolean; path?: unknown }
| undefined
return raw?.ok === true && raw.found === true && typeof raw.path === "string" ? raw.path : null
})
+ // Chat images authored as ``: read the bytes through the
+ // server (the iframe can't reach its disk, and a bare
can't carry the
+ // credential) and hand session-ui a data: URL.
+ unregisterImages = registerLocalImageLoader(async (path) => {
+ const raw = (await amicodeGet(pick(), `/file/content?path=${encodeURIComponent(path)}`)) as
+ | { type?: string; content?: unknown; encoding?: string; mimeType?: unknown }
+ | undefined
+ if (typeof raw?.content !== "string" || raw.content === "") return null
+ if (raw.type === "binary" && raw.encoding === "base64" && typeof raw.mimeType === "string" && raw.mimeType.startsWith("image/"))
+ return `data:${raw.mimeType};base64,${raw.content}`
+ if (raw.type === "text" && path.toLowerCase().endsWith(".svg"))
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(raw.content)}`
+ return null
+ })
+ })
+ onCleanup(() => {
+ unregister?.()
+ unregisterImages?.()
})
- onCleanup(() => unregister?.())
return null
}
diff --git a/packages/app-bundle/overlay/packages/session-ui/package.json b/packages/app-bundle/overlay/packages/session-ui/package.json
index 4f0901216..c6167263b 100644
--- a/packages/app-bundle/overlay/packages/session-ui/package.json
+++ b/packages/app-bundle/overlay/packages/session-ui/package.json
@@ -13,6 +13,7 @@
"./markdown-stream": "./src/components/markdown-stream.ts",
"./markdown-cache": "./src/components/markdown-cache.tsx",
"./markdown-file-refs": "./src/components/markdown-file-refs.ts",
+ "./markdown-local-images": "./src/components/markdown-local-images.ts",
"./line-comment-styles": "./src/components/line-comment-styles.ts",
"./pierre": "./src/pierre/index.ts",
"./pierre/*": "./src/pierre/*.ts",
diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/image-lightbox.tsx b/packages/app-bundle/overlay/packages/session-ui/src/components/image-lightbox.tsx
new file mode 100644
index 000000000..2091e0edc
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/session-ui/src/components/image-lightbox.tsx
@@ -0,0 +1,51 @@
+// AMICODE: full-size preview for a chat image, with copy. The stock
+// ImagePreview (ui) is close-only; agent-produced figures (equations, plots)
+// are usually opened to be copied into a deck or doc, so the header carries a
+// copy action next to close. Same data-component as ImagePreview so it rides
+// the shared image-preview styles.
+import { Dialog as Kobalte } from "@kobalte/core/dialog"
+import { createSignal, onCleanup } from "solid-js"
+import { useI18n } from "@opencode-ai/ui/context/i18n"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { copyImageToClipboard } from "../util/clipboard"
+
+export function ImageLightbox(props: { src: string; alt?: string }) {
+ const i18n = useI18n()
+ const [copied, setCopied] = createSignal(false)
+ let timer: ReturnType | undefined
+ onCleanup(() => clearTimeout(timer))
+ const copy = async () => {
+ if (!(await copyImageToClipboard(props.src))) return
+ setCopied(true)
+ clearTimeout(timer)
+ timer = setTimeout(() => setCopied(false), 2000)
+ }
+ return (
+
+
+
+
+ void copy()}
+ />
+
+
+
+

+
+
+
+
+ )
+}
diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.test.ts b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.test.ts
new file mode 100644
index 000000000..26459fd46
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.test.ts
@@ -0,0 +1,113 @@
+import { beforeEach, describe, expect, test } from "bun:test"
+import {
+ cachedLocalImage,
+ clearLocalImageCache,
+ loadLocalImageCached,
+ localImageLoaderVersion,
+ localImagePath,
+ frameImages,
+ loadLocalImages,
+ registerLocalImageLoader,
+} from "./markdown-local-images"
+
+const button = () => {
+ const el = document.createElement("div")
+ el.setAttribute("data-slot", "markdown-copy-button")
+ return el
+}
+const PNG = "data:image/png;base64,iVBORw0KGgo="
+
+beforeEach(() => {
+ registerLocalImageLoader(undefined)
+ clearLocalImageCache()
+})
+
+function block(html: string) {
+ const root = document.createElement("div")
+ root.innerHTML = html
+ document.body.appendChild(root)
+ return root
+}
+
+const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
+
+describe("localImagePath", () => {
+ test("absolute posix image paths are local; query and hash are dropped", () => {
+ expect(localImagePath("/home/me/talk/eq 1.png")).toBe("/home/me/talk/eq 1.png")
+ expect(localImagePath("/home/me/talk/eq%201.png?v=2#x")).toBe("/home/me/talk/eq 1.png")
+ expect(localImagePath("file:///tmp/plot.svg")).toBe("/tmp/plot.svg")
+ })
+
+ test("remote, data, protocol-relative, relative and non-image sources are not", () => {
+ expect(localImagePath("https://example.com/a.png")).toBeUndefined()
+ expect(localImagePath(PNG)).toBeUndefined()
+ expect(localImagePath("//cdn.example.com/a.png")).toBeUndefined()
+ expect(localImagePath("plots/a.png")).toBeUndefined()
+ expect(localImagePath("/home/me/notes.md")).toBeUndefined()
+ })
+})
+
+describe("loader registry", () => {
+ test("no loader → null, nothing cached", async () => {
+ expect(await loadLocalImageCached("/a.png")).toBeNull()
+ expect(cachedLocalImage("/a.png")).toBeUndefined()
+ })
+
+ test("loads cache hits and dedupe in flight; misses stay uncached for retry", async () => {
+ let calls = 0
+ registerLocalImageLoader(async (path) => {
+ calls++
+ return path === "/real.png" ? PNG : null
+ })
+ const [a, b] = await Promise.all([loadLocalImageCached("/real.png"), loadLocalImageCached("/real.png")])
+ expect(a).toBe(PNG)
+ expect(b).toBe(PNG)
+ expect(await loadLocalImageCached("/real.png")).toBe(PNG)
+ expect(calls).toBe(1)
+ expect(await loadLocalImageCached("/missing.png")).toBeNull()
+ expect(cachedLocalImage("/missing.png")).toBeUndefined()
+ })
+
+ test("a stale unregister keeps the newer loader; registration bumps the version", async () => {
+ const before = localImageLoaderVersion()
+ const stale = registerLocalImageLoader(async () => null)
+ registerLocalImageLoader(async () => PNG)
+ stale()
+ expect(localImageLoaderVersion()).toBeGreaterThan(before)
+ expect(await loadLocalImageCached("/x.png")).toBe(PNG)
+ })
+})
+
+describe("chat image frames", () => {
+ test("every image is framed with a copy button, once", () => {
+ const root = block('
')
+ frameImages(root, button)
+ frameImages(root, button)
+ const frames = root.querySelectorAll('[data-component="markdown-image"]')
+ expect(frames.length).toBe(1)
+ expect(frames[0]!.querySelector("img")?.getAttribute("src")).toBe("https://example.com/a.png")
+ expect(frames[0]!.querySelectorAll('[data-slot="markdown-copy-button"]').length).toBe(1)
+ })
+
+ test("a local path never reaches the network: src is parked until the loader answers", async () => {
+ let release: (url: string) => void = () => {}
+ registerLocalImageLoader(() => new Promise((resolve) => (release = resolve)))
+ const root = block('
')
+ frameImages(root, button)
+ const img = root.querySelector("img")!
+ expect(img.hasAttribute("src")).toBe(false)
+ expect(img.getAttribute("data-local-src")).toBe("/home/me/eq.png")
+ loadLocalImages(root)
+ release(PNG)
+ await tick()
+ expect(img.getAttribute("src")).toBe(PNG)
+ })
+
+ test("once cached, re-decorating sets the data URL synchronously (no flash on rebuild)", async () => {
+ registerLocalImageLoader(async () => PNG)
+ await loadLocalImageCached("/home/me/eq.png")
+ const root = block('
')
+ frameImages(root, button)
+ expect(root.querySelector("img")?.getAttribute("src")).toBe(PNG)
+ })
+})
diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.ts b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.ts
new file mode 100644
index 000000000..8931c27a8
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.ts
@@ -0,0 +1,124 @@
+// AMICODE: inline previews for images the agent wrote to the server's disk.
+// `` in chat markdown can't load directly: the
+// chat iframe's origin is the server, a bare
can't carry the Basic
+// credential, and file:// is stripped by the sanitizer and blocked by the CSP.
+// The app registers a loader (it owns the server connection + auth) that reads
+// the file through the server and returns a data: URL; the Markdown component
+// swaps it in. With no loader registered (stories, tests, a plain browser
+// embed) local images stay unloaded and show their alt text.
+
+import { createSignal } from "solid-js"
+
+export type LocalImageLoader = (path: string) => Promise
+
+let loader: LocalImageLoader | undefined
+const [version, setVersion] = createSignal(0)
+
+/** Same registration contract as registerFileRefResolver (#1845): the returned
+ * unregister clears ONLY this registration, so a remounting shell's late
+ * cleanup can't wipe its successor. */
+export function registerLocalImageLoader(next: LocalImageLoader | undefined): () => void {
+ loader = next
+ setVersion((v) => v + 1)
+ return () => {
+ if (loader !== next) return
+ loader = undefined
+ setVersion((v) => v + 1)
+ }
+}
+
+export function localImageLoaderVersion(): number {
+ return version()
+}
+
+const IMAGE_EXT = /\.(png|jpe?g|gif|webp|svg|bmp)$/i
+
+/** An authored src that names a file on the server's disk: an absolute posix
+ * path (not a protocol-relative `//host`) or a file:// URL. */
+export function localImagePath(src: string): string | undefined {
+ const value = src.trim()
+ if (/^file:\/\//i.test(value)) {
+ const path = decodeURIComponent(value.replace(/^file:\/\//i, ""))
+ return IMAGE_EXT.test(path) ? path : undefined
+ }
+ if (!value.startsWith("/") || value.startsWith("//")) return undefined
+ const path = decodeURIComponent(value.split(/[?#]/)[0] ?? "")
+ return IMAGE_EXT.test(path) ? path : undefined
+}
+
+// path → data URL (null = known-unloadable). Bounded FIFO by entry count; the
+// images are small (equations, plots) but a session can show many of them.
+const CACHE_MAX = 64
+const cache = new Map()
+const inflight = new Map>()
+
+export function cachedLocalImage(path: string): string | null | undefined {
+ return cache.get(path)
+}
+
+/** Load once, dedupe in-flight. Transient failures stay uncached so the next
+ * render retries (the image may still be being written). */
+export function loadLocalImageCached(path: string): Promise {
+ const hit = cache.get(path)
+ if (hit) return Promise.resolve(hit)
+ const pending = inflight.get(path)
+ if (pending) return pending
+ const load = loader
+ if (!load) return Promise.resolve(null)
+ const p = load(path)
+ .then((url) => {
+ if (!url) return null
+ if (cache.size >= CACHE_MAX) {
+ const oldest = cache.keys().next()
+ if (!oldest.done) cache.delete(oldest.value)
+ }
+ cache.set(path, url)
+ return url
+ })
+ .catch(() => null)
+ .finally(() => inflight.delete(path))
+ inflight.set(path, p)
+ return p
+}
+
+/** Test hook. */
+export function clearLocalImageCache(): void {
+ cache.clear()
+ inflight.clear()
+}
+
+// Every chat image gets a frame with an action button (the caller supplies the
+// copy button). Local-path sources (``) can't load directly in
+// the iframe: park the path in data-local-src and drop the src so no doomed
+// request fires; loadLocalImages fills it in on the live container, and once
+// cached the next pass sets it synchronously, so a morphdom rebuild of the
+// block never flashes the image away. Idempotent.
+export function frameImages(root: HTMLElement, button: () => HTMLElement) {
+ for (const img of Array.from(root.querySelectorAll("img"))) {
+ const local = localImagePath(img.getAttribute("src") ?? "")
+ if (local) {
+ img.setAttribute("data-local-src", local)
+ const cached = cache.get(local)
+ if (cached) img.setAttribute("src", cached)
+ else img.removeAttribute("src")
+ }
+ if (img.parentElement?.getAttribute("data-component") === "markdown-image") continue
+ const frame = document.createElement("span")
+ frame.setAttribute("data-component", "markdown-image")
+ img.parentNode?.replaceChild(frame, img)
+ frame.appendChild(img)
+ frame.appendChild(button())
+ }
+}
+
+/** Live-container pass: load every parked local image that has no src yet. */
+export function loadLocalImages(root: HTMLElement) {
+ for (const img of Array.from(root.querySelectorAll("img[data-local-src]:not([src])"))) {
+ const path = img.getAttribute("data-local-src")
+ if (!path) continue
+ void loadLocalImageCached(path).then((url) => {
+ if (!url || !img.isConnected || img.getAttribute("data-local-src") !== path) return
+ img.src = url
+ })
+ }
+}
diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.css b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.css
index f1871c1ad..dea45f669 100644
--- a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.css
+++ b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.css
@@ -309,6 +309,35 @@
margin: 1.5rem 0;
display: block;
}
+
+ /* AMICODE: chat image frame — click to preview, hover to copy. The paper
+ backing is deliberate, not a theme color: agent figures (equations, plots)
+ are usually transparent PNGs inked for a light slide, and vanish on a dark
+ chat background. The copied image keeps its transparency. */
+ [data-component="markdown-image"] {
+ position: relative;
+ display: block;
+ width: fit-content;
+ max-width: 100%;
+ margin: 1.5rem 0;
+ padding: 8px;
+ border-radius: var(--radius-sm);
+ background: #fff;
+
+ img {
+ margin: 0;
+ cursor: zoom-in;
+ }
+
+ img:not([src]) {
+ width: 160px;
+ height: 48px;
+ }
+ }
+
+ [data-component="markdown-image"]:hover [data-slot="markdown-copy-button"] {
+ opacity: 1;
+ }
}
body[data-new-layout] [data-component="markdown"] {
diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.tsx b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.tsx
index 502107dbf..53a1a7a9b 100644
--- a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.tsx
+++ b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.tsx
@@ -1,6 +1,6 @@
import { useMarked } from "@opencode-ai/ui/context/marked"
import { useI18n } from "@opencode-ai/ui/context/i18n"
-import { copyTextToClipboard } from "../util/clipboard"
+import { copyImageToClipboard, copyTextToClipboard } from "../util/clipboard"
import { normalizeDisplayMath } from "./markdown-stream"
import morphdom from "morphdom"
import { checksum } from "@opencode-ai/core/util/encode"
@@ -34,6 +34,9 @@ import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-s
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
import { inlineCodeKind } from "./markdown-inline-code-kind"
import { cachedFileRef, fileRefResolver, fileRefResolverVersion, fileRefUrl, resolveFileRefCached } from "./markdown-file-refs"
+import { frameImages, loadLocalImages, localImageLoaderVersion } from "./markdown-local-images"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { ImageLightbox } from "./image-lightbox"
type RenderedBlock =
| (MarkdownCacheEntry & { key: string; mode: Exclude })
@@ -343,6 +346,24 @@ function decorate(root: HTMLDivElement, labels: CopyLabels) {
// linkification (markFileLinks consumes data-inline-code-kind) — a core
// capability, not a layout preference. Runs unconditionally.
markInlineCode(root)
+ // AMICODE: chat image frames (preview + copy) — see markdown-local-images.
+ frameImages(root, () => createCopyButton(labels))
+}
+
+// Clicking a chat image opens it full-size (with copy). Optional dialog: a
+// Markdown rendered outside a DialogProvider (stories, embeds) keeps images inert.
+function setupImagePreview(root: HTMLDivElement, open: ((src: string, alt: string) => void) | undefined) {
+ if (!open) return
+ const handleClick = (event: MouseEvent) => {
+ const target = event.target
+ if (!(target instanceof HTMLImageElement)) return
+ if (target.closest('[data-component="markdown-image"]') === null) return
+ if (!target.src) return
+ event.preventDefault()
+ open(target.src, target.alt)
+ }
+ root.addEventListener("click", handleClick)
+ return () => root.removeEventListener("click", handleClick)
}
// Issue #270: Mark regular markdown links as external so they open via the bridge
@@ -373,11 +394,17 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
const button = target.closest('[data-slot="markdown-copy-button"]')
if (!(button instanceof HTMLElement)) return
- const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
- const content = code?.textContent ?? ""
- if (!content) return
- // Amicode webview: navigator.clipboard dies in the chat iframe — bridge-aware write.
- if (!(await copyTextToClipboard(content))) return
+ const image = button.closest('[data-component="markdown-image"]')?.querySelector("img")
+ if (image) {
+ if (!image.src || !(await copyImageToClipboard(image.src))) return
+ }
+ if (!image) {
+ const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
+ const content = code?.textContent ?? ""
+ if (!content) return
+ // Amicode webview: navigator.clipboard dies in the chat iframe — bridge-aware write.
+ if (!(await copyTextToClipboard(content))) return
+ }
const labels = getLabels()
setCopyState(button, labels, true)
const existing = timeouts.get(button)
@@ -468,6 +495,10 @@ export function Markdown(
const i18n = useI18n()
const [root, setRoot] = createSignal()
const owner = createUniqueId()
+ const dialog = optionalDialog()
+ const openImage = dialog
+ ? (src: string, alt: string) => dialog.show(() => )
+ : undefined
const activeCodeKeys = new Set()
const completedCode = new Map>()
const projection = createMemo((previous: Projection | undefined) =>
@@ -557,6 +588,7 @@ export function Markdown(
let copyCleanup: (() => void) | undefined
let linkCleanup: (() => void) | undefined
+ let imageCleanup: (() => void) | undefined
createEffect(() => {
const container = root()
@@ -597,12 +629,20 @@ export function Markdown(
copied: i18n.t("ui.message.copied"),
}))
if (!linkCleanup) linkCleanup = setupExternalLinks(container)
- if (!linkCleanup) linkCleanup = setupExternalLinks(container)
+ if (!imageCleanup) imageCleanup = setupImagePreview(container, openImage)
// #1839: file-reference linkification is core, not a layout preference —
// runs unconditionally (the pills it consumes are tagged unconditionally
// in decorate). The live report: with data-new-layout off, every file
// link in chat was silently inert.
markFileLinks(container)
+ loadLocalImages(container)
+ })
+
+ // A loader registered after this message rendered loads its local images.
+ createEffect(() => {
+ localImageLoaderVersion()
+ const container = root()
+ if (container) loadLocalImages(container)
})
// #1845: a resolver registered after this message rendered re-linkifies it
@@ -616,6 +656,7 @@ export function Markdown(
onCleanup(() => {
if (copyCleanup) copyCleanup()
if (linkCleanup) linkCleanup()
+ if (imageCleanup) imageCleanup()
activeCodeKeys.forEach(disposeCode)
completedCode.clear()
})
@@ -795,3 +836,13 @@ function createTokenSpan(token: MarkdownToken) {
span.textContent = token[0]
return span
}
+
+function optionalDialog() {
+ // useDialog throws outside a DialogProvider; Markdown also renders in
+ // provider-less hosts (stories, the widget frame), where previews are off.
+ try {
+ return useDialog()
+ } catch {
+ return undefined
+ }
+}
diff --git a/packages/app-bundle/overlay/packages/session-ui/src/util/clipboard.ts b/packages/app-bundle/overlay/packages/session-ui/src/util/clipboard.ts
index 87f3a8524..b3948c4a9 100644
--- a/packages/app-bundle/overlay/packages/session-ui/src/util/clipboard.ts
+++ b/packages/app-bundle/overlay/packages/session-ui/src/util/clipboard.ts
@@ -32,3 +32,43 @@ export async function copyTextToClipboard(text: string): Promise {
() => false,
)
}
+
+// Image copy. The OS clipboard belongs to the VS Code extension host, which
+// runs on the machine the user is sitting at (an interface machine, not
+// necessarily the server that rendered the image), so inside the chat iframe
+// the PNG rides the bridge as "clipboard-image-write" and the host writes it
+// natively (vscode.env.clipboard is text-only). Unframed, the async Clipboard
+// API stands. Non-PNG sources are rasterized first: PNG is the one image type
+// every clipboard target (Slides, Keynote, Docs) accepts.
+export async function copyImageToClipboard(src: string): Promise {
+ const png = await pngDataUrl(src)
+ if (!png) return false
+ if (typeof window !== "undefined" && window.parent !== window) {
+ window.parent.postMessage({ source: "amicode", kind: "clipboard-image-write", dataUrl: png }, "*")
+ return true // fire-and-forget, like copyTextToClipboard
+ }
+ const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
+ if (!clipboard?.write || typeof ClipboardItem === "undefined") return false
+ const blob = await fetch(png).then((res) => res.blob())
+ return clipboard.write([new ClipboardItem({ "image/png": blob })]).then(
+ () => true,
+ () => false,
+ )
+}
+
+async function pngDataUrl(src: string): Promise {
+ if (src.startsWith("data:image/png;base64,")) return src
+ if (typeof document === "undefined") return undefined
+ const image = new Image()
+ image.src = src
+ const loaded = await image.decode().then(
+ () => true,
+ () => false,
+ )
+ if (!loaded || !image.naturalWidth) return undefined
+ const canvas = document.createElement("canvas")
+ canvas.width = image.naturalWidth
+ canvas.height = image.naturalHeight
+ canvas.getContext("2d")?.drawImage(image, 0, 0)
+ return canvas.toDataURL("image/png")
+}
diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts
index 1fd62a3e5..88be5b73b 100644
--- a/packages/extension/src/chat_bridge.ts
+++ b/packages/extension/src/chat_bridge.ts
@@ -569,6 +569,25 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean
return true;
}
+ // Image copy bridge (chat image frame + preview "Copy"): vscode.env.clipboard
+ // is text-only, so write the PNG with the platform tool. This handler runs in
+ // the extension host — on the machine the user is sitting at — which is what
+ // makes the paste land there even when the image was rendered on a remote
+ // server. PNG only (the iframe rasterizes anything else), bounded like
+ // save-file; the result is echoed for the nonce'd caller.
+ if (msg.kind === "clipboard-image-write" && typeof (msg as { dataUrl?: unknown }).dataUrl === "string") {
+ if (!io.visible()) return true;
+ const raw = msg as unknown as { dataUrl: string; nonce?: string; tab?: string };
+ const prefix = "data:image/png;base64,";
+ if (!raw.dataUrl.startsWith(prefix)) return true;
+ const base64 = raw.dataUrl.slice(prefix.length);
+ if (!base64 || base64.length > 24_000_000) return true;
+ void writeClipboardImageNative(Buffer.from(base64, "base64")).then((ok) => {
+ io.postToWebview({ source: "amicode", kind: "clipboard-image-written", nonce: raw.nonce, tab: raw.tab, ok });
+ });
+ return true;
+ }
+
// Clipboard image bridge: the outer webview's navigator.clipboard.read()
// requires user activation (a keystroke on that document), but the user typed
// inside the sandboxed iframe — so the async Clipboard API throws in newer
@@ -1640,6 +1659,66 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean
return false;
}
+// ---------------------------------------------------------------------------
+// Native clipboard image writer
+// ---------------------------------------------------------------------------
+// Puts a PNG on the system clipboard via a temp file and the platform tool.
+// The path is passed as an argument (never spliced into a script). Resolves
+// false on any failure or an unsupported platform.
+
+export async function writeClipboardImageNative(png: Buffer): Promise {
+ const command = clipboardImageWriteCommand(process.platform, process.env);
+ if (!command) return false;
+ const file = path.join(os.tmpdir(), `amicode-copy-${process.pid}-${Date.now()}.png`);
+ fs.writeFileSync(file, png);
+ const ok = await runToExit(command.cmd, [...command.args, file]);
+ // xclip/wl-copy fork a selection server that keeps the bytes in memory, but
+ // give it a moment to finish reading before the temp file goes.
+ setTimeout(() => fs.rmSync(file, { force: true }), 5000);
+ return ok;
+}
+
+/** The writer for this host; the PNG path is appended as the last argument. */
+export function clipboardImageWriteCommand(
+ platform: NodeJS.Platform,
+ env: NodeJS.ProcessEnv,
+): { cmd: string; args: string[] } | null {
+ if (platform === "darwin")
+ return {
+ cmd: "osascript",
+ args: ["-e", "on run argv", "-e", "set the clipboard to (read (POSIX file (item 1 of argv)) as «class PNGf»)", "-e", "end run"],
+ };
+ if (platform === "linux" && env.WAYLAND_DISPLAY)
+ return { cmd: "sh", args: ["-c", 'exec wl-copy --type image/png < "$1"', "amicode-copy"] };
+ if (platform === "linux") return { cmd: "xclip", args: ["-selection", "clipboard", "-t", "image/png", "-i"] };
+ // win32 is served through WSL (the linux branch); a native Windows host has no writer.
+ return null;
+}
+
+// Resolve on the launched process's own exit, not on stdio close: xclip and
+// wl-copy daemonize a child that inherits stdio, so execFile would hang until
+// its timeout and report failure for a copy that worked.
+function runToExit(cmd: string, args: string[]): Promise {
+ return import("node:child_process").then(
+ ({ spawn }) =>
+ new Promise((resolve) => {
+ const child = spawn(cmd, args, { stdio: "ignore" });
+ const timer = setTimeout(() => {
+ child.kill();
+ resolve(false);
+ }, 5000);
+ child.on("error", () => {
+ clearTimeout(timer);
+ resolve(false);
+ });
+ child.on("exit", (code) => {
+ clearTimeout(timer);
+ resolve(code === 0);
+ });
+ }),
+ );
+}
+
// ---------------------------------------------------------------------------
// Native clipboard image reader
// ---------------------------------------------------------------------------
diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts
index f95edda00..55c7d56ec 100644
--- a/packages/extension/src/chat_panel.ts
+++ b/packages/extension/src/chat_panel.ts
@@ -700,7 +700,7 @@ export class ChatPanel implements LaunchTarget {
vscode.postMessage(d);
return;
}
- if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex" || d.kind === "fleet-enable-control")) {
+ if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "clipboard-image-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex" || d.kind === "fleet-enable-control")) {
vscode.postMessage(d);
}
return;
@@ -871,7 +871,7 @@ export class ChatPanel implements LaunchTarget {
vscode.postMessage(d);
return;
}
- if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex" || d.kind === "fleet-enable-control")) {
+ if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "clipboard-image-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex" || d.kind === "fleet-enable-control")) {
vscode.postMessage(d);
}
return;
diff --git a/packages/extension/test/chat_bridge.test.ts b/packages/extension/test/chat_bridge.test.ts
index 3057987d0..5939ddd62 100644
--- a/packages/extension/test/chat_bridge.test.ts
+++ b/packages/extension/test/chat_bridge.test.ts
@@ -4,6 +4,7 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
+ clipboardImageWriteCommand,
extractReportBugModel,
handleAmicodeBridgeMessage,
rebuildAppBundleStep,
@@ -385,6 +386,38 @@ describe("amicode bridge — clipboard", () => {
});
});
+describe("amicode bridge — clipboard-image-write", () => {
+ it("writes on the host the user sits at and answers the nonce'd caller", async () => {
+ const host = io();
+ const png = "data:image/png;base64,iVBORw0KGgo=";
+ expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "clipboard-image-write", dataUrl: png, nonce: "c1", tab: "pane-2" }, host)).toBe(true);
+ await vi.waitFor(() => expect(host.posted).toHaveLength(1));
+ const reply = host.posted[0] as { kind: string; nonce: string; tab: string; ok: unknown };
+ expect(reply).toMatchObject({ source: "amicode", kind: "clipboard-image-written", nonce: "c1", tab: "pane-2" });
+ expect(typeof reply.ok).toBe("boolean");
+ });
+
+ it("drops non-PNG payloads, oversize payloads, and hidden panels", async () => {
+ const hidden = io(false);
+ expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "clipboard-image-write", dataUrl: "data:image/png;base64,AA==" }, hidden)).toBe(true);
+ const host = io();
+ expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "clipboard-image-write", dataUrl: "data:image/svg+xml;base64,AA==" }, host)).toBe(true);
+ expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "clipboard-image-write", dataUrl: "data:image/png;base64," + "A".repeat(24_000_001) }, host)).toBe(true);
+ await flush();
+ expect(hidden.posted).toHaveLength(0);
+ expect(host.posted).toHaveLength(0);
+ });
+
+ it("picks the platform writer and never splices the path into a script", () => {
+ const mac = clipboardImageWriteCommand("darwin", {});
+ expect(mac?.cmd).toBe("osascript");
+ expect(mac?.args.join(" ")).toContain("item 1 of argv");
+ expect(clipboardImageWriteCommand("linux", {})).toEqual({ cmd: "xclip", args: ["-selection", "clipboard", "-t", "image/png", "-i"] });
+ expect(clipboardImageWriteCommand("linux", { WAYLAND_DISPLAY: "wayland-0" })?.args[1]).toContain("wl-copy --type image/png");
+ expect(clipboardImageWriteCommand("win32", {})).toBeNull();
+ });
+});
+
describe("amicode bridge — connect-harmoniqs-provider", () => {
it("hands a keyless connect off to the onboarding panel and acks so the picker closes (amicode#962)", async () => {
// The branded picker row (dialog-connect-provider-harmoniqs.ts) posts with