diff --git a/README.md b/README.md index fbbf02b..0e34907 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,7 @@ Chat - `eca-chat-prompt-prefix-loading`: Prompt prefix string while a request is in progress. - `eca-chat-read-only-history`: Whether the chat history/output, the `---` separator and the task area are read-only so only the progress, `@`-context and prompt input lines stay editable (default `t`). Set to `nil` to keep the whole buffer writable. - `eca-chat-hide-markdown-markup`: Whether to hide markdown markup in chat buffers (default `t`). Set to `nil` to keep fences/backticks visible, which may avoid fenced code blocks jumping while typing or streaming. +- `eca-chat-fontify-prompt`: Whether to apply Markdown fontification to prompt text (default `t`). Set to `nil` to skip prompt-area Markdown block scans in large chat buffers. - `eca-chat-history-page-size`: Number of newest messages to load when opening a persisted chat (default `50`). When non-nil, `eca-chat-resume` opens chats with a bounded window and shows a "Load older messages" control to page through earlier history on demand; set to `nil` to replay the entire history on open. - `eca-chat-context-prefix`: Prefix used for context references in the chat buffer (default `@`). - `eca-chat-filepath-prefix`: Prefix used for file path references in the chat buffer (default `#`). diff --git a/eca-chat.el b/eca-chat.el index abb36f6..cd17c57 100644 --- a/eca-chat.el +++ b/eca-chat.el @@ -325,12 +325,75 @@ typing or streaming." :type 'boolean :group 'eca) +(defcustom eca-chat-fontify-prompt t + "Whether ECA chat applies Markdown fontification to prompt text. +When non-nil, preserve the historical prompt rendering behavior. +When nil, prompt edits skip Markdown block scans over chat history. +Set this to nil if typing in large ECA chat buffers is slow." + :type 'boolean + :group 'eca) + (defun eca-chat--apply-markdown-markup-visibility () "Apply `eca-chat-hide-markdown-markup' in current buffer." (if eca-chat-hide-markdown-markup (add-to-invisibility-spec 'markdown-markup) (remove-from-invisibility-spec 'markdown-markup))) +(defvar-local eca-chat--syntax-propertize-function nil + "Original syntax propertizer wrapped by ECA chat.") + +(defun eca-chat--fontification-history-end (end) + "Return END clipped at the prompt when prompt fontification is nil." + (if eca-chat-fontify-prompt + end + (if-let* ((prompt-start (eca-chat--prompt-area-start-point))) + (min end prompt-start) + end))) + +(defun eca-chat--syntax-propertize (beg end) + "Apply syntax properties from BEG to END, respecting prompt settings." + (let ((fontify-end (eca-chat--fontification-history-end end))) + (when (and eca-chat--syntax-propertize-function + (< beg fontify-end)) + (funcall eca-chat--syntax-propertize-function beg fontify-end)))) + +(defun eca-chat--font-lock-extend-region-function (beg end old-len) + "Extend Markdown font-lock region from BEG to END with OLD-LEN." + (when (fboundp 'markdown-font-lock-extend-region-function) + (if eca-chat-fontify-prompt + (markdown-font-lock-extend-region-function beg end old-len) + (let ((fontify-end (eca-chat--fontification-history-end end))) + (when (< beg fontify-end) + (markdown-font-lock-extend-region-function + beg fontify-end old-len)))))) + +(defun eca-chat--syntax-propertize-extend-region-function (beg end) + "Extend Markdown syntax region from BEG to END, respecting prompt." + (when (fboundp 'markdown-syntax-propertize-extend-region) + (if eca-chat-fontify-prompt + (markdown-syntax-propertize-extend-region beg end) + (if-let* ((prompt-start (eca-chat--prompt-area-start-point))) + (when (<= end prompt-start) + (save-restriction + (narrow-to-region (point-min) prompt-start) + (markdown-syntax-propertize-extend-region beg end))) + (markdown-syntax-propertize-extend-region beg end))))) + +(defun eca-chat--install-fontification-overrides () + "Install ECA chat fontification wrappers in current buffer." + (unless (eq syntax-propertize-function #'eca-chat--syntax-propertize) + (setq-local eca-chat--syntax-propertize-function syntax-propertize-function) + (setq-local syntax-propertize-function #'eca-chat--syntax-propertize)) + (remove-hook 'syntax-propertize-extend-region-functions + #'markdown-syntax-propertize-extend-region t) + (add-hook 'syntax-propertize-extend-region-functions + #'eca-chat--syntax-propertize-extend-region-function nil t) + (remove-hook 'jit-lock-after-change-extend-region-functions + #'markdown-font-lock-extend-region-function t) + (add-hook 'jit-lock-after-change-extend-region-functions + #'eca-chat--font-lock-extend-region-function t t) + (setq-local font-lock-fontify-region-function #'eca-chat--fontify-region)) + (defvar-local eca-chat--tool-call-prepare-counters (make-hash-table :test 'equal) "Hash table mapping toolCall ID to message count.") @@ -3167,11 +3230,13 @@ fontifier runs normally. Returns `(jit-lock-bounds BEG . END)' so jit-lock's bookkeeping matches the region we considered." - (let ((pos beg)) - (while (< pos end) + (let ((pos beg) + (fontify-end (eca-chat--fontification-history-end end))) + (while (< pos fontify-end) (let ((skip (get-text-property pos 'eca-no-fontify)) - (next (or (next-single-property-change pos 'eca-no-fontify nil end) - end))) + (next (or (next-single-property-change + pos 'eca-no-fontify nil fontify-end) + fontify-end))) (unless skip (font-lock-default-fontify-region pos next loudly)) (setq pos next)))) @@ -3549,10 +3614,12 @@ CHILD, NAME, DOCSTRING and BODY are passed down." (seq-difference font-lock-extra-managed-props '(keymap help-echo mouse-face))) - ;; Skip font-lock on ranges tagged with `eca-no-fontify', currently - ;; used to stop gfm/markdown matchers from re-fontifying streaming - ;; tool-call argument bodies on every chunk (see #234). - (setq-local font-lock-fontify-region-function #'eca-chat--fontify-region) + ;; Keep chat fontification overrides together. Skip font-lock on + ;; ranges tagged with `eca-no-fontify', which stops gfm/markdown + ;; matchers from re-fontifying streaming tool-call argument bodies on + ;; every chunk (see #234). When configured, also skip prompt-area + ;; Markdown block scans during typing. + (eca-chat--install-fontification-overrides) (make-local-variable 'completion-at-point-functions) (setq-local completion-at-point-functions (list #'eca-chat-completion-at-point)) diff --git a/test/eca-chat-test.el b/test/eca-chat-test.el index 54cfb86..d623346 100644 --- a/test/eca-chat-test.el +++ b/test/eca-chat-test.el @@ -1055,6 +1055,200 @@ When MANUAL is non-nil the tool call requires manual approval." :to-have-been-called-with 2 (point-max)) (expect eca-chat--fontify-timer :to-be nil)))))) +(describe "eca-chat prompt fontification guard" + (it "keeps prompt markdown after-change extension enabled by default" + (with-temp-buffer + (let ((eca--chat-init-session (make-eca--session)) + (eca--chat-init-skip-welcome t) + markdown-called) + (eca-chat-mode) + (goto-char (eca-chat--prompt-field-start-point)) + (expect eca-chat-fontify-prompt :to-be-truthy) + (cl-letf (((symbol-function 'markdown-font-lock-extend-region-function) + (lambda (&rest _args) + (setq markdown-called t)))) + (run-hook-with-args 'jit-lock-after-change-extend-region-functions + (point) (point) 0)) + (expect markdown-called :to-be-truthy)))) + + (it "keeps prompt syntax propertize enabled by default" + (with-temp-buffer + (let ((eca--chat-init-session (make-eca--session)) + (eca--chat-init-skip-welcome t) + calls) + (eca-chat-mode) + (let ((prompt-start (eca-chat--prompt-field-start-point))) + (goto-char prompt-start) + (insert "prompt") + (expect eca-chat-fontify-prompt :to-be-truthy) + (setq-local eca-chat--syntax-propertize-function + (lambda (beg end) + (push (list beg end) calls))) + (funcall syntax-propertize-function prompt-start (point-max)) + (expect (nreverse calls) + :to-equal (list (list prompt-start (point-max)))))))) + + (it "wraps markdown syntax region extension in chat buffers" + (with-temp-buffer + (let ((eca--chat-init-session (make-eca--session)) + (eca--chat-init-skip-welcome t)) + (eca-chat-mode) + (expect (memq #'eca-chat--syntax-propertize-extend-region-function + syntax-propertize-extend-region-functions) + :to-be-truthy) + (expect (memq #'markdown-syntax-propertize-extend-region + syntax-propertize-extend-region-functions) + :to-be nil)))) + + (it "keeps prompt syntax region extension enabled by default" + (with-temp-buffer + (let ((eca--chat-init-session (make-eca--session)) + (eca--chat-init-skip-welcome t) + calls) + (eca-chat-mode) + (goto-char (eca-chat--prompt-field-start-point)) + (insert "prompt") + (expect eca-chat-fontify-prompt :to-be-truthy) + (cl-letf (((symbol-function 'markdown-syntax-propertize-extend-region) + (lambda (beg end) + (push (list beg end) calls) + nil))) + (run-hook-wrapped 'syntax-propertize-extend-region-functions + (lambda (fn) + (funcall fn (point) (point-max)) + nil))) + (expect calls :to-be-truthy)))) + + (it "can skip markdown syntax extension for prompt syntax queries" + (with-temp-buffer + (let ((eca-chat-fontify-prompt nil) + (eca--chat-init-session (make-eca--session)) + (eca--chat-init-skip-welcome t) + calls) + (eca-chat-mode) + (save-excursion + (goto-char (eca-chat--content-insertion-point)) + (eca-chat--insert (make-string 1000 ?x)) + (eca-chat--insert "\n")) + (goto-char (eca-chat--prompt-field-start-point)) + (insert "```\nprompt\n") + (cl-letf (((symbol-function 'markdown-syntax-propertize-extend-region) + (lambda (beg end) + (push (list beg end) calls) + nil))) + (syntax-ppss-flush-cache (point-min)) + (syntax-ppss (point))) + (expect calls :to-be nil)))) + + (it "still uses markdown syntax extension for history queries" + (with-temp-buffer + (let ((eca-chat-fontify-prompt nil) + (eca--chat-init-session (make-eca--session)) + (eca--chat-init-skip-welcome t) + calls) + (eca-chat-mode) + (save-excursion + (goto-char (eca-chat--content-insertion-point)) + (eca-chat--insert (make-string 1000 ?x)) + (eca-chat--insert "\n")) + (goto-char (point-min)) + (forward-char 10) + (let ((prompt-start (eca-chat--prompt-area-start-point))) + (cl-letf (((symbol-function 'markdown-syntax-propertize-extend-region) + (lambda (beg end) + (push (list beg end (point-max)) calls) + nil))) + (syntax-ppss-flush-cache (point-min)) + (syntax-ppss (point))) + (expect calls :to-be-truthy) + (expect (< (caar calls) prompt-start) :to-be-truthy) + (expect (cl-third (car calls)) :to-equal prompt-start))))) + + (it "fontifies through the prompt area by default" + (with-temp-buffer + (let ((eca--chat-init-session (make-eca--session)) + (eca--chat-init-skip-welcome t) + calls) + (eca-chat-mode) + (save-excursion + (goto-char (eca-chat--content-insertion-point)) + (eca-chat--insert "history\n")) + (expect eca-chat-fontify-prompt :to-be-truthy) + (cl-letf (((symbol-function 'font-lock-default-fontify-region) + (lambda (beg end loudly) + (push (list beg end loudly) calls)))) + (eca-chat--fontify-region (point-min) (point-max)) + (expect (nreverse calls) + :to-equal (list (list (point-min) (point-max) nil))))))) + + (it "can skip markdown after-change extension for prompt edits" + (with-temp-buffer + (let ((eca-chat-fontify-prompt nil) + (eca--chat-init-session (make-eca--session)) + (eca--chat-init-skip-welcome t) + markdown-called) + (eca-chat-mode) + (goto-char (eca-chat--prompt-field-start-point)) + (cl-letf (((symbol-function 'markdown-font-lock-extend-region-function) + (lambda (&rest _args) + (setq markdown-called t)))) + (run-hook-with-args 'jit-lock-after-change-extend-region-functions + (point) (point) 0)) + (expect markdown-called :to-be nil)))) + + (it "can skip syntax propertize for prompt-only regions" + (with-temp-buffer + (let ((eca-chat-fontify-prompt nil) + (eca--chat-init-session (make-eca--session)) + (eca--chat-init-skip-welcome t) + calls) + (eca-chat-mode) + (goto-char (eca-chat--prompt-field-start-point)) + (insert "prompt") + (setq-local eca-chat--syntax-propertize-function + (lambda (beg end) + (push (list beg end) calls))) + (funcall syntax-propertize-function + (eca-chat--prompt-field-start-point) + (point-max)) + (expect calls :to-be nil)))) + + (it "still runs syntax propertize for history regions" + (with-temp-buffer + (let ((eca-chat-fontify-prompt nil) + (eca--chat-init-session (make-eca--session)) + (eca--chat-init-skip-welcome t) + calls) + (eca-chat-mode) + (save-excursion + (goto-char (eca-chat--content-insertion-point)) + (eca-chat--insert "history\n")) + (let ((prompt-start (eca-chat--prompt-area-start-point))) + (setq-local eca-chat--syntax-propertize-function + (lambda (beg end) + (push (list beg end) calls))) + (funcall syntax-propertize-function (point-min) prompt-start) + (expect (nreverse calls) + :to-equal (list (list (point-min) prompt-start))))))) + + (it "clips chat fontification before the prompt area when disabled" + (with-temp-buffer + (let ((eca-chat-fontify-prompt nil) + (eca--chat-init-session (make-eca--session)) + (eca--chat-init-skip-welcome t) + calls) + (eca-chat-mode) + (save-excursion + (goto-char (eca-chat--content-insertion-point)) + (eca-chat--insert "history\n")) + (let ((prompt-start (eca-chat--prompt-area-start-point))) + (cl-letf (((symbol-function 'font-lock-default-fontify-region) + (lambda (beg end loudly) + (push (list beg end loudly) calls)))) + (eca-chat--fontify-region (point-min) (point-max)) + (expect (nreverse calls) + :to-equal (list (list (point-min) prompt-start nil))))))))) + (describe "eca-chat--render-content" (describe "progress finished" (it "clears progress text and spinner when chat-loading is nil"