Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Add `eca-chat-compose` (transient menu `i`): compose a prompt in a dedicated markdown buffer instead of the inline chat prompt field, convenient for long or multi-line prompts. `C-c C-c` sends the buffer content to the chat it was opened from (or the session's last used chat), `C-c C-k` discards it. `@context`/`#filepath` mentions complete against the ECA server, and yanking a clipboard image inserts an `@file` mention pointing at a saved screenshot, mirroring the chat buffer.

- Restore the rolled-back user message into the prompt field. `Rollback chat to before this message` (when messages are included) used to discard the clicked message's text together with the rest of the turn; the text now lands in the prompt unsent, ready to edit and resend, and any draft already typed in the prompt is kept below it, separated by a blank line.
- Fall back to the buffer's `xref` backend (eglot, etags with a loaded tags table, any custom backend) for `editor/getDefinition`/`editor/getReferences` when lsp-mode cannot serve the file, instead of answering no-server. `includeDeclaration: false` is honored by filtering definition locations out of the references, since LSP-backed xref backends always include the declaration. (editor-code-assistant/eca#351)

Expand Down
159 changes: 159 additions & 0 deletions eca-chat-compose.el
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
;;; eca-chat-compose.el --- ECA chat prompt compose buffer -*- lexical-binding: t; -*-
;; Copyright (C) 2025 Eric Dallo
;;
;; SPDX-License-Identifier: Apache-2.0
;;
;; This file is not part of GNU Emacs.
;;
;;; Commentary:
;;
;; Compose a chat prompt in a dedicated markdown buffer instead of
;; the inline chat prompt field, convenient for long or multi-line
;; prompts. `eca-chat-compose' opens a buffer targeting the current
;; chat; `C-c C-c' sends the buffer content as a prompt to that chat
;; and `C-c C-k' discards it. @context and #filepath mentions
;; complete against the ECA server just like in the chat prompt, and
;; yanking a clipboard image inserts an @file mention pointing at a
;; saved screenshot, mirroring the chat buffer behavior.
;;
;;; Code:

(require 'eca-util)
(require 'eca-chat)

;; Variables

(defvar-local eca-chat-compose--target-buffer nil
"The chat buffer the composed prompt will be sent to.")

;; Internal

(defun eca-chat-compose--yank-image-handler (type data)
"Save clipboard image DATA of mime TYPE and insert an @file mention.
Writes the image to a temporary eca-screenshot file, like the eca
chat buffer does, and inserts \"@/path/to/file \" at point so the
server picks it up as a file context when the prompt is sent."
(when-let* ((output-path (eca-chat-media--save-clipboard-image type data)))
(insert eca-chat-context-prefix output-path " ")
(eca-info "Image added, size: %s"
(file-size-human-readable (file-attribute-size (file-attributes output-path))))))

;; Public

(defun eca-chat-compose-tab ()
"Complete the @/# mention at point, else do markdown cycling.
With point after an @context or #filepath prefix this triggers
`completion-at-point'; anywhere else it behaves like TAB in
`markdown-mode'."
(interactive)
(if (eca-chat--completion-type-at-point)
(completion-at-point)
(call-interactively #'markdown-cycle)))

(defun eca-chat-compose-yank ()
"Yank into the compose buffer, routing images through `yank-media'.
A clipboard image is saved to a temporary file and inserted as an
@file mention; anything else falls back to a plain `yank'."
(interactive)
(if (and (fboundp 'yank-media)
(boundp 'yank-media--registered-handlers)
yank-media--registered-handlers
(eca-chat--clipboard-image-p))
(call-interactively #'yank-media)
(call-interactively #'yank)))

(eca-chat-define-derived-mode eca-chat-compose-mode "eca-chat-compose"
"Major mode for composing a prompt destined for an ECA chat.
The target chat is captured when the buffer is created by
`eca-chat-compose'. Yanking a clipboard image inserts an @file
mention pointing at a temporary screenshot file, mirroring the
eca chat buffer behavior.

\\{eca-chat-compose-mode-map}"
(setq header-line-format
(substitute-command-keys
"Compose prompt: \\[eca-chat-compose-send] to send, \
\\[eca-chat-compose-cancel] to cancel"))
;; ECA server completion for @contexts and #filepaths, mirroring the
;; chat prompt setup including its completion-style overrides.
(setq-local completion-at-point-functions (list #'eca-chat-completion-at-point))
(setq-local completion-category-defaults
(cons '(eca-capf (styles basic substring))
completion-category-defaults))
(setq-local completion-ignore-case t)
;; Paste image from clipboard support, mirroring eca-chat-mode: drop
;; the handlers inherited from markdown-mode (which insert markdown
;; image links) and register the @file mention handler.
(when (fboundp 'yank-media-handler)
(setq-local yank-media--registered-handlers nil)
(yank-media-handler "image/png" #'eca-chat-compose--yank-image-handler)
(yank-media-handler "image/jpeg" #'eca-chat-compose--yank-image-handler)
(yank-media-handler "image/jpg" #'eca-chat-compose--yank-image-handler)
(yank-media-handler "image/gif" #'eca-chat-compose--yank-image-handler)
(yank-media-handler "image/webp" #'eca-chat-compose--yank-image-handler)))

(define-key eca-chat-compose-mode-map (kbd "C-c C-c") #'eca-chat-compose-send)
(define-key eca-chat-compose-mode-map (kbd "C-c C-k") #'eca-chat-compose-cancel)
(define-key eca-chat-compose-mode-map [remap yank] #'eca-chat-compose-yank)
(define-key eca-chat-compose-mode-map (kbd "TAB") #'eca-chat-compose-tab)
(define-key eca-chat-compose-mode-map (kbd "<tab>") #'eca-chat-compose-tab)

;;;###autoload
(defun eca-chat-compose ()
"Compose a prompt for the current chat in a dedicated buffer.
When called from a chat buffer the prompt targets that chat,
otherwise it targets the session's last used chat.
\\<eca-chat-compose-mode-map>\\[eca-chat-compose-send] sends the \
buffer content as a prompt to that chat;
\\[eca-chat-compose-cancel] discards it."
(interactive)
(let ((session (eca-session)))
(eca-assert-session-running session)
(let ((target (if (derived-mode-p 'eca-chat-mode)
(current-buffer)
(eca-chat--get-last-buffer session))))
(unless (buffer-live-p target)
(user-error "No chat buffer found to compose for"))
(let ((buffer (generate-new-buffer
(format "*eca-compose:%s*" (buffer-name target)))))
(with-current-buffer buffer
(eca-chat-compose-mode)
(setq eca-chat-compose--target-buffer target)
;; Make `eca-session' resolve to the target's session from
;; this buffer, so completion and the send path work
;; regardless of where the compose window ends up.
(setq-local eca--session-id-cache (eca--session-id session))
(when-let* ((dir (car (eca--session-workspace-folders session))))
(setq-local default-directory dir))
;; Carry the target's chat id so @/# completion queries
;; (chat/queryContext, chat/queryFiles) run against that chat.
(setq-local eca-chat--id (buffer-local-value 'eca-chat--id target)))
(pop-to-buffer buffer)))))

(defun eca-chat-compose-send ()
"Send the composed prompt to the captured target chat.
Kills the compose buffer afterwards."
(interactive)
(let ((text (string-trim
(buffer-substring-no-properties (point-min) (point-max))))
(target eca-chat-compose--target-buffer)
(session (eca-session)))
(eca-assert-session-running session)
(when (string-empty-p text)
(user-error "Nothing to send"))
(unless (buffer-live-p target)
(user-error "The target chat buffer no longer exists"))
(setf (eca--session-last-chat-buffer session) target)
(eca-chat--with-current-buffer target
(eca-chat--send-prompt session text))
(quit-window t)
(eca-info "Prompt sent to %s" (buffer-name target))))

(defun eca-chat-compose-cancel ()
"Discard the composed prompt and kill the compose buffer."
(interactive)
(quit-window t)
(eca-info "Compose cancelled"))

(provide 'eca-chat-compose)
;;; eca-chat-compose.el ends here
64 changes: 38 additions & 26 deletions eca-chat-context.el
Original file line number Diff line number Diff line change
Expand Up @@ -447,45 +447,57 @@ TYPE can be a string or symbol."
subtype)))
"png")))

(defun eca-chat-media--save-clipboard-image (type data)
"Write clipboard image DATA of mime TYPE to a temp file.
Returns the path to the written file, or nil (after reporting the
failure via `eca-error') when writing fails. Shared by the eca
chat buffer and the compose buffer clipboard-paste handlers."
(let* ((extension (eca-chat-media--extension-for-type type))
(output-path (make-temp-file "eca-screenshot-" nil (concat "." extension))))
(condition-case err
(progn
(let ((coding-system-for-write 'no-conversion))
(write-region data nil output-path nil 'silent))
(and (f-exists? output-path) output-path))
(error
(eca-error "Failed to save yanked image: %s" (error-message-string err))
nil))))

(defun eca-chat--yank-image-handler (type data)
"Handler for `yank-media' to insert images from clipboard.
TYPE is the MIME type (e.g., image/png).
DATA is the binary image data as a string."
(when-let* ((session (eca-session))
(chat-buffer (eca-chat--get-last-buffer session))
(extension (eca-chat-media--extension-for-type type))
(output-path (make-temp-file "eca-screenshot-" nil (concat "." extension))))
(condition-case err
(progn
(let ((coding-system-for-write 'no-conversion))
(write-region data nil output-path nil 'silent))
(when (f-exists? output-path)
(eca-chat--with-current-buffer chat-buffer
(let ((context (list :type "file" :path output-path))
(file-size (file-size-human-readable (file-attribute-size (file-attributes output-path)))))
(eca-chat--select-window)
(if (eq 'system eca-chat-yank-image-context-location)
(eca-chat--add-context context)
(progn
(eca-chat--insert-prompt (concat (eca-chat--context->str context 'static) " "))
(goto-char (+ (point) (+ 2 (length output-path))))))
(eca-info "Image added, size: %s" file-size)))))
(error
(eca-error "Failed to save yanked image: %s" (error-message-string err))))))
(output-path (eca-chat-media--save-clipboard-image type data)))
(eca-chat--with-current-buffer chat-buffer
(let ((context (list :type "file" :path output-path))
(file-size (file-size-human-readable (file-attribute-size (file-attributes output-path)))))
(eca-chat--select-window)
(if (eq 'system eca-chat-yank-image-context-location)
(eca-chat--add-context context)
(progn
(eca-chat--insert-prompt (concat (eca-chat--context->str context 'static) " "))
(goto-char (+ (point) (+ 2 (length output-path))))))
(eca-info "Image added, size: %s" file-size)))))

(defun eca-chat--clipboard-image-p ()
"Return non-nil when an image is available on the clipboard."
(when-let* ((targets (and (display-graphic-p)
(gui-get-selection 'CLIPBOARD 'TARGETS))))
(seq-some (lambda (type)
(and (symbolp type)
(string-match-p "^image/" (symbol-name type))))
(if (vectorp targets) (append targets nil) targets))))

(defun eca-chat--yank-considering-image (orig-fun &rest args)
"Around advice for paste commands to use `yank-media' for images.
Call ORIG-FUN with ARGS if not media."
(if (and (display-graphic-p)
(derived-mode-p 'eca-chat-mode)
(if (and (derived-mode-p 'eca-chat-mode)
(fboundp 'yank-media)
(boundp 'yank-media--registered-handlers)
yank-media--registered-handlers
(when-let* ((targets (gui-get-selection 'CLIPBOARD 'TARGETS)))
(seq-some (lambda (type)
(and (symbolp type)
(string-match-p "^image/" (symbol-name type))))
(if (vectorp targets) (append targets nil) targets))))
(eca-chat--clipboard-image-p))
(call-interactively #'yank-media)
(apply orig-fun args)))

Expand Down
12 changes: 8 additions & 4 deletions eca-chat.el
Original file line number Diff line number Diff line change
Expand Up @@ -2412,10 +2412,14 @@ characters as part of the URL."
(t t)))

(defun eca-chat--point-at-new-context-p ()
"Return non-nil if point is at the context area."
(and (eq (line-number-at-pos (point))
(line-number-at-pos (eca-chat--new-context-start-point)))
(eolp)))
"Return non-nil if point is at the context area.
Returns nil in buffers without the chat prompt overlays (a nil
context-area start would make `line-number-at-pos' fall back to
the current line, making this predicate true at any end of line)."
(when-let* ((context-start (eca-chat--new-context-start-point)))
(and (eq (line-number-at-pos (point))
(line-number-at-pos context-start))
(eolp))))

(defun eca-chat--point-at-prompt-field-p ()
"Return non-nil if point is at the prompt field area."
Expand Down
1 change: 1 addition & 0 deletions eca-util.el
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ Inheirits BASE-MAP."
("k" "Delete" eca-chat-delete)
("R" "Rename" eca-chat-rename)
("t" "Talk" eca-chat-talk)
("i" "Compose prompt in buffer" eca-chat-compose)
("p" "Repeat prompt" eca-chat-repeat-prompt)
("C" "Clear prompt" eca-chat-clear-prompt)
("m" "Select model" eca-chat-select-model)
Expand Down
1 change: 1 addition & 0 deletions eca.el
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
(require 'eca-api)
(require 'eca-settings)
(require 'eca-chat)
(require 'eca-chat-compose)
(require 'eca-workspaces)
(require 'eca-mcp)
(require 'eca-providers)
Expand Down
109 changes: 109 additions & 0 deletions test/eca-chat-compose-test.el
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
;;; eca-chat-compose-test.el --- Tests for eca-chat-compose -*- lexical-binding: t; -*-
;;; Commentary:
;; Tests for the compose-buffer prompt flow: targeting, sending and
;; cancelling.
;;; Code:
(require 'buttercup)
(require 'eca-chat-compose)

(describe "eca-chat-compose"
(let (target-buffer session)
(before-each
(setq target-buffer (generate-new-buffer "*eca-compose-test-chat*"))
(with-current-buffer target-buffer
(setq major-mode 'eca-chat-mode)
(setq-local eca-chat--id "chat-1"))
(setq session (make-eca--session :id "session-1"
:last-chat-buffer target-buffer))
(spy-on 'eca-session :and-return-value session)
(spy-on 'eca--session-workspace-folders :and-return-value '("/ws")))

(after-each
(when (buffer-live-p target-buffer)
(kill-buffer target-buffer)))

(describe "eca-chat-compose"
(it "signals a user-error when no session is running"
(spy-on 'eca-session :and-return-value nil)
(expect (eca-chat-compose) :to-throw 'user-error))

(it "targets the session's last chat buffer when not called from a chat"
(let ((compose-buffer nil))
(spy-on 'pop-to-buffer :and-call-fake
(lambda (buf) (setq compose-buffer buf)))
(with-temp-buffer
(eca-chat-compose))
(unwind-protect
(with-current-buffer compose-buffer
(expect (eq eca-chat-compose--target-buffer target-buffer) :to-be-truthy)
(expect major-mode :to-be 'eca-chat-compose-mode)
(expect eca-chat--id :to-equal "chat-1"))
(kill-buffer compose-buffer))))

(it "targets the current buffer when called from a chat buffer"
(let ((compose-buffer nil))
(spy-on 'pop-to-buffer :and-call-fake
(lambda (buf) (setq compose-buffer buf)))
(with-current-buffer target-buffer
(eca-chat-compose))
(unwind-protect
(with-current-buffer compose-buffer
(expect (eq eca-chat-compose--target-buffer target-buffer) :to-be-truthy))
(kill-buffer compose-buffer)))))

(describe "eca-chat-compose-send"
(it "signals a user-error for an empty prompt"
(let ((buf (generate-new-buffer " *compose-empty*")))
(unwind-protect
(with-current-buffer buf
(eca-chat-compose-mode)
(setq eca-chat-compose--target-buffer target-buffer)
(expect (eca-chat-compose-send) :to-throw 'user-error))
(when (buffer-live-p buf) (kill-buffer buf)))))

(it "signals a user-error when the target buffer is gone"
(let ((buf (generate-new-buffer " *compose-dead-target*"))
(dead (generate-new-buffer " *compose-dead*")))
(kill-buffer dead)
(unwind-protect
(with-current-buffer buf
(eca-chat-compose-mode)
(insert "hello")
(setq eca-chat-compose--target-buffer dead)
(expect (eca-chat-compose-send) :to-throw 'user-error))
(when (buffer-live-p buf) (kill-buffer buf)))))

(it "sends the buffer text to the target chat and kills the compose buffer"
(let ((buf (generate-new-buffer " *compose-send*"))
(sent-session nil)
(sent-prompt nil))
(spy-on 'eca-chat--send-prompt
:and-call-fake (lambda (s p)
(setq sent-session s
sent-prompt p)))
(spy-on 'quit-window)
(with-current-buffer buf
(eca-chat-compose-mode)
(insert " hello eca ")
(setq eca-chat-compose--target-buffer target-buffer)
(eca-chat-compose-send))
(expect sent-prompt :to-equal "hello eca")
(expect (eq sent-session session) :to-be-truthy)
(expect (eq (eca--session-last-chat-buffer session) target-buffer) :to-be-truthy)
(expect 'quit-window :to-have-been-called)
(when (buffer-live-p buf) (kill-buffer buf)))))

(describe "eca-chat-compose-cancel"
(it "kills the compose buffer without sending anything"
(spy-on 'eca-chat--send-prompt)
(let ((buf (generate-new-buffer " *compose-cancel*")))
(spy-on 'quit-window)
(with-current-buffer buf
(eca-chat-compose-mode)
(insert "discard me")
(eca-chat-compose-cancel))
(expect 'quit-window :to-have-been-called)
(expect 'eca-chat--send-prompt :not :to-have-been-called)
(when (buffer-live-p buf) (kill-buffer buf)))))))

;;; eca-chat-compose-test.el ends here
Loading