Skip to content

Commit d4356ae

Browse files
committed
Show images mentioned in chat as inline thumbnails
Pasted screenshots and image files added to the prompt were plain @/tmp/eca-screenshot-XXX.png text, as were their mentions in sent messages, so there was no way to check which image went out. Image mentions now show inline as thumbnails in the prompt, the context line, sent messages and the compose buffer, and RET toggles them back to text. RET is handled in eca-chat--key-pressed-return since font-lock strips keymap text properties in chat buffers. An after-change hook shows them however they land in the prompt (paste, completion, prompt history, yank), and the context line keeps each chip state across its redraws. Terminal frames and missing or remote files stay as text. See eca-chat-image-show-thumbnails and eca-chat-image-thumbnail-size. Text typed right after a context chip inherited its properties, so it looked glued to the chip, was dropped from the sent message, and backspacing it deleted a chip-length chunk that could cross the prompt start, moving the > prefix to the context line. Pasting into an empty prompt left point right at the chip end, making it easy to hit. Chip properties are now non-sticky, deleting a chip uses its bounds and stops at the prompt start, and items added to the prompt are spaced from the word before them.
1 parent 7c55b3d commit d4356ae

11 files changed

Lines changed: 1000 additions & 49 deletions

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Image mentions show inline as thumbnails: screenshots pasted in the prompt, image files added as context and images mentioned in sent messages. `RET` toggles them back to text. Customize with `eca-chat-image-show-thumbnails` and `eca-chat-image-thumbnail-size`.
6+
- Bugfix: text typed right after a context chip in the prompt became part of it: it took the chip color, was dropped from the sent message, and backspacing it could break the prompt, moving the `>` to the context line. Pasted images and contexts added to the prompt are now also spaced from the word before them.
57
- Bugfix: questions asked in parallel (several `ask_user` tool calls) stopped reacting to RET once one was answered, and an option could answer another question. Each question is now answered on its own: point moves on to the next pending one, a typed answer goes to the topmost question accepting one, and stopping cancels them all.
68
- Bugfix: `eca-open-global-config` edited a file the server never reads on Windows when `HOME` is unset (#321), since Emacs defaults `HOME` to `%APPDATA%` while the server uses the user profile directory. The path now comes from the server's `initialize` response (`globalConfigPath`), translated through TRAMP for remote sessions; with older servers the local fallback resolves the home directory like the server does.
79
- Bugfix: new chat buffers now get the session's first workspace folder as `default-directory` (#323). It was inherited from whichever buffer was current when the buffer got created, which for a new session is the async `initialize` response, so `(let ((default-directory dir)) (eca))` wrappers ended up with the chat rooted in the previous buffer's directory.

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ Chat
9999
- `eca-chat-clear-prompt`: Clear written prompt in chat
100100
- `eca-chat-repeat-prompt`: Repeat a previously sent prompt
101101
- `eca-chat-copy-at-point`: Copy the code block or assistant response at point
102+
- `eca-chat-toggle-image-thumbnail`: Toggle the thumbnail of the image mention at point, also `RET` on it: a screenshot pasted in the prompt, an image file added as context, or an image mentioned in a sent message
102103
- `eca-chat-stop-prompt`: Stop a running prompt in chat
103104
- `eca-chat-tool-call-accept-all`: Accept all pending tool calls in chat
104105
- `eca-chat-tool-call-accept-all-and-remember`: Accept all pending tool calls in chat and remember for session
@@ -198,6 +199,8 @@ Chat
198199
- `eca-chat-tool-call-prepare-update-interval`: When using `smart` throttle, process every Nth `toolCallPrepare` update.
199200
- `eca-chat-tool-call-approval-content-size`: Face height used for tool call approval UI text.
200201
- `eca-chat-save-chat-initial-path`: Default initial path to save chats.
202+
- `eca-chat-image-show-thumbnails`: Whether image mentions show inline as thumbnails right away (default `t`). When `nil` they show as text until `RET` toggles their thumbnail.
203+
- `eca-chat-image-thumbnail-size`: Max width and height in pixels of image mention thumbnails (default `200`).
201204

202205
Completion
203206

‎eca-chat-compose.el‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,17 @@
3232
"Save clipboard image DATA of mime TYPE and insert an @file mention.
3333
Writes the image to a temporary eca-screenshot file, like the eca
3434
chat buffer does, and inserts \"@/path/to/file \" at point so the
35-
server picks it up as a file context when the prompt is sent."
35+
server picks it up as a file context when the prompt is sent. RET
36+
on the mention toggles the image thumbnail."
3637
(when-let* ((output-path (eca-chat-media--save-clipboard-image type data)))
37-
(insert eca-chat-context-prefix output-path " ")
38+
(unless (memq (char-before) '(nil ?\s ?\t ?\n))
39+
(insert " "))
40+
(let ((start (point)))
41+
(insert (eca-chat--propertize-image-mention
42+
(concat eca-chat-context-prefix output-path)
43+
output-path))
44+
(eca-chat--add-image-link-overlay start (point)))
45+
(insert " ")
3846
(eca-info "Image added, size: %s"
3947
(file-size-human-readable (file-attribute-size (file-attributes output-path))))))
4048

@@ -81,6 +89,9 @@ eca chat buffer behavior.
8189
(cons '(eca-capf (styles basic substring))
8290
completion-category-defaults))
8391
(setq-local completion-ignore-case t)
92+
(eca-chat--setup-item-stickiness)
93+
(add-hook 'after-change-functions
94+
#'eca-chat--auto-show-image-thumbnails-after-change nil t)
8495
;; Paste image from clipboard support, mirroring eca-chat-mode: drop
8596
;; the handlers inherited from markdown-mode (which insert markdown
8697
;; image links) and register the @file mention handler.

‎eca-chat-context.el‎

Lines changed: 88 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
(require 'eca-util)
1717
(require 'eca-api)
1818
(require 'eca-chat-expandable)
19+
(require 'eca-chat-image)
1920

2021
;; Forward declarations for eca-chat.el core
2122
(defvar eca-chat-mode-map)
@@ -323,36 +324,63 @@ If STATIC? return strs with no dynamic values."
323324
(format ":L%d-L%d" start end)))
324325
'font-lock-face 'eca-chat-context-buffer-face)))
325326
(_ (concat eca-chat-context-prefix "unknown:" type)))))
326-
(propertize context-str
327-
'eca-chat-item-type 'context
328-
'eca-chat-item-str-length (length context-str)
329-
'eca-chat-context-item context)))
327+
(eca-chat--propertize-image-mention
328+
(propertize context-str
329+
'eca-chat-item-type 'context
330+
'eca-chat-item-str-length (length context-str)
331+
'eca-chat-context-item context)
332+
(and (equal type "file") (plist-get context :path)))))
330333

331334
(defun eca-chat--filepath->str (filepath lines-range)
332335
"Convert FILEPATH and LINES-RANGE to a presentable str in buffer."
333336
(let* ((item-str (concat eca-chat-filepath-prefix
334337
(eca-chat--context-presentable-path filepath)
335338
(-when-let ((&plist :start start :end end) lines-range)
336339
(format "(%d-%d)" start end)))))
337-
(propertize item-str
338-
'eca-chat-item-type 'filepath
339-
'eca-chat-item-str-length (length item-str)
340-
'eca-chat-expanded-item-str (concat eca-chat-filepath-prefix
341-
filepath
342-
(-when-let ((&plist :start start :end end) lines-range)
343-
(format ":L%d-L%d" start end)))
344-
'font-lock-face 'eca-chat-context-file-face)))
340+
(eca-chat--propertize-image-mention
341+
(propertize item-str
342+
'eca-chat-item-type 'filepath
343+
'eca-chat-item-str-length (length item-str)
344+
'eca-chat-expanded-item-str (concat eca-chat-filepath-prefix
345+
filepath
346+
(-when-let ((&plist :start start :end end) lines-range)
347+
(format ":L%d-L%d" start end)))
348+
'font-lock-face 'eca-chat-context-file-face)
349+
filepath)))
350+
351+
(defun eca-chat--context-chips (start end)
352+
"Return (CONTEXT . IMAGE) for each context chip between START and END.
353+
IMAGE is the thumbnail the chip shows, or nil."
354+
(let ((chips '())
355+
(pos start))
356+
(while (< pos end)
357+
(let ((next (next-single-property-change pos 'eca-chat-context-item nil end)))
358+
(when-let* ((context (get-text-property pos 'eca-chat-context-item)))
359+
(push (cons context (-some-> (eca-chat--image-thumbnail-at pos next)
360+
(overlay-get 'display)))
361+
chips))
362+
(setq pos next)))
363+
chips))
345364

346365
(defun eca-chat--refresh-context ()
347-
"Refresh chat context."
366+
"Refresh chat context.
367+
Redrawn chips keep their thumbnail, or its absence when hidden with
368+
RET, while new ones follow `eca-chat-image-show-thumbnails'."
348369
(save-excursion
349370
(-some-> (eca-chat--prompt-context-field-ov)
350371
(overlay-start)
351372
(goto-char))
352-
(delete-region (point) (line-end-position))
353-
(seq-doseq (context eca-chat--context)
354-
(eca-chat--insert (eca-chat--context->str context))
355-
(eca-chat--insert " "))
373+
(let ((chips (eca-chat--context-chips (point) (line-end-position))))
374+
(delete-region (point) (line-end-position))
375+
(seq-doseq (context eca-chat--context)
376+
(let ((start (point))
377+
(chip (assoc context chips)))
378+
(let ((eca-chat--inhibit-auto-thumbnails t))
379+
(eca-chat--insert (eca-chat--context->str context)))
380+
(cond
381+
((cdr chip) (eca-chat--show-image-thumbnail start (point) (cdr chip)))
382+
((not chip) (eca-chat--auto-show-image-thumbnails start (point)))))
383+
(eca-chat--insert " ")))
356384
(eca-chat--insert (propertize eca-chat-context-prefix 'font-lock-face 'eca-chat-context-unlinked-face))))
357385

358386
(defun eca-chat--add-context (context)
@@ -476,9 +504,8 @@ DATA is the binary image data as a string."
476504
(eca-chat--select-window)
477505
(if (eq 'system eca-chat-yank-image-context-location)
478506
(eca-chat--add-context context)
479-
(progn
480-
(eca-chat--insert-prompt (concat (eca-chat--context->str context 'static) " "))
481-
(goto-char (+ (point) (+ 2 (length output-path))))))
507+
(goto-char (eca-chat--insert-prompt
508+
(concat (eca-chat--context->str context 'static) " "))))
482509
(eca-info "Image added, size: %s" file-size)))))
483510

484511
(defun eca-chat--clipboard-image-p ()
@@ -602,6 +629,34 @@ file or directory."
602629
(when (eq last-command-event ?\s)
603630
(eca-chat--maybe-finalize-context-token)))
604631

632+
(defconst eca-chat--item-properties
633+
'(eca-chat-item-type eca-chat-item-str-length eca-chat-context-item
634+
eca-chat-expanded-item-str eca-chat-image-path)
635+
"Text properties making a prompt item, like a context chip.")
636+
637+
(defun eca-chat--drop-inherited-item-face ()
638+
"Drop the item face a char typed right after an item inherited.
639+
Item properties don't stick to typed text, see
640+
`eca-chat--setup-item-stickiness', but faces do, since the query
641+
typed after the context line `@' takes its face."
642+
(let ((pos (1- (point))))
643+
(when (and (> pos (point-min))
644+
(get-text-property (1- pos) 'eca-chat-item-type)
645+
(not (get-text-property pos 'eca-chat-item-type)))
646+
(with-silent-modifications
647+
(remove-text-properties pos (point) '(font-lock-face nil))))))
648+
649+
(defun eca-chat--setup-item-stickiness ()
650+
"Keep the text typed right after a prompt item out of it.
651+
Otherwise the typed text inherits the item properties: it looks
652+
glued to the item, gets deleted along with it and is replaced by
653+
the item when the prompt is sent."
654+
(setq-local text-property-default-nonsticky
655+
(append (mapcar (lambda (prop) (cons prop t))
656+
eca-chat--item-properties)
657+
text-property-default-nonsticky))
658+
(add-hook 'post-self-insert-hook #'eca-chat--drop-inherited-item-face nil t))
659+
605660
(declare-function dired-get-marked-files "dired")
606661
(declare-function treemacs-node-at-point "treemacs")
607662
(declare-function treemacs-button-get "treemacs")
@@ -917,12 +972,19 @@ Calls CB with the resulting message."
917972
((when-let ((item-type (get-text-property (point) 'eca-chat-item-type)))
918973
(when-let ((item-str (get-text-property (point) 'eca-chat-expanded-item-str)))
919974
(when-let ((face (get-text-property (point) 'font-lock-face)))
920-
(funcall cb (format "%s: %s"
921-
(pcase item-type
922-
('context "Context")
923-
('filepath "Filepath"))
924-
(propertize item-str 'face face)))
925-
t))))))
975+
(funcall cb (concat (format "%s: %s"
976+
(pcase item-type
977+
('context "Context")
978+
('filepath "Filepath"))
979+
(propertize item-str 'face face))
980+
(when (get-text-property (point) 'eca-chat-image-path)
981+
" (RET: toggle thumbnail)")))
982+
t))))
983+
;; Image mentions of sent messages
984+
((when-let* ((path (get-text-property (point) 'eca-chat-image-path)))
985+
(funcall cb (concat "Image: " (abbreviate-file-name path)
986+
" (RET: toggle thumbnail)"))
987+
t))))
926988

927989
;;;; Completion-at-point function
928990

0 commit comments

Comments
 (0)