From 806ac138029a4eb33c95cb7c59b680f51f1cab7b Mon Sep 17 00:00:00 2001 From: shohi Date: Mon, 28 Sep 2026 21:29:10 -0400 Subject: [PATCH] Fix chat prompt partly covered by the mode line When editing the prompt or streaming output leaves the end of the prompt not fully visible, scroll just enough to show it, without moving point. --- CHANGELOG.md | 1 + eca-chat.el | 77 ++++++++++++++++--- test/eca-chat-test.el | 168 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6500b68..37866fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/eca-chat.el b/eca-chat.el index 728f0ee..ac1509e 100644 --- a/eca-chat.el +++ b/eca-chat.el @@ -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 @@ -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. @@ -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 diff --git a/test/eca-chat-test.el b/test/eca-chat-test.el index 70d2d80..91b4981 100644 --- a/test/eca-chat-test.el +++ b/test/eca-chat-test.el @@ -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