Skip to content
Merged
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: while assistant output streamed in, the lines of a multiline prompt below point could slide under the mode line, since streamed text pushes the prompt down and redisplay only keeps point's line visible. 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.
- Perf: long thinking blocks no longer make Emacs sluggish while they stream (#337). Each `reasonText` chunk was concatenated to the whole block content, copying it along with one text property interval per chunk received so far, which made streaming quadratic: a 200k chars thinking block took over 30s of CPU, even collapsed. Chunks are now queued and only joined when the content is needed, e.g. when expanding the block, and the indent is added to the copy inserted in the buffer instead of to every chunk, so a collapsed block costs the same per chunk at any size (32.6s → 78ms for 200k chars). Subagent output and reasoning nested in a collapsed subagent block get the same treatment. Streaming in an expanded block was also slowed down by the read-only protection of the chat history, which ran the buffer change hooks over the whole current turn on each chunk, rescanning it for markdown checkboxes; it is now applied silently, as font-lock does with faces (77s → 5s for 200k chars). Measured with the new `reason-stream-{closed,open}` rows of `benchmarks/eca-chat-bench.el`.
- Bugfix: over TRAMP, Emacs could hang at 100% CPU once ECA ran a tool, leaving the chat stuck on the tool call. The server process shared TRAMP's ssh ControlMaster connection, so a remote command run while the server output wasn't read, like checking the edited file from the process filter, could wait forever for its reply (bug#61350). The server now gets its own ssh connection, as Eglot does for language servers, and file change tool calls no longer check remote files while rendering.
- Show subscription quota instead of costs in the chat mode-line and providers buffer. The last message cost (`:last-message-cost`) now shows too.
Expand Down
44 changes: 35 additions & 9 deletions eca-chat.el
Original file line number Diff line number Diff line change
Expand Up @@ -2142,17 +2142,42 @@ 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--ensure-prompt-end-visible (win)
"Scroll WIN so the end of the prompt is fully visible, keeping point.
Text inserted above the prompt pushes it down, and redisplay only
keeps the line of point visible, not the prompt lines below it."
(when (window-live-p win)
(with-selected-window win
(save-excursion
(unless (pos-visible-in-window-p (point-max))
(goto-char (point-max))
(recenter -1)
;; `recenter' can leave the last row partly under the mode
;; line when the window is not a whole number of lines tall.
(unless (pos-visible-in-window-p (point-max))
(goto-char (window-start))
(vertical-motion 1)
(set-window-start nil (point) t)))))))

(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 @@ -3842,10 +3867,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--remote-relative-filename (filename roots)
"Return remote FILENAME relative to the first of ROOTS holding it.
Expand Down
68 changes: 68 additions & 0 deletions test/eca-chat-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,74 @@ around rendering applies, as when the chat window is selected."
(setq eca-chat--keep-point t))
(expect (point) :to-equal (point-min)))))

(defmacro eca-chat-test--with-streaming-prompt (visible &rest body)
"Run BODY in a shown render buffer, point on a two-line prompt.
VISIBLE is the value `pos-visible-in-window-p' returns."
(declare (indent 1) (debug t))
`(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))
(spy-on 'eca-chat--viewing-bottom-p :and-return-value t)
(spy-on 'pos-visible-in-window-p :and-return-value ,visible)
(spy-on 'recenter)
,@body))
(kill-buffer buf))))

(defun eca-chat-test--stream-output (text)
"Render TEXT above the prompt the way streaming does."
(eca-chat--with-point-preserved
(save-excursion
(goto-char (eca-chat--content-insertion-point))
(eca-chat--insert text))))

(describe "eca-chat prompt visibility while streaming"
(it "scrolls a hidden prompt end into view, keeping point"
(eca-chat-test--with-streaming-prompt nil
(let ((cursor (point)))
(eca-chat-test--stream-output "output\n")
(expect 'recenter :to-have-been-called-with -1)
(expect (point) :to-equal (+ cursor 7)))))

(it "scrolls one more line when recenter leaves the end clipped"
(eca-chat-test--with-streaming-prompt nil
(set-window-start (selected-window) (point-min))
(eca-chat-test--stream-output "output\n")
(expect 'recenter :to-have-been-called-with -1)
(expect (window-start) :to-equal
(save-excursion
(goto-char (point-min))
(vertical-motion 1)
(point)))))

(it "does not scroll when the prompt end stays visible"
(eca-chat-test--with-streaming-prompt t
(eca-chat-test--stream-output "output\n")
(expect 'recenter :not :to-have-been-called)))

(it "does not scroll when point is outside the prompt"
(eca-chat-test--with-streaming-prompt nil
(goto-char (point-min))
(eca-chat-test--stream-output "output\n")
(expect 'recenter :not :to-have-been-called)))

(it "does not scroll while the user reads earlier content"
(eca-chat-test--with-streaming-prompt nil
(spy-on 'eca-chat--viewing-bottom-p :and-return-value nil)
(eca-chat-test--stream-output "output\n")
(expect 'recenter :not :to-have-been-called)))

(it "scrolls after a timer flush of streamed text"
(eca-chat-test--with-streaming-prompt nil
(setq-local eca-chat--stream-pending-chunks (list "output\n"))
(spy-on 'eca-chat--schedule-fontify)
(eca-chat--stream-flush)
(expect 'recenter :to-have-been-called-with -1))))

(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