diff --git a/eca-chat.el b/eca-chat.el index 693cdd4..3f81591 100644 --- a/eca-chat.el +++ b/eca-chat.el @@ -2763,6 +2763,16 @@ matches the region we considered." (setq pos next)))) `(jit-lock-bounds ,beg . ,end)) +(defun eca-chat--font-lock-ensure (beg end) + "Fontify BEG to END without shifting visible chat windows. +Hidden markdown markup can change display geometry after streaming. +Preserve window starts and force a cheap redisplay update so fenced +block layout settles before later point motion." + (prog1 + (eca-chat--with-preserved-scroll + (font-lock-ensure beg end)) + (force-window-update (current-buffer)))) + (defun eca-chat--schedule-fontify () "Schedule a deferred `font-lock-ensure' for the current chat buffer. Cancels any previously scheduled timer. Does nothing when @@ -2785,7 +2795,7 @@ the new turn instead of the full chat history." (when (buffer-live-p buf) (with-current-buffer buf (setq eca-chat--fontify-timer nil) - (font-lock-ensure + (eca-chat--font-lock-ensure (or eca-chat--last-user-message-pos (point-min)) (point-max)))))))))) @@ -3910,8 +3920,9 @@ Must be called with `eca-chat--with-current-buffer' or equivalent." ;; (re)fontification at end-of-stream; full-buffer ;; fontify is O(buffer size) and was the dominant ;; cost on long chats. - (font-lock-ensure (or eca-chat--last-user-message-pos (point-min)) - (point-max)) + (eca-chat--font-lock-ensure + (or eca-chat--last-user-message-pos (point-min)) + (point-max)) (eca-chat--refresh-copy-scopes) (eca-chat--set-chat-loading session nil) (eca-chat--refresh-progress chat-buffer) @@ -3932,8 +3943,9 @@ Must be called with `eca-chat--with-current-buffer' or equivalent." ;; to the current turn so cost does not grow with ;; chat history; previous turns were already ;; fontified at their own end-of-stream. - (font-lock-ensure (or eca-chat--last-user-message-pos (point-min)) - (point-max)) + (eca-chat--font-lock-ensure + (or eca-chat--last-user-message-pos (point-min)) + (point-max)) (eca-chat--refresh-copy-scopes) ;; Table align/beautify default to scanning from ;; `eca-chat--last-user-message-pos' when called with diff --git a/test/eca-chat-test.el b/test/eca-chat-test.el index e3e4d46..6d5f292 100644 --- a/test/eca-chat-test.el +++ b/test/eca-chat-test.el @@ -482,6 +482,35 @@ does not treat the first line as metadata. Returns FN's value." (expect (current-kill 0 t) :to-equal "Previous assistant response"))))) +(describe "eca-chat--font-lock-ensure" + (it "fontifies and requests a redisplay update" + (with-temp-buffer + (spy-on 'font-lock-ensure :and-return-value 'fontified) + (spy-on 'force-window-update) + (expect (eca-chat--font-lock-ensure 1 1) :to-equal 'fontified) + (expect 'font-lock-ensure :to-have-been-called-with 1 1) + (expect 'force-window-update + :to-have-been-called-with (current-buffer))))) + +(describe "eca-chat--schedule-fontify" + (it "uses stable fontification from the current turn" + (with-temp-buffer + (insert "abc") + (setq-local eca-chat--last-user-message-pos 2) + (setq-local eca-chat-fontify-debounce-interval 0.15) + (let (scheduled) + (cl-letf (((symbol-function 'run-with-idle-timer) + (lambda (_secs _repeat fn &rest args) + (setq scheduled (lambda () (apply fn args))) + 'test-timer))) + (spy-on 'eca-chat--font-lock-ensure) + (eca-chat--schedule-fontify) + (expect scheduled :to-be-truthy) + (funcall scheduled) + (expect 'eca-chat--font-lock-ensure + :to-have-been-called-with 2 (point-max)) + (expect eca-chat--fontify-timer :to-be nil)))))) + (describe "eca-chat--render-content" (describe "progress finished" (it "clears progress text and spinner when chat-loading is nil" @@ -512,6 +541,31 @@ does not treat the first line as metadata. Returns FN's value." :to-have-been-called)) (when (timerp eca-chat--spinner-timer) (cancel-timer eca-chat--spinner-timer)) + (kill-buffer buf)))) + + (it "uses stable fontification for normal completion" + (let ((buf (eca-chat-test--make-prompt-buffer "")) + (session (make-eca--session))) + (unwind-protect + (with-current-buffer buf + (setq-local eca-chat--progress-text "thinking...") + (setq-local eca-chat--chat-loading t) + (setq-local eca-chat--last-user-message-pos (point-min)) + (spy-on 'eca-chat--font-lock-ensure) + (spy-on 'eca-chat--align-tables) + (spy-on 'eca-chat--beautify-tables) + (spy-on 'eca-chat--refresh-progress) + (spy-on 'eca-chat--set-chat-loading) + (spy-on 'eca-chat--send-steered-prompt) + (spy-on 'eca-chat--send-queued-prompt) + (eca-chat--render-content + session buf "system" + (list :type "progress" :state "finished") + nil) + (expect 'eca-chat--font-lock-ensure + :to-have-been-called-with (point-min) (point-max)) + (expect 'eca-chat--align-tables :to-have-been-called) + (expect 'eca-chat--beautify-tables :to-have-been-called)) (kill-buffer buf)))))) (describe "eca-chat--transient-segment-loading"