Skip to content

Commit 4baefb7

Browse files
committed
perf(tui): cache rendered thinking-preview markdown across live ticks
The markdown render + regex parse for the thinking preview can run many times per second on Live refresh ticks when no new content has arrived. Introduce a cache key (the preview string) so markdown parsing only runs when the pending thinking text actually changes, not on every spinner animation. Includes tests verifying cache behavior and output equivalence.
1 parent 81522bc commit 4baefb7

2 files changed

Lines changed: 66 additions & 2 deletions

File tree

src/pythinker_code/ui/shell/visualize/_blocks.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,10 @@ def __init__(self, is_think: bool, *, show_thinking_stream: bool = False, paced:
651651
self._scrollback_renderable: RenderableType | None = None
652652
self._preview_text_cache_key: tuple[int, int, int, bool, str] | None = None
653653
self._preview_text_cache: str | None = None
654+
# Rendered thinking-preview cache (legacy ``show_thinking_stream`` path):
655+
# avoids re-running the markdown parse on every Live tick when unchanged.
656+
self._thinking_render_cache_key: str | None = None
657+
self._thinking_render_cache: RenderableType | None = None
654658
# Interactive prompt preamble row budget (``None`` = no limit; Rich Live).
655659
self._preview_row_budget: int | None = None
656660
self._last_commit_scan_len = 0
@@ -905,6 +909,8 @@ def _pending_text_for_final(self) -> str:
905909
def _invalidate_preview_cache(self) -> None:
906910
self._preview_text_cache_key = None
907911
self._preview_text_cache = None
912+
self._thinking_render_cache_key = None
913+
self._thinking_render_cache = None
908914

909915
def _wrap_bullet(self, renderable: RenderableType) -> BulletColumns:
910916
"""First call gets the ``•`` bullet; subsequent calls get a space."""
@@ -1151,8 +1157,8 @@ def _compose_thinking_stream(self) -> RenderableType:
11511157
pending = self._pending_text()
11521158
if not pending:
11531159
return spinner
1154-
preview = self._build_preview(pending, max_lines=_THINKING_PREVIEW_LINES)
1155-
rendered_preview = _render_thinking_preview(preview)
1160+
preview = self._build_preview_cached(pending, max_lines=_THINKING_PREVIEW_LINES)
1161+
rendered_preview = self._render_thinking_preview_cached(preview)
11561162
if rendered_preview is None:
11571163
return spinner
11581164
preview_style = tui_rich_style("thinking_text") + Style(italic=True)
@@ -1171,6 +1177,21 @@ def _compose_thinking_spinner(self) -> Text:
11711177
width=self._layout_width(),
11721178
)
11731179

1180+
def _render_thinking_preview_cached(self, preview: str) -> RenderableType | None:
1181+
"""Render the thinking preview markdown, caching on the preview string.
1182+
1183+
Mirrors :meth:`_build_preview_cached` so the markdown parse only runs when
1184+
the preview content changes, not on every Live refresh tick driven by the
1185+
spinner animation. The cache is cleared by ``_invalidate_preview_cache``
1186+
on new content, width, or preview-budget changes.
1187+
"""
1188+
if preview == self._thinking_render_cache_key:
1189+
return self._thinking_render_cache
1190+
rendered = _render_thinking_preview(preview)
1191+
self._thinking_render_cache_key = preview
1192+
self._thinking_render_cache = rendered
1193+
return rendered
1194+
11741195
def _layout_width(self) -> int:
11751196
width = current_console_width()
11761197
if width != self._block_width:

tests/ui_and_conv/test_streaming_content_block.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,49 @@ def test_stream_mode_status_includes_token_count(self):
669669
assert "Thinking" in plain
670670
assert "tokens" in plain
671671

672+
def test_stream_mode_reuses_rendered_preview_across_ticks(self, monkeypatch):
673+
"""The markdown render runs once per preview change, not per Live tick.
674+
675+
The Live area refreshes on the spinner's own animation cadence with no new
676+
content; ``_render_thinking_preview`` (regex + markdown parse) must be
677+
served from cache on those ticks and only recomputed when pending changes.
678+
"""
679+
from pythinker_code.ui.shell.visualize import _blocks
680+
681+
calls: list[str] = []
682+
original = _blocks._render_thinking_preview
683+
684+
def counting(preview: str):
685+
calls.append(preview)
686+
return original(preview)
687+
688+
monkeypatch.setattr(_blocks, "_render_thinking_preview", counting)
689+
690+
block = _ContentBlock(is_think=True, show_thinking_stream=True)
691+
block.append("**first reasoning line**")
692+
block.compose()
693+
block.compose() # unchanged pending -> served from cache
694+
assert len(calls) == 1
695+
696+
block.append("\n**second reasoning line**")
697+
block.compose() # pending changed -> recompute
698+
assert len(calls) == 2
699+
700+
def test_stream_mode_cached_preview_matches_uncached_render(self):
701+
"""Caching is behavior-preserving: composed output is byte-identical to a
702+
fresh (uncached) render of the same reasoning content across ticks."""
703+
block = _ContentBlock(is_think=True, show_thinking_stream=True)
704+
block.append("**Preparing report generation**")
705+
706+
first = Console(record=True, width=120, color_system=None)
707+
first.print(block.compose())
708+
second = Console(record=True, width=120, color_system=None)
709+
second.print(block.compose()) # cache hit
710+
first_text = first.export_text()
711+
assert first_text == second.export_text()
712+
assert "Preparing report generation" in first_text
713+
assert block._thinking_render_cache_key is not None
714+
672715
def test_compact_mode_compose_final_returns_trace_line(self):
673716
from rich.text import Text
674717

0 commit comments

Comments
 (0)