diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1af2e75..3c9ba29 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,9 +2,9 @@
## Unreleased
-- Bugfix: keep the `:usage` and `:trust` mode-line segments visible after adding the context-usage bar. The right-alignment reserved space from `(length right)`, which counts the bar's pixel-width `display` spaces (and wide glyphs) as ~1 char each, so the segments overflowed off the right edge. It now measures the real rendered width via `string-pixel-width` (with a `buffer-text-pixel-size` fallback on Emacs < 29) and aligns flush to the right edge in pixels.
+- Bugfix: keep the `:usage` and `:trust` mode-line segments visible after adding the context-usage bar. The right-alignment reserved space from `(length right)`, which counts the bar's pixel-width `display` spaces (and wide glyphs) as ~1 char each, so the segments overflowed off the right edge. It now measures the real rendered width via `string-pixel-width` and aligns flush to the right edge in pixels (Emacs 29+ only; on Emacs 28 right segments follow left without alignment).
- Bugfix: closing a chat (`kill-buffer`, `C-c C-k`, or the tab close button) now switches the chat window to a sibling chat (the previous tab, or the only one left) instead of falling back to an unrelated buffer like the settings buffer, and drops the dead chat from the session registry. `C-c C-k` (`eca-chat-reset`) only starts a fresh chat when the closed chat was the last one.
-- Replace the `[copy response]` and `[copy]` chat button labels with a clipboard icon (same click/RET/mouse behavior and tooltips). Customizable via `eca-chat-copy-button-symbol` and `eca-chat-copy-button-symbol-tty` (terminal fallback).
+- Add `eca-chat-copy-at-point`, bound to `C-c C-w`. It copies the fenced code block at point, the assistant response at point, or the latest response as a fallback.
- Bugfix: guard `chat/askQuestion` against a non-sequence `:options` (e.g. a malformed string from a misbehaving server). `append` was splitting such a string into character integers that rendered as a list of random numbers; non-sequence options are now treated as empty.
- Add a context-usage bar in the chat mode-line, left of the token usage, showing how full the model context window is, colored by category (system prompt, rules, skills, AGENTS.md, tool definitions, tool calls, conversation, free space), each a distinct color. In graphical frames it renders pixel-width thin segments for high granularity (small percentages stay visible); terminals fall back to block characters. Same footprint either way (`eca-chat-context-bar-width`). Colors come from the server (canonical `color`/`freeColor`) so they are consistent; hover the bar for a legend that maps each category to its server emoji swatch (`emoji`/`freeEmoji`, matching the `/context` command output), or click to run the new `/context` command. Configurable via `eca-chat-context-bar-width` and the `:context-bar` module in `eca-chat-mode-line-format`. Needs an eca server that sends `contextBreakdown` in `usage` content.
- Auto-dismiss a pending `ask_user` question when another client (e.g. an SSE/web client in remote mode, see eca 0.139.0) answers it first. The server resolves the `ask_user` tool out from under us and sends a `toolCalled`/`toolCallRejected` for that tool-call id but no longer expects our answer (it cancels our request); we now correlate that id with `eca-chat--pending-question` and clear the stale answer-mode prompt state instead of staying stuck waiting for input.
diff --git a/README.md b/README.md
index d18c9b0..b540ef0 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,7 @@ Chat
- `eca-chat-send-prompt-at-chat` Open chat and send any prompt written there
- `eca-chat-clear-prompt`: Clear written prompt in chat
- `eca-chat-repeat-prompt`: Repeat a previously sent prompt
+- `eca-chat-copy-at-point`: Copy the code block or assistant response at point
- `eca-chat-stop-prompt`: Stop a running prompt in chat
- `eca-chat-tool-call-accept-all`: Accept all pending tool calls in chat
- `eca-chat-tool-call-accept-all-and-remember`: Accept all pending tool calls in chat and remember for session
@@ -155,9 +156,6 @@ Chat
- `eca-chat-mcp-tool-call-success-symbol`: Symbol used for MCP tool calls when they succeed.
- `eca-chat-expand-pending-approval-tools`: Whether to auto-expand tool calls that are pending approval.
- `eca-chat-shrink-called-tools`: Whether to auto-shrink tool calls after they have been executed.
-- `eca-chat-show-copy-buttons`: Whether to show copy buttons for chat responses and fenced code blocks (default `t`).
-- `eca-chat-copy-button-symbol`: Glyph used for chat copy buttons on graphic frames (default `📋`).
-- `eca-chat-copy-button-symbol-tty`: Glyph used for chat copy buttons on terminal (non-graphic) frames.
- `eca-chat-tab-line`: Whether to show a tab line with chat tabs at the top of each chat window (default `t`). Each tab shows the chat status (pending approval, loading) and title.
- `eca-chat-custom-model`: Override the model used for chat (nil = server default).
- `eca-chat-custom-agent`: Override the chat agent (nil = server default).
@@ -221,6 +219,7 @@ You can access the transient menu with common commands via `M-x eca-transient-me
| Chat: select chat | C-c C-f |
| Chat: repeat last prompt | C-c C-p |
| Chat: clear prompt | C-c C-d |
+| Chat: copy at point | C-c C-w |
| Chat: timeline | C-c C-h |
| Chat: send prompt at chat buffer | C-c C-RET |
| Chat: accept all pending tool calls | C-c C-a |
diff --git a/eca-chat.el b/eca-chat.el
index 9553e9b..693cdd4 100644
--- a/eca-chat.el
+++ b/eca-chat.el
@@ -158,16 +158,6 @@ to nil to keep the whole chat buffer writable."
:type 'string
:group 'eca)
-(defcustom eca-chat-copy-button-symbol "📋"
- "The glyph used in eca chat buffer for copy buttons on graphic frames."
- :type 'string
- :group 'eca)
-
-(defcustom eca-chat-copy-button-symbol-tty "⎘"
- "Copy button glyph used on terminal (non-graphic) frames."
- :type 'string
- :group 'eca)
-
(defcustom eca-chat-expand-pending-approval-tools t
"Whether to auto expand tool calls when pending approval."
:type 'boolean
@@ -178,11 +168,6 @@ to nil to keep the whole chat buffer writable."
:type 'boolean
:group 'eca)
-(defcustom eca-chat-show-copy-buttons t
- "Whether to show copy buttons for chat responses and code blocks."
- :type 'boolean
- :group 'eca)
-
(defcustom eca-chat-tab-line t
"Whether to show a tab line with chat tabs at the top of each chat window.
When non-nil, enables `tab-line-mode' in chat buffers with tabs
@@ -418,16 +403,6 @@ behavior)."
"Face for the rollback button."
:group 'eca)
-(defface eca-chat-copy-button-face
- '((t (:inherit link :underline nil :weight bold)))
- "Face for chat copy buttons."
- :group 'eca)
-
-(defface eca-chat-copy-label-face
- '((t (:inherit font-lock-comment-face :height 0.9)))
- "Face for chat copy button labels."
- :group 'eca)
-
(defface eca-chat-system-messages-face
'((t :inherit font-lock-builtin-face))
"Face for the system messages in chat."
@@ -830,6 +805,7 @@ and resume link are not left behind under the replayed messages.")
(define-key map (kbd "C-c C-f") #'eca-chat-select)
(define-key map (kbd "C-c C-p") #'eca-chat-repeat-prompt)
(define-key map (kbd "C-c C-d") #'eca-chat-clear-prompt)
+ (define-key map (kbd "C-c C-w") #'eca-chat-copy-at-point)
(define-key map (kbd "C-c C-S-h") #'eca-chat-timeline)
(define-key map (kbd "C-c C-S-o") #'eca-chat-load-older-history)
(define-key map (kbd "C-c C-a") #'eca-chat-tool-call-accept-all)
@@ -2618,14 +2594,12 @@ are in progress."
(defun eca-chat--string-pixel-width (string)
"Return the rendered pixel width of STRING for mode-line alignment.
Honors `display' specs (e.g. the context bar's pixel-width spaces) and
-wide glyphs, unlike `length'. Falls back to `buffer-text-pixel-size'
-on Emacs versions without `string-pixel-width' (< 29)."
+wide glyphs, unlike `length'. Uses `string-pixel-width' when
+available, falling back to `string-width' on Emacsen without it."
(cond
((string-empty-p string) 0)
((fboundp 'string-pixel-width) (string-pixel-width string))
- (t (with-temp-buffer
- (insert string)
- (car (buffer-text-pixel-size nil nil t))))))
+ (t (string-width string))))
(defun eca-chat--mode-line-string (session)
"Build mode-line string for SESSION from `eca-chat-mode-line-format'."
@@ -2650,7 +2624,8 @@ on Emacs versions without `string-pixel-width' (< 29)."
(eca-chat--mode-line-module session m))
right-modules))
"")))
- (fill (if (string-empty-p right)
+ (fill (if (or (string-empty-p right)
+ (not (fboundp 'string-pixel-width)))
""
(let ((px (eca-chat--string-pixel-width right)))
(propertize
@@ -2815,17 +2790,8 @@ the new turn instead of the full chat history."
(point-max))))))))))
(defun eca-chat--copy-region-text (beg end)
- "Return text between BEG and END without copy button text."
- (let ((pos beg)
- parts)
- (while (< pos end)
- (let ((next (or (next-single-property-change
- pos 'eca-chat--copy-button-kind nil end)
- end)))
- (unless (get-text-property pos 'eca-chat--copy-button-kind)
- (push (buffer-substring-no-properties pos next) parts))
- (setq pos next)))
- (apply #'concat (nreverse parts))))
+ "Return plain text between BEG and END."
+ (buffer-substring-no-properties beg end))
(defun eca-chat--copy-region (start end description)
"Copy text between START and END and show DESCRIPTION."
@@ -2838,151 +2804,116 @@ the new turn instead of the full chat history."
(eca-chat--copy-region-text beg finish))))
(message "Copied %s" description)))
-(defun eca-chat--copy-button-text (text callback help)
- "Return actionable copy button TEXT using CALLBACK and HELP."
- (let* ((button (eca-buttonize eca-chat-mode-map text callback))
- (callback-int (lambda (&rest _)
- (interactive)
- (funcall callback)))
- (map (eca-chat--copy-button-keymap button)))
- (define-key map (kbd "") callback-int)
- (add-text-properties
- 0 (length button)
- `(font-lock-face eca-chat-copy-button-face
- mouse-face highlight
- help-echo ,help)
- button)
- button))
-
-(defun eca-chat--copy-button-keymap (button)
- "Return the keymap from copy BUTTON."
- (or (get-text-property 0 'keymap button)
- (get-text-property 0 'local-map button)))
-
-(defun eca-chat--decorate-copy-button-overlay (overlay button prop)
- "Decorate OVERLAY for copy BUTTON and mark it with PROP."
- (overlay-put overlay prop t)
- (overlay-put overlay 'keymap (eca-chat--copy-button-keymap button))
- (overlay-put overlay 'local-map (eca-chat--copy-button-keymap button))
- (overlay-put overlay 'mouse-face 'highlight)
- (overlay-put overlay 'pointer 'hand)
- (overlay-put overlay 'help-echo (get-text-property 0 'help-echo button)))
-
-(defun eca-chat--remove-copy-buttons (start end kind)
- "Remove copy button text of KIND between START and END.
-Return the adjusted end position after deletion."
- (let ((end-marker (copy-marker end)))
- (save-excursion
- (goto-char start)
- (while (< (point) (marker-position end-marker))
- (let ((next (or (next-single-property-change
- (point) 'eca-chat--copy-button-kind
- nil end-marker)
- (marker-position end-marker))))
- (if (eq (get-text-property
- (point) 'eca-chat--copy-button-kind)
- kind)
- (let ((inhibit-read-only t))
- (delete-region (point) next))
- (goto-char next)))))
- (prog1 (marker-position end-marker)
- (set-marker end-marker nil))))
-
-(defun eca-chat--insert-copy-button (pos button kind overlay-prop)
- "Insert copy BUTTON at POS and mark it as KIND with OVERLAY-PROP.
-Return the position after the inserted button."
- (let ((inhibit-read-only t))
- (save-excursion
- (goto-char pos)
- (add-text-properties
- 0 (length button)
- `(eca-chat--copy-button-kind ,kind
- rear-nonsticky t)
- button)
- (let ((beg (point)))
- (insert button)
- (let ((ov (make-overlay beg (point) nil t t)))
- (eca-chat--decorate-copy-button-overlay
- ov button overlay-prop))
- (setq-local buffer-undo-list nil)
- (point)))))
+(defun eca-chat--make-copy-scope (start end kind prop &rest extra-props)
+ "Create an invisible copy scope overlay from START to END.
+KIND is the copied content kind. PROP marks the overlay type.
+EXTRA-PROPS are additional overlay properties."
+ (let ((overlay (make-overlay start end nil nil nil)))
+ (overlay-put overlay prop t)
+ (overlay-put overlay 'eca-chat--copy-kind kind)
+ (while extra-props
+ (overlay-put overlay (pop extra-props) (pop extra-props)))
+ overlay))
(defun eca-chat--code-fence-close-regexp (fence)
"Return regexp matching the closing FENCE line."
(concat "^[ \t]*" (regexp-quote fence) "[ \t]*$"))
-(defun eca-chat--copy-button-label ()
- "Return the copy button glyph followed by a newline.
-Uses the TTY glyph on terminal (non-graphic) frames."
- (concat (if (display-graphic-p)
- eca-chat-copy-button-symbol
- eca-chat-copy-button-symbol-tty)
- "\n"))
-
-(defun eca-chat--refresh-code-copy-buttons (&optional from to)
- "Refresh copy buttons for fenced code blocks between FROM and TO."
+(defun eca-chat--refresh-code-copy-scopes (&optional from to)
+ "Refresh copy scopes for fenced code blocks between FROM and TO."
(let* ((start (or from (point-min)))
- (end (eca-chat--remove-copy-buttons
- start (or to (point-max)) 'code))
+ (end (or to (point-max)))
(limit (copy-marker end)))
- (remove-overlays start end 'eca-chat--code-copy-button t)
- (when eca-chat-show-copy-buttons
- (save-excursion
- (goto-char start)
- (while (re-search-forward "^[ \t]*\\(``+\\|~~+\\).*$" limit t)
- (let* ((open-start (line-beginning-position))
- (fence (match-string-no-properties 1))
- (body-start (copy-marker (1+ (line-end-position)) t))
- (close-regexp (eca-chat--code-fence-close-regexp fence)))
- (when (re-search-forward close-regexp limit t)
- (let* ((body-end (copy-marker (match-beginning 0)))
- (button (eca-chat--copy-button-text
- (eca-chat--copy-button-label)
- (lambda ()
- (interactive)
- (eca-chat--copy-region
- body-start
- body-end
- "code block"))
- "Copy code block")))
- (eca-chat--insert-copy-button
- open-start button 'code 'eca-chat--code-copy-button)))))))
+ (remove-overlays start end 'eca-chat--code-copy-scope t)
+ (save-excursion
+ (goto-char start)
+ (while (re-search-forward "^[ \t]*\\(``+\\|~~+\\).*$" limit t)
+ (let* ((open-start (line-beginning-position))
+ (fence (match-string-no-properties 1))
+ (body-start (copy-marker (1+ (line-end-position)) t))
+ (close-regexp (eca-chat--code-fence-close-regexp fence)))
+ (when (re-search-forward close-regexp limit t)
+ (eca-chat--make-copy-scope
+ open-start (line-end-position) 'code
+ 'eca-chat--code-copy-scope
+ 'eca-chat--copy-start body-start
+ 'eca-chat--copy-end (copy-marker (match-beginning 0)))))))
(set-marker limit nil)))
-(defun eca-chat--refresh-response-copy-button (&optional from to)
- "Refresh the copy button for assistant response between FROM and TO."
+(defun eca-chat--refresh-response-copy-scope (&optional from to)
+ "Refresh the copy scope for assistant response between FROM and TO."
(let* ((start (or from eca-chat--last-response-copy-start))
(end (or to (eca-chat--content-insertion-point))))
- (when (and start end (< start end))
- (setq end (eca-chat--remove-copy-buttons start end 'response))
- (remove-overlays start end 'eca-chat--response-copy-button t)
- (when (and eca-chat-show-copy-buttons
- (not (string-empty-p
- (string-trim
- (buffer-substring-no-properties start end)))))
- (let ((copy-start (make-marker))
- (copy-end (copy-marker end)))
- (let* ((button (eca-chat--copy-button-text
- (eca-chat--copy-button-label)
- (lambda ()
- (interactive)
- (eca-chat--copy-region
- copy-start
- copy-end
- "response"))
- "Copy response"))
- (button-end (eca-chat--insert-copy-button
- start button 'response
- 'eca-chat--response-copy-button)))
- (set-marker copy-start button-end (current-buffer))))))))
-
-(defun eca-chat--refresh-copy-buttons ()
- "Refresh copy affordances for the latest assistant response."
+ (when (and start end (< start end)
+ (not (string-empty-p
+ (string-trim
+ (buffer-substring-no-properties start end)))))
+ (remove-overlays start end 'eca-chat--response-copy-scope t)
+ (eca-chat--make-copy-scope
+ start end 'response 'eca-chat--response-copy-scope))))
+
+(defun eca-chat--copy-scope-range (overlay)
+ "Return the copy range for OVERLAY as a cons cell."
+ (cons (or (overlay-get overlay 'eca-chat--copy-start)
+ (overlay-start overlay))
+ (or (overlay-get overlay 'eca-chat--copy-end)
+ (overlay-end overlay))))
+
+(defun eca-chat--copy-scope-description (overlay)
+ "Return a user-facing description for OVERLAY."
+ (pcase (overlay-get overlay 'eca-chat--copy-kind)
+ ('code "code block")
+ ('response "response")
+ (_ "text")))
+
+(defun eca-chat--smallest-copy-scope (prop)
+ "Return smallest copy scope at point marked by PROP."
+ (car (sort (seq-filter (lambda (overlay) (overlay-get overlay prop))
+ (overlays-at (point)))
+ (lambda (left right)
+ (< (- (overlay-end left) (overlay-start left))
+ (- (overlay-end right) (overlay-start right)))))))
+
+(defun eca-chat--copy-scope-at-point ()
+ "Return the best copy scope at point, preferring code blocks."
+ (or (eca-chat--smallest-copy-scope 'eca-chat--code-copy-scope)
+ (eca-chat--smallest-copy-scope 'eca-chat--response-copy-scope)))
+
+(defun eca-chat--latest-response-copy-scope ()
+ "Return the latest assistant response copy scope."
+ (let ((end (or (eca-chat--content-insertion-point) (point-max))))
+ (car (sort (seq-filter
+ (lambda (overlay)
+ (overlay-get overlay 'eca-chat--response-copy-scope))
+ (overlays-in (point-min) end))
+ (lambda (left right)
+ (> (overlay-start left) (overlay-start right)))))))
+
+(defun eca-chat--copy-scope (overlay)
+ "Copy text described by copy scope OVERLAY."
+ (let ((range (eca-chat--copy-scope-range overlay)))
+ (eca-chat--copy-region
+ (car range) (cdr range)
+ (eca-chat--copy-scope-description overlay))))
+
+(defun eca-chat-copy-at-point (&optional latest)
+ "Copy code block or assistant response at point.
+With prefix argument LATEST, copy the latest assistant response."
+ (interactive "P")
+ (if-let* ((overlay (if latest
+ (eca-chat--latest-response-copy-scope)
+ (or (eca-chat--copy-scope-at-point)
+ (eca-chat--latest-response-copy-scope)))))
+ (eca-chat--copy-scope overlay)
+ (user-error "No response to copy")))
+
+(defun eca-chat--refresh-copy-scopes ()
+ "Refresh copy scopes for the latest assistant response."
(let ((start eca-chat--last-response-copy-start)
(end (eca-chat--content-insertion-point)))
(when (and start end (< start end))
- (eca-chat--refresh-response-copy-button start end)
- (eca-chat--refresh-code-copy-buttons
+ (eca-chat--refresh-response-copy-scope start end)
+ (eca-chat--refresh-code-copy-scopes
start (eca-chat--content-insertion-point)))))
(defun eca-chat--add-text-content (text &optional overlay-key overlay-value)
@@ -3981,7 +3912,7 @@ Must be called with `eca-chat--with-current-buffer' or equivalent."
;; cost on long chats.
(font-lock-ensure (or eca-chat--last-user-message-pos (point-min))
(point-max))
- (eca-chat--refresh-copy-buttons)
+ (eca-chat--refresh-copy-scopes)
(eca-chat--set-chat-loading session nil)
(eca-chat--refresh-progress chat-buffer)
(eca-chat--send-steered-prompt session)
@@ -4003,7 +3934,7 @@ Must be called with `eca-chat--with-current-buffer' or equivalent."
;; fontified at their own end-of-stream.
(font-lock-ensure (or eca-chat--last-user-message-pos (point-min))
(point-max))
- (eca-chat--refresh-copy-buttons)
+ (eca-chat--refresh-copy-scopes)
;; Table align/beautify default to scanning from
;; `eca-chat--last-user-message-pos' when called with
;; no argument, scoping work to the current turn.
diff --git a/test/eca-chat-test.el b/test/eca-chat-test.el
index 34eabc9..e3e4d46 100644
--- a/test/eca-chat-test.el
+++ b/test/eca-chat-test.el
@@ -62,15 +62,6 @@ does not treat the first line as metadata. Returns FN's value."
(goto-char (match-beginning 0))
(funcall fn)))
-(defun eca-chat-test--overlay-text (overlay)
- "Return text covered by OVERLAY."
- (buffer-substring (overlay-start overlay) (overlay-end overlay)))
-
-(defun eca-chat-test--overlay-action (overlay)
- "Return the ECA button action at OVERLAY start."
- (get-text-property (overlay-start overlay)
- 'eca-button-on-action))
-
;; ---------------------------------------------------------------------------
;; Tests
;; ---------------------------------------------------------------------------
@@ -310,177 +301,104 @@ does not treat the first line as metadata. Returns FN's value."
:to-equal "@/remote/path/file.txt")
(expect 'eca--path-local-to-remote :to-have-been-called-with "file.txt"))))))
-(describe "eca-chat copy buttons"
+(describe "eca-chat copy command"
(it "copies fenced code block content"
- (let ((eca-chat-show-copy-buttons t))
+ (let (kill-ring
+ kill-ring-yank-pointer)
(with-temp-buffer
(setq major-mode 'eca-chat-mode)
(insert "```elisp\n(+ 1 2)\n```\n")
- (eca-chat--refresh-code-copy-buttons (point-min) (point-max))
- (let* ((ov (-first (lambda (overlay)
- (overlay-get overlay
- 'eca-chat--code-copy-button))
- (overlays-in (point-min) (point-max))))
- (action (eca-chat-test--overlay-action ov)))
- (funcall action)
- (expect (current-kill 0 t) :to-equal "(+ 1 2)")))))
+ (eca-chat--refresh-code-copy-scopes (point-min) (point-max))
+ (goto-char (point-min))
+ (search-forward "(+ 1 2)")
+ (eca-chat-copy-at-point)
+ (expect (current-kill 0 t) :to-equal "(+ 1 2)"))))
(it "copies two-backtick fenced code block content"
- (let ((eca-chat-show-copy-buttons t))
+ (let (kill-ring
+ kill-ring-yank-pointer)
(with-temp-buffer
(setq major-mode 'eca-chat-mode)
(insert "``bash\naz login\n``\n")
- (eca-chat--refresh-code-copy-buttons (point-min) (point-max))
- (let* ((ov (-first (lambda (overlay)
- (overlay-get overlay
- 'eca-chat--code-copy-button))
- (overlays-in (point-min) (point-max))))
- (action (eca-chat-test--overlay-action ov)))
- (funcall action)
- (expect (current-kill 0 t) :to-equal "az login")))))
+ (eca-chat--refresh-code-copy-scopes (point-min) (point-max))
+ (goto-char (point-min))
+ (search-forward "az login")
+ (eca-chat-copy-at-point)
+ (expect (current-kill 0 t) :to-equal "az login"))))
(it "copies the whole assistant response"
- (let ((eca-chat-show-copy-buttons t))
+ (let (kill-ring
+ kill-ring-yank-pointer)
(with-temp-buffer
(setq major-mode 'eca-chat-mode)
(insert "Answer\n")
- (eca-chat--refresh-response-copy-button (point-min) (point-max))
- (let* ((ov (-first (lambda (overlay)
- (overlay-get overlay
- 'eca-chat--response-copy-button))
- (overlays-in (point-min) (point-max))))
- (action (eca-chat-test--overlay-action ov)))
- (funcall action)
- (expect (current-kill 0 t) :to-equal "Answer")))))
-
- (it "renders response and code copy affordances on separate lines"
- (let ((eca-chat-show-copy-buttons t))
- (with-temp-buffer
- (setq major-mode 'eca-chat-mode)
- (insert "Answer\n```elisp\n(+ 1 2)\n```\n")
- (eca-chat--refresh-response-copy-button (point-min) (point-max))
- (eca-chat--refresh-code-copy-buttons (point-min) (point-max))
- (let* ((response-ov (-first
- (lambda (overlay)
- (overlay-get overlay
- 'eca-chat--response-copy-button))
- (overlays-in (point-min) (point-max))))
- (code-ov (-first
- (lambda (overlay)
- (overlay-get overlay
- 'eca-chat--code-copy-button))
- (overlays-in (point-min) (point-max)))))
- (expect (substring-no-properties
- (eca-chat-test--overlay-text response-ov))
- :to-equal (eca-chat--copy-button-label))
- (expect (substring-no-properties
- (eca-chat-test--overlay-text code-ov))
- :to-equal (eca-chat--copy-button-label))))))
-
- (it "adds keyboard and mouse bindings to copy overlays"
- (let ((eca-chat-show-copy-buttons t))
- (with-temp-buffer
- (setq major-mode 'eca-chat-mode)
- (insert "```elisp\n(+ 1 2)\n```\n")
- (eca-chat--refresh-code-copy-buttons (point-min) (point-max))
- (let* ((ov (-first (lambda (overlay)
- (overlay-get overlay
- 'eca-chat--code-copy-button))
- (overlays-in (point-min) (point-max))))
- (map (get-text-property (overlay-start ov) 'keymap)))
- (expect (lookup-key map (kbd "")) :not :to-be nil)
- (expect (lookup-key map (kbd "RET")) :not :to-be nil)
- (expect (overlay-get ov 'keymap) :not :to-be nil)))))
-
- (it "renders code copy affordance as reachable buffer text"
- (let ((eca-chat-show-copy-buttons t))
- (with-temp-buffer
- (setq major-mode 'eca-chat-mode)
- (insert "```elisp\n(+ 1 2)\n```\n")
- (eca-chat--refresh-code-copy-buttons (point-min) (point-max))
- (let ((ov (-first (lambda (overlay)
- (overlay-get overlay
- 'eca-chat--code-copy-button))
- (overlays-in (point-min) (point-max)))))
- (expect (overlay-start ov) :to-be (point-min))
- (expect (substring-no-properties
- (eca-chat-test--overlay-text ov))
- :to-equal (eca-chat--copy-button-label))
- (expect (buffer-substring-no-properties
- (overlay-end ov)
- (+ (overlay-end ov) 8))
- :to-equal "```elisp")))))
-
- (it "adds copy overlays to each fenced code block"
- (let ((eca-chat-show-copy-buttons t)
- kill-ring
+ (eca-chat--refresh-response-copy-scope (point-min) (point-max))
+ (goto-char (point-min))
+ (eca-chat-copy-at-point)
+ (expect (current-kill 0 t) :to-equal "Answer"))))
+
+ (it "does not insert visible copy controls"
+ (with-temp-buffer
+ (setq major-mode 'eca-chat-mode)
+ (insert "Answer\n```elisp\n(+ 1 2)\n```\n")
+ (let ((original (buffer-string)))
+ (eca-chat--refresh-response-copy-scope (point-min) (point-max))
+ (eca-chat--refresh-code-copy-scopes (point-min) (point-max))
+ (expect (buffer-string) :to-equal original)
+ (expect (-first (lambda (overlay)
+ (overlay-get overlay 'eca-chat--response-copy-scope))
+ (overlays-in (point-min) (point-max)))
+ :not :to-be nil)
+ (expect (-first (lambda (overlay)
+ (overlay-get overlay 'eca-chat--code-copy-scope))
+ (overlays-in (point-min) (point-max)))
+ :not :to-be nil))))
+
+ (it "adds copy scopes to each fenced code block"
+ (let (kill-ring
kill-ring-yank-pointer)
(with-temp-buffer
(setq major-mode 'eca-chat-mode)
(insert "```bash\naz webapp list-runtimes --os linux -o table\n```\n\n")
(insert "```bash\naz webapp show --name --resource-group ")
(insert "--query \"siteConfig.linuxFxVersion\" -o tsv\n```\n")
- (eca-chat--refresh-code-copy-buttons (point-min) (point-max))
- (let ((overlays (sort (seq-filter
- (lambda (overlay)
- (overlay-get overlay
- 'eca-chat--code-copy-button))
- (overlays-in (point-min) (point-max)))
- (lambda (left right)
- (< (overlay-start left)
- (overlay-start right))))))
- (expect (length overlays) :to-be 2)
- (dolist (ov overlays)
- (funcall (eca-chat-test--overlay-action ov)))
- (expect (car kill-ring)
- :to-equal
- "az webapp show --name --resource-group --query \"siteConfig.linuxFxVersion\" -o tsv")
- (expect (cadr kill-ring)
- :to-equal
- "az webapp list-runtimes --os linux -o table")))))
-
- (it "does not add fenced code block copy buttons when disabled"
- (let ((eca-chat-show-copy-buttons nil))
- (with-temp-buffer
- (setq major-mode 'eca-chat-mode)
- (insert "```elisp\n(+ 1 2)\n```\n")
- (eca-chat--refresh-code-copy-buttons (point-min) (point-max))
- (expect (-first (lambda (overlay)
- (overlay-get overlay 'eca-chat--code-copy-button))
- (overlays-in (point-min) (point-max)))
- :to-be nil))))
-
- (it "does not add response copy buttons when disabled"
- (let ((eca-chat-show-copy-buttons nil))
- (with-temp-buffer
- (setq major-mode 'eca-chat-mode)
- (insert "Answer\n")
- (eca-chat--refresh-response-copy-button (point-min) (point-max))
- (expect (-first (lambda (overlay)
- (overlay-get overlay 'eca-chat--response-copy-button))
- (overlays-in (point-min) (point-max)))
- :to-be nil))))
-
- (it "does not add copy buttons to rendered user messages"
+ (eca-chat--refresh-code-copy-scopes (point-min) (point-max))
+ (let ((overlays (seq-filter
+ (lambda (overlay)
+ (overlay-get overlay 'eca-chat--code-copy-scope))
+ (overlays-in (point-min) (point-max)))))
+ (expect (length overlays) :to-be 2))
+ (goto-char (point-min))
+ (search-forward "az webapp list-runtimes")
+ (eca-chat-copy-at-point)
+ (search-forward "az webapp show")
+ (eca-chat-copy-at-point)
+ (expect (car kill-ring)
+ :to-equal
+ "az webapp show --name --resource-group --query \"siteConfig.linuxFxVersion\" -o tsv")
+ (expect (cadr kill-ring)
+ :to-equal
+ "az webapp list-runtimes --os linux -o table"))))
+
+ (it "does not add copy scopes to rendered user messages"
(with-temp-buffer
(setq major-mode 'eca-chat-mode)
(insert "User says:\n```elisp\n(+ 1 2)\n```\n")
(setq-local eca-chat--last-user-message-pos (point-max))
(let ((ov (make-overlay (point-max) (point-max))))
(overlay-put ov 'eca-chat-prompt-area t))
- (eca-chat--refresh-copy-buttons)
+ (eca-chat--refresh-copy-scopes)
(expect (-first (lambda (overlay)
- (overlay-get overlay 'eca-chat--code-copy-button))
+ (overlay-get overlay 'eca-chat--code-copy-scope))
(overlays-in (point-min) (point-max)))
:to-be nil)
(expect (-first (lambda (overlay)
- (overlay-get overlay 'eca-chat--response-copy-button))
+ (overlay-get overlay 'eca-chat--response-copy-scope))
(overlays-in (point-min) (point-max)))
:to-be nil)))
(it "copies only assistant text after a tool interruption"
- (let ((eca-chat-show-copy-buttons t)
- kill-ring
+ (let (kill-ring
kill-ring-yank-pointer)
(with-temp-buffer
(setq major-mode 'eca-chat-mode)
@@ -491,18 +409,13 @@ does not treat the first line as metadata. Returns FN's value."
(let ((ov (make-overlay (point) (point))))
(overlay-put ov 'eca-chat-prompt-area t))
(setq-local eca-chat--last-response-copy-start final-start)
- (eca-chat--refresh-copy-buttons)
- (let* ((ov (-first (lambda (overlay)
- (overlay-get overlay
- 'eca-chat--response-copy-button))
- (overlays-in (point-min) (point-max))))
- (action (eca-chat-test--overlay-action ov)))
- (funcall action)
- (expect (current-kill 0 t) :to-equal "Final answer"))))))
-
- (it "excludes code copy buttons from response copy text"
- (let ((eca-chat-show-copy-buttons t)
- kill-ring
+ (eca-chat--refresh-copy-scopes)
+ (goto-char final-start)
+ (eca-chat-copy-at-point)
+ (expect (current-kill 0 t) :to-equal "Final answer")))))
+
+ (it "copies response text including fenced code"
+ (let (kill-ring
kill-ring-yank-pointer)
(with-temp-buffer
(setq major-mode 'eca-chat-mode)
@@ -510,19 +423,29 @@ does not treat the first line as metadata. Returns FN's value."
(let ((ov (make-overlay (point) (point))))
(overlay-put ov 'eca-chat-prompt-area t))
(setq-local eca-chat--last-response-copy-start (point-min))
- (eca-chat--refresh-copy-buttons)
- (let* ((ov (-first (lambda (overlay)
- (overlay-get overlay
- 'eca-chat--response-copy-button))
- (overlays-in (point-min) (point-max))))
- (action (eca-chat-test--overlay-action ov)))
- (funcall action)
- (expect (current-kill 0 t)
- :to-equal "Answer\n```elisp\n(+ 1 2)\n```")))))
-
- (it "does not copy previous response or user question"
- (let ((eca-chat-show-copy-buttons t)
- kill-ring
+ (eca-chat--refresh-copy-scopes)
+ (goto-char (point-min))
+ (eca-chat-copy-at-point)
+ (expect (current-kill 0 t)
+ :to-equal "Answer\n```elisp\n(+ 1 2)\n```"))))
+
+ (it "prefers code block scopes over response scopes"
+ (let (kill-ring
+ kill-ring-yank-pointer)
+ (with-temp-buffer
+ (setq major-mode 'eca-chat-mode)
+ (insert "Answer\n```elisp\n(+ 1 2)\n```\n")
+ (let ((ov (make-overlay (point) (point))))
+ (overlay-put ov 'eca-chat-prompt-area t))
+ (setq-local eca-chat--last-response-copy-start (point-min))
+ (eca-chat--refresh-copy-scopes)
+ (goto-char (point-min))
+ (search-forward "(+ 1 2)")
+ (eca-chat-copy-at-point)
+ (expect (current-kill 0 t) :to-equal "(+ 1 2)"))))
+
+ (it "falls back to the latest response"
+ (let (kill-ring
kill-ring-yank-pointer)
(with-temp-buffer
(setq major-mode 'eca-chat-mode)
@@ -534,19 +457,14 @@ does not treat the first line as metadata. Returns FN's value."
(let ((ov (make-overlay (point) (point))))
(overlay-put ov 'eca-chat-prompt-area t))
(setq-local eca-chat--last-response-copy-start latest-start)
- (eca-chat--refresh-copy-buttons)
- (let* ((ov (-first (lambda (overlay)
- (overlay-get overlay
- 'eca-chat--response-copy-button))
- (overlays-in (point-min) (point-max))))
- (action (eca-chat-test--overlay-action ov)))
- (funcall action)
- (expect (current-kill 0 t)
- :to-equal "Latest answer only"))))))
-
- (it "keeps an older response copy button scoped to that response"
- (let ((eca-chat-show-copy-buttons t)
- kill-ring
+ (eca-chat--refresh-copy-scopes)
+ (goto-char (point-min))
+ (eca-chat-copy-at-point)
+ (expect (current-kill 0 t)
+ :to-equal "Latest answer only")))))
+
+ (it "keeps an older response scoped to that response"
+ (let (kill-ring
kill-ring-yank-pointer)
(with-temp-buffer
(setq major-mode 'eca-chat-mode)
@@ -554,18 +472,15 @@ does not treat the first line as metadata. Returns FN's value."
(let ((ov (make-overlay (point) (point))))
(overlay-put ov 'eca-chat-prompt-area t))
(setq-local eca-chat--last-response-copy-start (point-min))
- (eca-chat--refresh-copy-buttons)
- (let ((old-ov (-first (lambda (overlay)
- (overlay-get overlay
- 'eca-chat--response-copy-button))
- (overlays-in (point-min) (point-max)))))
- (save-excursion
- (goto-char (eca-chat--content-insertion-point))
- (insert "User question that should not be copied\n")
- (insert "Latest answer only\n"))
- (funcall (eca-chat-test--overlay-action old-ov))
- (expect (current-kill 0 t)
- :to-equal "Previous assistant response"))))))
+ (eca-chat--refresh-copy-scopes)
+ (save-excursion
+ (goto-char (eca-chat--content-insertion-point))
+ (insert "User question that should not be copied\n")
+ (insert "Latest answer only\n"))
+ (goto-char (point-min))
+ (eca-chat-copy-at-point)
+ (expect (current-kill 0 t)
+ :to-equal "Previous assistant response")))))
(describe "eca-chat--render-content"
(describe "progress finished"
@@ -1073,6 +988,8 @@ does not treat the first line as metadata. Returns FN's value."
(expect (eca-chat--string-pixel-width "") :to-equal 0))
(it "honors pixel-width display specs instead of counting chars"
+ (unless (fboundp 'string-pixel-width)
+ (buttercup-skip "string-pixel-width not available"))
;; A single space carrying a 40px display width must measure ~40, not
;; 1 (its `length'). Counting it as 1 char is what pushed the :usage
;; and :trust mode-line segments off the right edge once the context
@@ -1082,6 +999,8 @@ does not treat the first line as metadata. Returns FN's value."
(expect (eca-chat--string-pixel-width s) :to-equal 40)))
(it "measures a pixel context-bar wider than its char length"
+ (unless (fboundp 'string-pixel-width)
+ (buttercup-skip "string-pixel-width not available"))
(let ((bar (eca-chat--context-bar-pixels
(list (list :name "System prompt" :tokens 100 :color "#ff0000"))
(list :freeColor "#222222")
@@ -1090,6 +1009,8 @@ does not treat the first line as metadata. Returns FN's value."
(describe "eca-chat context-bar compaction marker"
(it "keeps the pixel-bar total width when the marker is overlaid"
+ (unless (fboundp 'string-pixel-width)
+ (buttercup-skip "string-pixel-width not available"))
(let* ((cats (list (list :name "System prompt" :tokens 100 :color "#ff0000")))
(bd (list :freeColor "#222222"))
(plain (eca-chat--context-bar-pixels cats bd 16 0.5))