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
13 changes: 0 additions & 13 deletions pi-coding-agent-core.el
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,6 @@ Sets status to `streaming' on agent_start, `idle' on agent_end."
(setq pi-coding-agent--state-timestamp (float-time)))
("message_start"
(plist-put pi-coding-agent--state :current-message (plist-get event :message)))
("message_update"
(pi-coding-agent--handle-message-update event))
("message_end"
(plist-put pi-coding-agent--state :current-message nil))
("tool_execution_start"
Expand All @@ -284,17 +282,6 @@ Sets status to `streaming' on agent_start, `idle' on agent_end."
("extension_error"
(plist-put pi-coding-agent--state :last-error (plist-get event :error))))))

(defun pi-coding-agent--handle-message-update (event)
"Handle a message_update EVENT by accumulating text deltas."
(let* ((msg-event (plist-get event :assistantMessageEvent))
(event-type (plist-get msg-event :type))
(current (plist-get pi-coding-agent--state :current-message)))
(when (and current (equal event-type "text_delta"))
(let* ((delta (plist-get msg-event :delta))
(content (or (plist-get current :content) ""))
(new-content (concat content delta)))
(plist-put current :content new-content)))))

(defun pi-coding-agent--ensure-active-tools ()
"Ensure :active-tools hash table exists in state."
(unless (plist-get pi-coding-agent--state :active-tools)
Expand Down
134 changes: 91 additions & 43 deletions pi-coding-agent-render.el
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ Shown when the session starts without a configured model/API key."

(defun pi-coding-agent--cleanup-on-kill ()
"Clean up resources when chat buffer is killed.
Also kills the linked input buffer.
Also kills the linked input buffer and fontification cache buffers.

Note: This runs from `kill-buffer-hook', which executes AFTER the kill
decision is made. For proper cancellation support, use `pi-coding-agent-quit'
Expand All @@ -472,6 +472,7 @@ which asks upfront before any buffers are touched."
(pi-coding-agent--unregister-display-handler pi-coding-agent--process)
(when (process-live-p pi-coding-agent--process)
(delete-process pi-coding-agent--process)))
(pi-coding-agent--kill-fontify-buffers)
(when (and pi-coding-agent--input-buffer (buffer-live-p pi-coding-agent--input-buffer))
(let ((input-buf pi-coding-agent--input-buffer))
(pi-coding-agent--set-input-buffer nil) ; break cycle before kill
Expand Down Expand Up @@ -975,10 +976,13 @@ when multi-line constructs (docstrings, block comments) start
above the visible window.

Inhibits modification hooks to prevent jit-lock from scanning the
full buffer on each delta."
full buffer on each delta. Skips the delete+reinsert cycle when the
visible tail content is unchanged from the previous delta."
(when (and pi-coding-agent--pending-tool-overlay
raw-text
(not (string-empty-p raw-text)))
;; Always sync fontify buffer (incremental, cheap) regardless of
;; whether the display will update — the buffer must stay current.
(when lang
(pi-coding-agent--fontify-sync raw-text lang))
(let* ((tail-result
Expand All @@ -1000,38 +1004,52 @@ full buffer on each delta."
(or has-hidden
(and truncation
(> (plist-get truncation :hidden-lines) 0))))
(inhibit-read-only t)
(inhibit-modification-hooks t))
;; Nothing to display yet (e.g., first delta before any newline)
(unless (string-empty-p display-content)
(pi-coding-agent--with-scroll-preservation
(save-excursion
(let* ((ov-end (overlay-end pi-coding-agent--pending-tool-overlay))
(header-end (overlay-get pi-coding-agent--pending-tool-overlay
'pi-coding-agent-header-end)))
;; Delete previous streaming content (everything after header)
(when (and header-end (< header-end ov-end))
(delete-region header-end ov-end))
;; Insert new streaming content
(goto-char (overlay-end pi-coding-agent--pending-tool-overlay))
(when show-hidden-indicator
(insert (propertize "... (earlier output)\n"
'face 'pi-coding-agent-collapsed-indicator)))
(let ((content-start (point)))
(insert (if lang
display-content
(pi-coding-agent--render-tool-content
display-content nil)))
(insert "\n")
;; Mark pre-fontified content so jit-lock won't override
;; our syntax faces with gfm-mode faces on redisplay.
;; Also layer markdown-code-face underneath so the text
;; uses fixed-pitch font, matching completed code blocks.
(when lang
(put-text-property content-start (point)
'fontified t)
(add-face-text-property content-start (point)
'markdown-code-face t))))))))))
;; Compare plain text to cached value — skip if unchanged.
;; Property-stripped comparison is correct: the text determines
;; whether the preview changed. Font-lock properties may shift
;; cosmetically but that's not worth a full redraw.
(display-text (substring-no-properties display-content))
(cache-key (if show-hidden-indicator
(concat "H:" display-text)
display-text))
(last-tail (overlay-get pi-coding-agent--pending-tool-overlay
'pi-coding-agent-last-tail)))
;; Skip display when the visible tail is unchanged
(unless (or (string-empty-p display-content)
(equal cache-key last-tail))
(let ((inhibit-read-only t)
(inhibit-modification-hooks t))
(pi-coding-agent--with-scroll-preservation
(save-excursion
(let* ((ov-end (overlay-end pi-coding-agent--pending-tool-overlay))
(header-end (overlay-get pi-coding-agent--pending-tool-overlay
'pi-coding-agent-header-end)))
;; Delete previous streaming content (everything after header)
(when (and header-end (< header-end ov-end))
(delete-region header-end ov-end))
;; Insert new streaming content
(goto-char (overlay-end pi-coding-agent--pending-tool-overlay))
(when show-hidden-indicator
(insert (propertize "... (earlier output)\n"
'face 'pi-coding-agent-collapsed-indicator)))
(let ((content-start (point)))
(insert (if lang
display-content
(pi-coding-agent--render-tool-content
display-content nil)))
(insert "\n")
;; Mark pre-fontified content so jit-lock won't override
;; our syntax faces with gfm-mode faces on redisplay.
;; Also layer markdown-code-face underneath so the text
;; uses fixed-pitch font, matching completed code blocks.
(when lang
(put-text-property content-start (point)
'fontified t)
(add-face-text-property content-start (point)
'markdown-code-face t))))))
;; Cache the displayed content for next comparison
(overlay-put pi-coding-agent--pending-tool-overlay
'pi-coding-agent-last-tail cache-key))))))

(defun pi-coding-agent--display-tool-update (partial-result)
"Display PARTIAL-RESULT as streaming output in pending tool overlay.
Expand All @@ -1057,9 +1075,28 @@ Returns the rendered string."
(pi-coding-agent--wrap-in-src-block content lang)
(propertize content 'face 'pi-coding-agent-tool-output)))

(defsubst pi-coding-agent--fontify-buffer-name (lang)
"Return the name of the fontification cache buffer for LANG."
(format " *pi-fontify:%s*" lang))
(defun pi-coding-agent--fontify-get-buffer (lang)
"Return the fontification cache buffer for LANG in the current session.
Looks up the buffer-local `pi-coding-agent--fontify-buffers' hash table.
Returns nil if no buffer exists for LANG. Removes stale entries
for buffers that have been killed externally."
(when pi-coding-agent--fontify-buffers
(let ((buf (gethash lang pi-coding-agent--fontify-buffers)))
(cond
((null buf) nil)
((buffer-live-p buf) buf)
(t (remhash lang pi-coding-agent--fontify-buffers) nil)))))

(defun pi-coding-agent--fontify-get-or-create-buffer (lang)
"Return or create the fontification cache buffer for LANG.
Uses the buffer-local hash table to track per-session buffers.
Returns nil if called outside a chat buffer (no hash table)."
(when pi-coding-agent--fontify-buffers
(or (pi-coding-agent--fontify-get-buffer lang)
(let ((buf (generate-new-buffer
(format " *pi-fontify:%s:%s*" lang (buffer-name)))))
(puthash lang buf pi-coding-agent--fontify-buffers)
buf))))

(defun pi-coding-agent--fontify-reset (args)
"Clear the fontification buffer for the language implied by ARGS.
Expand All @@ -1068,7 +1105,7 @@ buffer, preventing stale content from a previous call from being
treated as a matching prefix during incremental sync."
(when-let* ((lang (pi-coding-agent--path-to-language
(pi-coding-agent--tool-path args)))
(buf (get-buffer (pi-coding-agent--fontify-buffer-name lang))))
(buf (pi-coding-agent--fontify-get-buffer lang)))
(with-current-buffer buf
(erase-buffer))))

Expand All @@ -1081,9 +1118,8 @@ incorrect keyword matching on partial tokens.
The buffer always accumulates content regardless of whether the
language mode is available, so `pi-coding-agent--fontify-buffer-tail'
can extract the tail even for languages without an installed mode."
(condition-case nil
(let ((buf (get-buffer-create
(pi-coding-agent--fontify-buffer-name lang))))
(condition-case err
(when-let* ((buf (pi-coding-agent--fontify-get-or-create-buffer lang)))
(with-current-buffer buf
;; Activate language mode once (best-effort for fontification).
(let ((mode (and lang (markdown-get-lang-mode lang))))
Expand Down Expand Up @@ -1117,7 +1153,8 @@ can extract the tail even for languages without an installed mode."
(ignore-errors
(font-lock-default-fontify-region
(point-min) (point-max) nil)))))))
(error nil)))
(error
(message "pi-coding-agent: fontify-sync error for %s: %S" lang err))))

(defun pi-coding-agent--fontify-buffer-tail (lang n)
"Extract last N non-blank complete lines from the LANG fontification buffer.
Expand All @@ -1135,7 +1172,7 @@ Returns (CONTENT . HAS-HIDDEN) where CONTENT is a string with text
properties preserved. HAS-HIDDEN is non-nil when earlier lines
exist above the returned tail.
Returns nil if the buffer doesn't exist or has no complete lines."
(let ((buf (get-buffer (pi-coding-agent--fontify-buffer-name lang))))
(let ((buf (pi-coding-agent--fontify-get-buffer lang)))
(when (and buf (> (buffer-size buf) 0))
(with-current-buffer buf
;; Find end of last complete line (just before the trailing
Expand All @@ -1162,6 +1199,17 @@ Returns nil if the buffer doesn't exist or has no complete lines."
(cons (mapconcat #'identity line-strings "\n")
(> (point) (point-min))))))))))

(defun pi-coding-agent--kill-fontify-buffers ()
"Kill all fontification cache buffers for the current session.
Iterates the buffer-local `pi-coding-agent--fontify-buffers' hash table
and kills each buffer, then clears the table."
(when pi-coding-agent--fontify-buffers
(maphash (lambda (_lang buf)
(when (buffer-live-p buf)
(kill-buffer buf)))
pi-coding-agent--fontify-buffers)
(clrhash pi-coding-agent--fontify-buffers)))

(defun pi-coding-agent--display-tool-end (tool-name args content details is-error)
"Display result for TOOL-NAME and update overlay face.
ARGS contains tool arguments, CONTENT is a list of content blocks.
Expand Down
8 changes: 8 additions & 0 deletions pi-coding-agent-ui.el
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ This is a read-only buffer showing the conversation history."
(setq-local markdown-hide-markup t)
(add-to-invisibility-spec 'markdown-markup)
(setq-local pi-coding-agent--tool-args-cache (make-hash-table :test 'equal))
(setq-local pi-coding-agent--fontify-buffers (make-hash-table :test 'equal))
;; Disable hl-line-mode: its post-command-hook overlay update causes
;; scroll oscillation in buffers with invisible text + variable heights.
(setq-local global-hl-line-mode nil)
Expand Down Expand Up @@ -566,6 +567,13 @@ Enables dedup guard in tool_execution_start to skip overlay creation
when the overlay was already created by the streaming event path.
Set at toolcall_start, consumed and cleared at tool_execution_start.")

(defvar-local pi-coding-agent--fontify-buffers nil
"Hash table mapping language strings to fontification cache buffers.
Each chat buffer tracks its own fontify buffers so parallel sessions
writing the same language don't corrupt each other's syntax state.
Initialized in `pi-coding-agent-chat-mode'; cleaned up by
`pi-coding-agent--kill-fontify-buffers' when the session ends.")

(defvar-local pi-coding-agent--assistant-header-shown nil
"Non-nil if Assistant header has been shown for current prompt.
Used to avoid duplicate headers during retry sequences.")
Expand Down
7 changes: 4 additions & 3 deletions test/pi-coding-agent-core-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -290,15 +290,16 @@
(pi-coding-agent--update-state-from-event (list :type "message_start" :message msg))
(should (plist-get pi-coding-agent--state :current-message))))

(ert-deftest pi-coding-agent-test-event-message-update-accumulates-text ()
"message_update event updates current message with delta."
(ert-deftest pi-coding-agent-test-event-message-update-is-state-noop ()
"message_update event does not modify state.
Display is handled by the display handler, not by state updates."
(let ((pi-coding-agent--state (list :current-message '(:role "assistant" :content "Hello"))))
(pi-coding-agent--update-state-from-event
'(:type "message_update"
:message (:role "assistant")
:assistantMessageEvent (:type "text_delta" :delta " world")))
(should (equal (plist-get (plist-get pi-coding-agent--state :current-message) :content)
"Hello world"))))
"Hello"))))

(ert-deftest pi-coding-agent-test-event-message-end-clears-current-message ()
"message_end event clears current-message."
Expand Down
Loading