Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Bugfix: a multiline prompt could be partly covered by the mode line, making it hard to keep typing: a line just added with `S-RET` or by wrapping long typed or pasted input, or the lines below point while assistant output streamed in. The chat now scrolls just enough to keep the end of the prompt fully visible, without moving point, and still does not scroll while reading earlier content.
- 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`.
- 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.
- 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.
Expand Down
77 changes: 68 additions & 9 deletions eca-chat.el
Original file line number Diff line number Diff line change
Expand Up @@ -2125,17 +2125,73 @@ Rendering inserts text wherever the content goes, dragging point
along, and streaming must not move the cursor. Helpers that move
point deliberately set `eca-chat--keep-point' to keep the new
position: a plain `save-excursion' would undo it whenever the chat
window is the selected one."
window is the selected one. When point is in the prompt and the
bottom of the chat is shown, keep the end of the prompt visible."
(declare (indent 0) (debug t))
(let ((saved (gensym "saved-point-")))
`(let ((eca-chat--keep-point nil)
(,saved (point-marker)))
(let ((saved (gensym "saved-point-"))
(win (gensym "chat-window-")))
`(let* ((eca-chat--keep-point nil)
(,saved (point-marker))
(,win (when-let* ((w (get-buffer-window (current-buffer))))
(and (eca-chat--point-at-prompt-field-p)
(eca-chat--viewing-bottom-p w)
w))))
(unwind-protect (progn ,@body)
(when (and (not eca-chat--keep-point)
(eq (marker-buffer ,saved) (current-buffer)))
(goto-char ,saved))
(goto-char ,saved)
(when ,win
(eca-chat--ensure-prompt-end-visible ,win)))
(set-marker ,saved nil)))))

(defun eca-chat--prompt-end-fully-visible-p (win)
"Return non-nil when the text at the prompt end is fully shown in WIN.
A row clipped only in its `line-spacing' still counts as visible."
(pcase (pos-visible-in-window-p (point-max) win t)
(`(,_ ,_) t)
(`(,_ ,_ ,_ ,_ ,visible-height ,_)
(>= visible-height (default-font-height)))))

(defun eca-chat--ensure-prompt-end-visible (win)
"Scroll WIN so the end of the prompt is fully visible, keeping point.
Point stays on its line, so redisplay alone would leave the prompt
lines below it clipped by the mode line. Scroll one screen line at
a time: `recenter' can leave an empty row with `line-spacing'."
(when (and (window-live-p win)
(eq (window-buffer win) (current-buffer))
(not (eca-chat--prompt-end-fully-visible-p win)))
(save-excursion
(goto-char (window-start win))
(let ((rows (window-body-height win)))
(while (and (> rows 0)
(not (eca-chat--prompt-end-fully-visible-p win)))
(vertical-motion 1 win)
(set-window-start win (point) t)
(setq rows (1- rows)))))
;; Far below the window: fall back to one jump.
(unless (eca-chat--prompt-end-fully-visible-p win)
(with-selected-window win
(save-excursion
(goto-char (point-max))
(recenter -1))))))

(defvar-local eca-chat--prompt-edited nil
"Non-nil when the current command edited the prompt.")

(defun eca-chat--note-prompt-edit (beg _end _old-len)
"Flag an edit at BEG inside the prompt for `post-command-hook'."
(when-let* ((prompt (eca-chat--prompt-field-start-point)))
(when (>= beg prompt)
(setq eca-chat--prompt-edited t))))

(defun eca-chat--keep-prompt-end-visible ()
"Keep the end of the prompt visible after a command that edited it."
(when eca-chat--prompt-edited
(setq eca-chat--prompt-edited nil)
(when-let* ((win (get-buffer-window (current-buffer))))
(when (eca-chat--point-at-prompt-field-p)
(eca-chat--ensure-prompt-end-visible win)))))

(defun eca-chat--viewing-bottom-p (win)
"Return non-nil when the prompt separator is displayed in WIN.
That means the user is viewing the bottom of the chat, so it is
Expand Down Expand Up @@ -3778,10 +3834,11 @@ Add a overlay before with OVERLAY-KEY = OVERLAY-VALUE if passed."
(when chunks
(let ((text (mapconcat #'identity (nreverse chunks) "")))
(unless (string-empty-p text)
(save-excursion
(eca-chat--add-text-content text)
(eca-chat--schedule-fontify)
(eca-chat--protect-non-prompt eca-chat--last-user-message-pos)))))))
(eca-chat--with-point-preserved
(save-excursion
(eca-chat--add-text-content text)
(eca-chat--schedule-fontify)
(eca-chat--protect-non-prompt eca-chat--last-user-message-pos))))))))

(defun eca-chat--relativize-filename-for-workspace-root (filename roots &optional hide-filename?)
"Relativize the FILENAME if a workspace root is found for ROOTS.
Expand Down Expand Up @@ -3998,6 +4055,8 @@ CHILD, NAME, DOCSTRING and BODY are passed down."

;; Turn raw @path/#path tokens into proper items after a space.
(add-hook 'post-self-insert-hook #'eca-chat--post-self-insert nil t)
(add-hook 'after-change-functions #'eca-chat--note-prompt-edit nil t)
(add-hook 'post-command-hook #'eca-chat--keep-prompt-end-visible nil t)

(eca-chat--setup-item-stickiness)
(add-hook 'after-change-functions
Expand Down
168 changes: 168 additions & 0 deletions test/eca-chat-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,174 @@ around rendering applies, as when the chat window is selected."
(setq eca-chat--keep-point t))
(expect (point) :to-equal (point-min)))))

(defconst eca-chat-test--clipped-pos '(0 0 0 16 0 5)
"A `pos-visible-in-window-p' result for a row clipped at the bottom.")

(defconst eca-chat-test--spacing-clipped-pos '(0 0 0 2 15 5)
"A `pos-visible-in-window-p' result clipping only `line-spacing'.")

(defconst eca-chat-test--full-pos '(0 0)
"A `pos-visible-in-window-p' result for a fully visible position.")

(defun eca-chat-test--make-visibility-buffer ()
"Create a render buffer with the prompt visibility hooks installed."
(let ((buf (eca-chat-test--make-render-buffer)))
(with-current-buffer buf
(add-hook 'after-change-functions #'eca-chat--note-prompt-edit nil t)
(add-hook 'post-command-hook #'eca-chat--keep-prompt-end-visible nil t)
(goto-char (point-max)))
buf))

(describe "eca-chat multiline prompt visibility"
(it "scrolls after a command leaves the prompt end clipped"
(let ((buf (eca-chat-test--make-visibility-buffer)))
(unwind-protect
(save-window-excursion
(set-window-buffer (selected-window) buf)
(with-current-buffer buf
(insert "first line")
(run-hooks 'post-command-hook)
(spy-on 'pos-visible-in-window-p
:and-return-value eca-chat-test--clipped-pos)
(spy-on 'recenter)
(insert "\n")
(run-hooks 'post-command-hook)
(expect 'recenter :to-have-been-called-with -1)
(expect (point) :to-equal (point-max))))
(kill-buffer buf))))

(it "does not scroll when the prompt end stays fully visible"
(let ((buf (eca-chat-test--make-visibility-buffer)))
(unwind-protect
(save-window-excursion
(set-window-buffer (selected-window) buf)
(with-current-buffer buf
(spy-on 'pos-visible-in-window-p
:and-return-value eca-chat-test--full-pos)
(spy-on 'recenter)
(insert "short")
(run-hooks 'post-command-hook)
(expect 'recenter :not :to-have-been-called)))
(kill-buffer buf))))

(it "does not scroll when only the line spacing is clipped"
(let ((buf (eca-chat-test--make-visibility-buffer)))
(unwind-protect
(save-window-excursion
(set-window-buffer (selected-window) buf)
(with-current-buffer buf
(spy-on 'pos-visible-in-window-p
:and-return-value eca-chat-test--spacing-clipped-pos)
(spy-on 'default-font-height :and-return-value 14)
(spy-on 'recenter)
(let ((start (window-start)))
(insert "short")
(run-hooks 'post-command-hook)
(expect (window-start) :to-equal start))
(expect 'recenter :not :to-have-been-called)))
(kill-buffer buf))))

(it "scrolls one screen line at a time instead of recentering"
(let ((buf (eca-chat-test--make-visibility-buffer)))
(unwind-protect
(save-window-excursion
(set-window-buffer (selected-window) buf)
(with-current-buffer buf
(set-window-start (selected-window) (point-min))
;; Clipped until the window has scrolled once.
(spy-on 'pos-visible-in-window-p
:and-call-fake
(lambda (&rest _)
(if (eq (window-start) (point-min))
eca-chat-test--clipped-pos
eca-chat-test--full-pos)))
(spy-on 'recenter)
(insert "\n")
(run-hooks 'post-command-hook)
(expect (window-start) :to-equal
(save-excursion
(goto-char (point-min))
(vertical-motion 1)
(point)))
(expect 'recenter :not :to-have-been-called)))
(kill-buffer buf))))

(it "ignores edits outside the prompt after a command"
(let ((buf (eca-chat-test--make-visibility-buffer)))
(unwind-protect
(save-window-excursion
(set-window-buffer (selected-window) buf)
(with-current-buffer buf
(run-hooks 'post-command-hook)
(spy-on 'pos-visible-in-window-p
:and-return-value eca-chat-test--clipped-pos)
(spy-on 'recenter)
(save-excursion
(goto-char (eca-chat--content-insertion-point))
(eca-chat--insert "output\n"))
(run-hooks 'post-command-hook)
(expect 'recenter :not :to-have-been-called)))
(kill-buffer buf))))

(it "keeps a clipped prompt end visible when output arrives"
(let ((buf (eca-chat-test--make-render-buffer)))
(unwind-protect
(save-window-excursion
(set-window-buffer (selected-window) buf)
(with-current-buffer buf
(goto-char (point-max))
(insert "first line\nsecond line")
(goto-char (eca-chat--prompt-field-start-point))
(let ((cursor (point)))
(spy-on 'eca-chat--viewing-bottom-p :and-return-value t)
(spy-on 'pos-visible-in-window-p
:and-return-value eca-chat-test--clipped-pos)
(spy-on 'recenter)
(eca-chat--with-point-preserved
(save-excursion
(goto-char (eca-chat--content-insertion-point))
(eca-chat--insert "output\n")))
(expect 'recenter :to-have-been-called-with -1)
(expect (point) :to-equal (+ cursor 7)))))
(kill-buffer buf))))

(it "does not scroll on output while reading history"
(let ((buf (eca-chat-test--make-render-buffer)))
(unwind-protect
(save-window-excursion
(set-window-buffer (selected-window) buf)
(with-current-buffer buf
(goto-char (point-min))
(spy-on 'eca-chat--viewing-bottom-p :and-return-value t)
(spy-on 'pos-visible-in-window-p
:and-return-value eca-chat-test--clipped-pos)
(spy-on 'recenter)
(eca-chat--with-point-preserved
(save-excursion
(goto-char (eca-chat--content-insertion-point))
(eca-chat--insert "more output\n")))
(expect 'recenter :not :to-have-been-called)))
(kill-buffer buf))))

(it "keeps a clipped prompt end visible after a timer flush"
(let ((buf (eca-chat-test--make-render-buffer)))
(unwind-protect
(save-window-excursion
(set-window-buffer (selected-window) buf)
(with-current-buffer buf
(goto-char (point-max))
(insert "first line\nsecond line")
(goto-char (eca-chat--prompt-field-start-point))
(setq-local eca-chat--stream-pending-chunks (list "output\n"))
(spy-on 'eca-chat--schedule-fontify)
(spy-on 'eca-chat--viewing-bottom-p :and-return-value t)
(spy-on 'pos-visible-in-window-p
:and-return-value eca-chat-test--clipped-pos)
(spy-on 'recenter)
(eca-chat--stream-flush)
(expect 'recenter :to-have-been-called-with -1)))
(kill-buffer buf)))))

(describe "eca-chat--ensure-tool-call-approval-visible"
;; Issue #308: a tool call awaiting approval whose expanded body is
;; taller than the window must keep its label and buttons in view
Expand Down
Loading