Skip to content

Commit 5c62560

Browse files
author
Conor Nash
committed
feat: display inline images from tool results in chat buffer
When a tool (e.g. read) returns image content blocks alongside text, the images are now rendered inline inside the tool block overlay. In GUI Emacs: decoded and displayed via create-image/insert-image with max-width/max-height scaling to fit the window. In terminal Emacs: a text placeholder showing MIME type and size. Implementation: - New struct slot: tool-block.image-blocks stores content for toggle - pi-coding-agent--mime-to-image-type: MIME string to Emacs type symbol - pi-coding-agent--insert-inline-image: decode + insert or placeholder - pi-coding-agent--insert-tool-images: iterate content, insert images - display-tool-end: calls insert-tool-images after text rendering - toggle-tool-output: re-inserts images from stored blocks after toggle Handles: corrupt base64, unknown MIME types, empty data, vectors and lists, multiple image blocks, image-only results (no text). 12 new tests covering all paths. 942/942 full suite passes.
1 parent 3a27de8 commit 5c62560

2 files changed

Lines changed: 238 additions & 1 deletion

File tree

pi-coding-agent-render.el

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1383,7 +1383,8 @@ are left alone."
13831383
path
13841384
offset
13851385
line-map
1386-
last-tail)
1386+
last-tail
1387+
image-blocks)
13871388

13881389
(defun pi-coding-agent--ensure-live-tool-blocks ()
13891390
"Return the live tool block registry for the current buffer."
@@ -1524,6 +1525,67 @@ needed for compatibility, the current non-keyed pending block."
15241525
(pi-coding-agent--tool-block-refresh-overlay block))
15251526
block)
15261527

1528+
(defun pi-coding-agent--tool-block-set-image-blocks (block image-blocks)
1529+
"Store IMAGE-BLOCKS on BLOCK for re-insertion after toggle."
1530+
(when block
1531+
(setf (pi-coding-agent--tool-block-image-blocks block) image-blocks))
1532+
block)
1533+
1534+
(defun pi-coding-agent--mime-to-image-type (mime-type)
1535+
"Convert MIME-TYPE string to Emacs image type symbol.
1536+
Returns nil for unsupported types."
1537+
(pcase mime-type
1538+
((or "image/jpeg" "image/jpg") 'jpeg)
1539+
("image/png" 'png)
1540+
("image/gif" 'gif)
1541+
("image/webp" 'webp)
1542+
(_ nil)))
1543+
1544+
(defun pi-coding-agent--insert-inline-image (data mime-type)
1545+
"Insert inline image decoded from base64 DATA with MIME-TYPE.
1546+
In GUI Emacs with the required image type support, decodes DATA and
1547+
inserts a scaled image via `insert-image'. Otherwise inserts a text
1548+
placeholder. Returns non-nil if a graphical image was inserted."
1549+
(condition-case nil
1550+
(let* ((type (pi-coding-agent--mime-to-image-type mime-type))
1551+
(gui-p (and type
1552+
(display-images-p)
1553+
(image-type-available-p type)))
1554+
(raw (base64-decode-string data)))
1555+
(if (and gui-p (> (length raw) 0))
1556+
(let ((img (create-image raw type t
1557+
:max-width (truncate
1558+
(* 0.9 (max 400 (window-pixel-width))))
1559+
:max-height (truncate
1560+
(* 0.5 (max 300 (window-pixel-height)))))))
1561+
(insert-image img "[image]")
1562+
t)
1563+
(insert (propertize
1564+
(format "[Image: %s, %s]"
1565+
(or mime-type "unknown")
1566+
(file-size-human-readable (length raw)))
1567+
'face 'pi-coding-agent-tool-header
1568+
'pi-coding-agent-no-fontify t))
1569+
nil))
1570+
(error
1571+
(insert (propertize
1572+
(format "[Image: %s, decode error]" (or mime-type "unknown"))
1573+
'face 'pi-coding-agent-tool-header
1574+
'pi-coding-agent-no-fontify t))
1575+
nil)))
1576+
1577+
(defun pi-coding-agent--insert-tool-images (content)
1578+
"Insert inline images from CONTENT blocks at point.
1579+
CONTENT is a sequence of content block plists. Only blocks with
1580+
:type \"image\" are processed. A newline is inserted before each image."
1581+
(seq-doseq (block content)
1582+
(when (equal (plist-get block :type) "image")
1583+
(let ((data (plist-get block :data))
1584+
(mime (plist-get block :mimeType)))
1585+
(when (and data (> (length data) 0))
1586+
(insert "\n")
1587+
(pi-coding-agent--insert-inline-image data mime))))))
1588+
15271589
(defun pi-coding-agent--tool-block-create
15281590
(tool-name args &optional tool-call-id order preview-state)
15291591
"Insert a live tool block for TOOL-NAME with ARGS and return it.
@@ -2018,6 +2080,9 @@ if none exists, render the result at point without a live overlay."
20182080
(string-trim-right display-content "\n+")
20192081
lang
20202082
is-edit-diff))
2083+
;; Insert any image content blocks after text
2084+
(pi-coding-agent--insert-tool-images content)
2085+
(pi-coding-agent--tool-block-set-image-blocks block content)
20212086
(set-marker end-marker (point))
20222087
(pi-coding-agent--tool-block-refresh-overlay block)
20232088
;; Note: no [error] badge — error content in the block is sufficient,
@@ -2044,6 +2109,8 @@ if none exists, render the result at point without a live overlay."
20442109
(string-trim-right display-content "\n+")
20452110
lang
20462111
is-edit-diff))
2112+
;; Insert any image content blocks after text
2113+
(pi-coding-agent--insert-tool-images content)
20472114
(insert "\n")))))))
20482115

20492116
(defun pi-coding-agent--ranges-excluding-property (start end prop)
@@ -2108,6 +2175,10 @@ Preserves window scroll position during the toggle."
21082175
;; Toggle: if currently expanded, show collapsed (and vice versa)
21092176
(pi-coding-agent--insert-tool-content-with-toggle
21102177
preview-content full-content lang is-edit-diff hidden-count (not expanded))
2178+
;; Re-insert any image blocks stored on the tool block struct
2179+
(when-let* ((block (pi-coding-agent--tool-block-from-overlay ov))
2180+
(img-content (pi-coding-agent--tool-block-image-blocks block)))
2181+
(pi-coding-agent--insert-tool-images img-content))
21112182
;; Ensure fontification of inserted content (JIT font-lock is lazy)
21122183
;; while excluding metadata-like details payload.
21132184
(pi-coding-agent--font-lock-ensure-excluding-property

test/pi-coding-agent-render-test.el

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6231,5 +6231,171 @@ events where the header text hasn't changed."
62316231
(goto-char (marker-position pi-coding-agent--hot-tail-start))
62326232
(should (looking-at "Assistant")))))
62336233

6234+
;;; Inline Image Display
6235+
6236+
(ert-deftest pi-coding-agent-test-mime-to-image-type ()
6237+
"MIME type conversion covers backend types and rejects unknowns."
6238+
(should (eq (pi-coding-agent--mime-to-image-type "image/png") 'png))
6239+
(should (eq (pi-coding-agent--mime-to-image-type "image/jpeg") 'jpeg))
6240+
(should (eq (pi-coding-agent--mime-to-image-type "image/jpg") 'jpeg))
6241+
(should (eq (pi-coding-agent--mime-to-image-type "image/gif") 'gif))
6242+
(should (eq (pi-coding-agent--mime-to-image-type "image/webp") 'webp))
6243+
(should-not (pi-coding-agent--mime-to-image-type "image/tga"))
6244+
(should-not (pi-coding-agent--mime-to-image-type "text/plain")))
6245+
6246+
(ert-deftest pi-coding-agent-test-insert-inline-image-placeholder ()
6247+
"insert-inline-image produces a placeholder in batch mode."
6248+
;; In batch mode display-images-p is nil, so we always get the placeholder.
6249+
(with-temp-buffer
6250+
(pi-coding-agent--insert-inline-image "iVBORw0KGgo=" "image/png")
6251+
(let ((content (buffer-substring-no-properties (point-min) (point-max))))
6252+
;; Should contain MIME type and a size indicator
6253+
(should (string-match-p "Image:" content))
6254+
(should (string-match-p "image/png" content)))))
6255+
6256+
(ert-deftest pi-coding-agent-test-insert-inline-image-error-path ()
6257+
"insert-inline-image handles corrupt base64 gracefully."
6258+
(with-temp-buffer
6259+
(pi-coding-agent--insert-inline-image "!!!invalid!!!" "image/png")
6260+
(should (string-match-p "decode error"
6261+
(buffer-substring-no-properties
6262+
(point-min) (point-max))))))
6263+
6264+
(ert-deftest pi-coding-agent-test-tool-end-image-block-renders ()
6265+
"Image content block in tool result is displayed."
6266+
(with-temp-buffer
6267+
(pi-coding-agent-chat-mode)
6268+
(pi-coding-agent--display-tool-end
6269+
"read" '(:path "photo.png")
6270+
'((:type "text" :text "Read image file [image/png]")
6271+
(:type "image" :data "iVBORw0KGgo=" :mimeType "image/png"))
6272+
nil nil)
6273+
(let ((content (buffer-substring-no-properties (point-min) (point-max))))
6274+
(should (string-match-p "Read image file" content))
6275+
;; In batch: placeholder text; in GUI: [image] fallback from insert-image
6276+
(should (or (string-match-p "\\[Image:" content)
6277+
(string-match-p "\\[image\\]" content))))))
6278+
6279+
(ert-deftest pi-coding-agent-test-tool-end-text-only-no-image ()
6280+
"Tool result with only text blocks has no image placeholder."
6281+
(with-temp-buffer
6282+
(pi-coding-agent-chat-mode)
6283+
(pi-coding-agent--display-tool-end
6284+
"bash" '(:command "ls")
6285+
'((:type "text" :text "file1\nfile2"))
6286+
nil nil)
6287+
(let ((content (buffer-substring-no-properties (point-min) (point-max))))
6288+
(should (string-match-p "file1" content))
6289+
(should-not (string-match-p "\\[Image:" content))
6290+
(should-not (string-match-p "\\[image\\]" content)))))
6291+
6292+
(ert-deftest pi-coding-agent-test-tool-end-image-inside-overlay ()
6293+
"Image content is contained within the tool block overlay."
6294+
(with-temp-buffer
6295+
(pi-coding-agent-chat-mode)
6296+
(let ((block (pi-coding-agent--display-tool-start
6297+
"read" '(:path "photo.png"))))
6298+
(pi-coding-agent--display-tool-end
6299+
"read" '(:path "photo.png")
6300+
'((:type "text" :text "Read image file [image/png]")
6301+
(:type "image" :data "iVBORw0KGgo=" :mimeType "image/png"))
6302+
nil nil block)
6303+
(let ((ov (seq-find (lambda (o) (overlay-get o 'pi-coding-agent-tool-block))
6304+
(overlays-in (point-min) (point-max)))))
6305+
(should ov)
6306+
(let ((ov-content (buffer-substring-no-properties
6307+
(overlay-start ov) (overlay-end ov))))
6308+
(should (or (string-match-p "\\[Image:" ov-content)
6309+
(string-match-p "\\[image\\]" ov-content))))))))
6310+
6311+
(ert-deftest pi-coding-agent-test-tool-end-image-blocks-stored ()
6312+
"Image content blocks are stored on the tool block struct."
6313+
(with-temp-buffer
6314+
(pi-coding-agent-chat-mode)
6315+
(let ((block (pi-coding-agent--display-tool-start
6316+
"read" '(:path "photo.png"))))
6317+
(pi-coding-agent--display-tool-end
6318+
"read" '(:path "photo.png")
6319+
'((:type "text" :text "Read image")
6320+
(:type "image" :data "iVBORw0KGgo=" :mimeType "image/png"))
6321+
nil nil block)
6322+
(should (pi-coding-agent--tool-block-image-blocks block))
6323+
;; Content should include image block
6324+
(should (seq-some (lambda (b) (equal (plist-get b :type) "image"))
6325+
(pi-coding-agent--tool-block-image-blocks block))))))
6326+
6327+
(ert-deftest pi-coding-agent-test-tool-end-multiple-images ()
6328+
"Multiple image blocks each produce output."
6329+
(with-temp-buffer
6330+
(pi-coding-agent-chat-mode)
6331+
(pi-coding-agent--display-tool-end
6332+
"read" '(:path "photo.png")
6333+
'((:type "text" :text "Two images")
6334+
(:type "image" :data "iVBORw0KGgo=" :mimeType "image/png")
6335+
(:type "image" :data "/9j/4AAQ" :mimeType "image/jpeg"))
6336+
nil nil)
6337+
(let ((content (buffer-substring-no-properties (point-min) (point-max))))
6338+
;; Both image indicators should be present
6339+
(should (or (>= (length (split-string content "\\[Image:\\|\\[image\\]" t)) 2)
6340+
;; GUI mode: two [image] from insert-image
6341+
(string-match-p "\\[image\\].*\\[image\\]" content))))))
6342+
6343+
(ert-deftest pi-coding-agent-test-tool-end-image-only ()
6344+
"Tool result with only image blocks, no text, still renders."
6345+
(with-temp-buffer
6346+
(pi-coding-agent-chat-mode)
6347+
(pi-coding-agent--display-tool-end
6348+
"read" '(:path "photo.png")
6349+
'((:type "image" :data "iVBORw0KGgo=" :mimeType "image/png"))
6350+
nil nil)
6351+
(let ((content (buffer-substring-no-properties (point-min) (point-max))))
6352+
(should (or (string-match-p "\\[Image:" content)
6353+
(string-match-p "\\[image\\]" content))))))
6354+
6355+
(ert-deftest pi-coding-agent-test-tool-end-unknown-mime-type ()
6356+
"Unknown MIME type produces a placeholder, not an error."
6357+
(with-temp-buffer
6358+
(pi-coding-agent-chat-mode)
6359+
(pi-coding-agent--display-tool-end
6360+
"read" '(:path "file.tga")
6361+
'((:type "text" :text "Read image")
6362+
(:type "image" :data "AAAA" :mimeType "image/tga"))
6363+
nil nil)
6364+
(let ((content (buffer-substring-no-properties (point-min) (point-max))))
6365+
(should (string-match-p "image/tga" content)))))
6366+
6367+
(ert-deftest pi-coding-agent-test-history-tool-with-image ()
6368+
"History replay renders image from tool result."
6369+
(with-temp-buffer
6370+
(pi-coding-agent-chat-mode)
6371+
(let ((messages [(:role "assistant"
6372+
:content [(:type "toolCall" :id "tc1"
6373+
:name "read"
6374+
:arguments (:path "photo.png"))]
6375+
:timestamp 1704067200000)
6376+
(:role "toolResult" :toolCallId "tc1"
6377+
:toolName "read"
6378+
:content [(:type "text" :text "Read image file [image/png]")
6379+
(:type "image" :data "iVBORw0KGgo=" :mimeType "image/png")]
6380+
:isError :json-false
6381+
:timestamp 1704067201000)]))
6382+
(pi-coding-agent--display-history-messages messages))
6383+
(let ((content (buffer-substring-no-properties (point-min) (point-max))))
6384+
(should (or (string-match-p "\\[Image:.*image/png" content)
6385+
(string-match-p "\\[image\\]" content))))))
6386+
6387+
(ert-deftest pi-coding-agent-test-tool-end-image-with-vector-content ()
6388+
"Image blocks work when content is a vector (JSON parse format)."
6389+
(with-temp-buffer
6390+
(pi-coding-agent-chat-mode)
6391+
(pi-coding-agent--display-tool-end
6392+
"read" '(:path "photo.png")
6393+
[(:type "text" :text "Read image")
6394+
(:type "image" :data "iVBORw0KGgo=" :mimeType "image/png")]
6395+
nil nil)
6396+
(let ((content (buffer-substring-no-properties (point-min) (point-max))))
6397+
(should (or (string-match-p "\\[Image:" content)
6398+
(string-match-p "\\[image\\]" content))))))
6399+
62346400
(provide 'pi-coding-agent-render-test)
62356401
;;; pi-coding-agent-render-test.el ends here

0 commit comments

Comments
 (0)