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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `#`).
Expand Down
83 changes: 75 additions & 8 deletions eca-chat.el
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand Down Expand Up @@ -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))))
Expand Down Expand Up @@ -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))
Expand Down
194 changes: 194 additions & 0 deletions test/eca-chat-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading