Skip to content

Commit 9b47621

Browse files
authored
Merge pull request #222 from leo-ar/perf/batch-history-render
Speed up long chat buffer rendering
2 parents 26a8450 + 0f6dff9 commit 9b47621

2 files changed

Lines changed: 128 additions & 11 deletions

File tree

pi-coding-agent-render.el

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,28 @@
5050

5151
;;;; Response Display
5252

53+
(defvar-local pi-coding-agent--defer-history-postprocessing nil
54+
"Non-nil while replaying history with batched display post-processing.
55+
When set, per-message fontification and table decoration are skipped;
56+
`pi-coding-agent--postprocess-history-buffer' runs one consolidated pass after
57+
all history has been inserted.")
58+
59+
(defvar-local pi-coding-agent--streaming-table-candidate nil
60+
"Non-nil when recent streaming text may contain a markdown pipe table.
61+
Streaming table decoration is comparatively expensive because it queries the
62+
current message with tree-sitter. Most assistant text is not table content, so
63+
we track whether a pipe has appeared since the last decoration attempt and skip
64+
the query when no table can be present.")
65+
66+
(defun pi-coding-agent--history-postprocessing-deferred-p ()
67+
"Return non-nil when history display post-processing is currently deferred."
68+
pi-coding-agent--defer-history-postprocessing)
69+
70+
(defun pi-coding-agent--decorate-tables-unless-deferred (start end)
71+
"Decorate markdown tables between START and END unless deferred."
72+
(unless (pi-coding-agent--history-postprocessing-deferred-p)
73+
(pi-coding-agent--decorate-tables-in-region start end)))
74+
5375
(defun pi-coding-agent--display-user-message (text &optional timestamp)
5476
"Display user message TEXT in the chat buffer.
5577
If TIMESTAMP (Emacs time value) is provided, display it in the header."
@@ -58,7 +80,7 @@ If TIMESTAMP (Emacs time value) is provided, display it in the header."
5880
(concat "\n" (pi-coding-agent--make-separator "You" timestamp) "\n"
5981
text "\n"))
6082
(with-current-buffer (pi-coding-agent--get-chat-buffer)
61-
(pi-coding-agent--decorate-tables-in-region start (point-max)))))
83+
(pi-coding-agent--decorate-tables-unless-deferred start (point-max)))))
6284

6385
(defun pi-coding-agent--display-agent-start ()
6486
"Display separator for new agent turn.
@@ -79,6 +101,7 @@ Note: status is set to `streaming' by the event handler."
79101
(setq pi-coding-agent--line-parse-state 'line-start)
80102
(setq pi-coding-agent--in-code-block nil)
81103
(setq pi-coding-agent--in-thinking-block nil)
104+
(setq pi-coding-agent--streaming-table-candidate nil)
82105
(pi-coding-agent--set-activity-phase "thinking"))
83106

84107
(defun pi-coding-agent--process-streaming-char (char state in-block)
@@ -178,8 +201,14 @@ fontification; tree-sitter re-parses at the C level on each insert."
178201
(goto-char (marker-position pi-coding-agent--streaming-marker))
179202
(insert transformed)
180203
(set-marker pi-coding-agent--streaming-marker (point))))
181-
;; After inserting text with completed lines, check for active table
182-
(when (string-match-p "\n" delta)
204+
;; After inserting text with completed lines, check for active tables only
205+
;; if recent streaming text contained a pipe. This avoids a tree-sitter
206+
;; table query on every non-table newline in long assistant messages.
207+
(when (string-match-p "|" delta)
208+
(setq pi-coding-agent--streaming-table-candidate t))
209+
(when (and pi-coding-agent--streaming-table-candidate
210+
(string-match-p "\n" delta))
211+
(setq pi-coding-agent--streaming-table-candidate nil)
183212
(pi-coding-agent--maybe-decorate-streaming-table)))))
184213

185214
(defun pi-coding-agent--thinking-insert-position ()
@@ -1065,7 +1094,7 @@ which asks upfront before any buffers are touched."
10651094
(not (string-empty-p content)))
10661095
(let ((start (point-max)))
10671096
(pi-coding-agent--append-to-chat (concat "\n" content "\n"))
1068-
(pi-coding-agent--decorate-tables-in-region start (point-max)))
1097+
(pi-coding-agent--decorate-tables-unless-deferred start (point-max)))
10691098
;; Reset so next assistant message shows its header
10701099
(setq pi-coding-agent--assistant-header-shown nil)))
10711100

@@ -1128,7 +1157,8 @@ Updates buffer-local state and renders display updates."
11281157
("text_end"
11291158
;; Text block ended — finalize any active table that may have
11301159
;; a trailing row without newline (backstop for streaming).
1131-
(pi-coding-agent--maybe-decorate-streaming-table))
1160+
(pi-coding-agent--maybe-decorate-streaming-table)
1161+
(setq pi-coding-agent--streaming-table-candidate nil))
11321162
("thinking_start"
11331163
(pi-coding-agent--display-thinking-start))
11341164
("thinking_delta"
@@ -2572,7 +2602,7 @@ TIMESTAMP is optional time when compaction occurred."
25722602
'face 'pi-coding-agent-tool-name)
25732603
(or summary "") "\n"))
25742604
(with-current-buffer (pi-coding-agent--get-chat-buffer)
2575-
(pi-coding-agent--decorate-tables-in-region start (point-max)))))
2605+
(pi-coding-agent--decorate-tables-unless-deferred start (point-max)))))
25762606

25772607
(defun pi-coding-agent--handle-compaction-success (tokens-before summary &optional timestamp)
25782608
"Handle successful compaction: display result and notify user.
@@ -2694,6 +2724,19 @@ Uses the current buffer's completed-thinking display mode."
26942724
(puthash (plist-get msg :toolCallId) msg index)))))
26952725
index))
26962726

2727+
(defun pi-coding-agent--postprocess-history-buffer ()
2728+
"Run consolidated display post-processing after history replay.
2729+
History replay inserts many small user/assistant chunks. Running fontification
2730+
and table decoration after each chunk is expensive in large sessions, so replay
2731+
defers those operations and performs one full-buffer pass here."
2732+
;; History replay should keep rendering even if markdown fontification trips
2733+
;; over a tree-sitter/runtime mismatch. Preserve debugger behavior when
2734+
;; `debug-on-error' is non-nil.
2735+
(condition-case-unless-debug nil
2736+
(font-lock-ensure (point-min) (point-max))
2737+
(error nil))
2738+
(pi-coding-agent--decorate-tables-in-region (point-min) (point-max)))
2739+
26972740
(defun pi-coding-agent--render-history-text (text)
26982741
"Render TEXT as markdown content with proper isolation.
26992742
Ensures markdown structures don't leak to subsequent content.
@@ -2705,10 +2748,11 @@ Display-only table decoration is applied after fontification."
27052748
;; History replay should keep rendering even if markdown
27062749
;; fontification trips over a tree-sitter/runtime mismatch.
27072750
;; Preserve debugger behavior when `debug-on-error' is non-nil.
2708-
(condition-case-unless-debug nil
2709-
(font-lock-ensure start (point-max))
2710-
(error nil))
2711-
(pi-coding-agent--decorate-tables-in-region start (point-max)))
2751+
(unless (pi-coding-agent--history-postprocessing-deferred-p)
2752+
(condition-case-unless-debug nil
2753+
(font-lock-ensure start (point-max))
2754+
(error nil))
2755+
(pi-coding-agent--decorate-tables-in-region start (point-max))))
27122756
;; Two trailing newlines reset any open markdown list/paragraph context
27132757
(pi-coding-agent--append-to-chat "\n\n"))))
27142758

@@ -3010,13 +3054,15 @@ Note: When called from async callbacks, pass CHAT-BUF explicitly."
30103054
(erase-buffer)
30113055
(insert (pi-coding-agent--format-startup-header) "\n")
30123056
(when (vectorp messages)
3013-
(pi-coding-agent--display-history-messages messages))
3057+
(let ((pi-coding-agent--defer-history-postprocessing t))
3058+
(pi-coding-agent--display-history-messages messages)))
30143059
(goto-char (point-max))
30153060
(unless (bolp) (insert "\n"))
30163061
(pi-coding-agent--set-message-start-marker nil)
30173062
(pi-coding-agent--set-streaming-marker nil)
30183063
(pi-coding-agent--update-hot-tail-boundary)
30193064
(pi-coding-agent--cool-completed-tool-blocks-outside-hot-tail)
3065+
(pi-coding-agent--postprocess-history-buffer)
30203066
(goto-char (point-max))))))
30213067

30223068
(provide 'pi-coding-agent-render)

test/pi-coding-agent-render-test.el

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,44 @@
5050
(pi-coding-agent--display-message-delta "world!")
5151
(should (string-match-p "Hello, world!" (buffer-string)))))
5252

53+
(ert-deftest pi-coding-agent-test-display-message-delta-skips-table-scan-without-pipe ()
54+
"Streaming text without pipes should not query for markdown tables."
55+
(with-temp-buffer
56+
(pi-coding-agent-chat-mode)
57+
(pi-coding-agent--display-agent-start)
58+
(let ((table-scan-count 0))
59+
(cl-letf (((symbol-function 'pi-coding-agent--maybe-decorate-streaming-table)
60+
(lambda () (setq table-scan-count (1+ table-scan-count)))))
61+
(pi-coding-agent--display-message-delta "ordinary prose\nmore prose\n"))
62+
(should (= table-scan-count 0)))))
63+
64+
(ert-deftest pi-coding-agent-test-display-message-delta-scans-table-after-pipe-newline ()
65+
"Streaming text with a pipe and newline should check for markdown tables."
66+
(with-temp-buffer
67+
(pi-coding-agent-chat-mode)
68+
(pi-coding-agent--display-agent-start)
69+
(let ((table-scan-count 0))
70+
(cl-letf (((symbol-function 'pi-coding-agent--maybe-decorate-streaming-table)
71+
(lambda () (setq table-scan-count (1+ table-scan-count)))))
72+
(pi-coding-agent--display-message-delta "| a | b |")
73+
(should (= table-scan-count 0))
74+
(pi-coding-agent--display-message-delta "\n|---|---|\n"))
75+
(should (= table-scan-count 1)))))
76+
77+
(ert-deftest pi-coding-agent-test-text-end-clears-streaming-table-candidate ()
78+
"The text_end table backstop clears any pending streaming table candidate."
79+
(with-temp-buffer
80+
(pi-coding-agent-chat-mode)
81+
(setq pi-coding-agent--streaming-table-candidate t)
82+
(let ((table-scan-count 0))
83+
(cl-letf (((symbol-function 'pi-coding-agent--maybe-decorate-streaming-table)
84+
(lambda () (setq table-scan-count (1+ table-scan-count)))))
85+
(pi-coding-agent--handle-display-event
86+
'(:type "message_update"
87+
:assistantMessageEvent (:type "text_end"))))
88+
(should (= table-scan-count 1))
89+
(should-not pi-coding-agent--streaming-table-candidate))))
90+
5391
(ert-deftest pi-coding-agent-test-delta-transforms-atx-headings ()
5492
"ATX headings in assistant content are leveled down.
5593
# becomes ##, ## becomes ###, etc. This keeps our setext H1 separators
@@ -612,6 +650,39 @@ agent_end + next section's leading newline must not create triple newlines."
612650
text))
613651
(should-not (string-match-p "> Need to double-check\\." text))))))
614652

653+
(ert-deftest pi-coding-agent-test-display-session-history-batches-postprocessing ()
654+
"Session history replay defers per-message display post-processing."
655+
(with-temp-buffer
656+
(pi-coding-agent-chat-mode)
657+
(let ((font-lock-count 0)
658+
(table-decoration-count 0))
659+
(cl-letf (((symbol-function 'font-lock-ensure)
660+
(lambda (&rest _) (setq font-lock-count (1+ font-lock-count))))
661+
((symbol-function 'pi-coding-agent--decorate-tables-in-region)
662+
(lambda (&rest _) (setq table-decoration-count
663+
(1+ table-decoration-count)))))
664+
(pi-coding-agent--display-session-history
665+
[(:role "user"
666+
:content [(:type "text" :text "Question?")]
667+
:timestamp 1704067200000)
668+
(:role "assistant"
669+
:content [(:type "text" :text "First answer.")]
670+
:timestamp 1704067201000)
671+
(:role "compactionSummary"
672+
:summary "Summary text"
673+
:tokensBefore 1234
674+
:timestamp 1704067201500)
675+
(:role "custom"
676+
:display t
677+
:content "Custom note\n\n| a | b |\n|---|---|\n| 1 | 2 |"
678+
:timestamp 1704067201750)
679+
(:role "assistant"
680+
:content [(:type "text" :text "Second answer.")]
681+
:timestamp 1704067202000)]
682+
(current-buffer)))
683+
(should (= font-lock-count 1))
684+
(should (= table-decoration-count 1)))))
685+
615686
(ert-deftest pi-coding-agent-test-display-session-history-renders-custom-messages ()
616687
"Session history replay should preserve visible custom messages."
617688
(with-temp-buffer

0 commit comments

Comments
 (0)