From 06b1af2e005fc43a9bfee5cea8430f5269af782b Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 11 Jul 2026 13:29:12 -0400 Subject: [PATCH 1/6] fix(tui): render thinking preview markdown --- .../ui/shell/visualize/_blocks.py | 33 +++++++++++++++- .../test_streaming_content_block.py | 39 ++++++++++++++++++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index 2f487125..b537872a 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -43,6 +43,7 @@ ) from pythinker_code.ui.shell.console import current_console_width from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ASSISTANT_MARKER, TRANSCRIPT_STATUS_MARKER +from pythinker_code.ui.shell.markdown.fences import iter_fence_aware_lines from pythinker_code.ui.shell.mcp_status import mcp_startup_header from pythinker_code.ui.shell.motion import ( ActivitySnapshot, @@ -561,6 +562,33 @@ def _tail_lines(text: str, n: int) -> str: return text[pos + 1 :] +_COMPLETE_HTML_COMMENT_BLOCK_RE = re.compile(r"(?ms)^[ \t]*[ \t]*(?=\r?$)") + + +def _render_thinking_preview(preview: str) -> RenderableType | None: + segments: list[str] = [] + unfenced: list[str] = [] + + def flush_unfenced() -> None: + if not unfenced: + return + segments.append(_COMPLETE_HTML_COMMENT_BLOCK_RE.sub("", "".join(unfenced))) + unfenced.clear() + + for line, inside_fence in iter_fence_aware_lines(preview): + if inside_fence: + flush_unfenced() + segments.append(line) + else: + unfenced.append(line) + flush_unfenced() + + cleaned = "".join(segments) + if not cleaned.strip(): + return None + return render_agent_body(cleaned) + + def _advance_by_display_cells(text: str, start: int, cell_budget: int) -> int: """Return a character offset advanced by roughly ``cell_budget`` terminal cells.""" from rich.cells import cell_len @@ -1123,12 +1151,15 @@ def _compose_thinking_stream(self) -> RenderableType: if not pending: return spinner preview = self._build_preview(pending, max_lines=_THINKING_PREVIEW_LINES) + rendered_preview = _render_thinking_preview(preview) + if rendered_preview is None: + return spinner preview_style = tui_rich_style("thinking_text") + Style(italic=True) return Group( spinner, BLANK_ROW, BulletColumns( - Text(preview, style=preview_style), + rendered_preview, bullet=Text(TRANSCRIPT_ASSISTANT_MARKER, style=preview_style), ), ) diff --git a/tests/ui_and_conv/test_streaming_content_block.py b/tests/ui_and_conv/test_streaming_content_block.py index 904d2e57..2c191274 100644 --- a/tests/ui_and_conv/test_streaming_content_block.py +++ b/tests/ui_and_conv/test_streaming_content_block.py @@ -386,7 +386,7 @@ def test_thinking_stream_preview_has_standard_gap_after_activity_line(): _assert_blank_line_after_activity(console.export_text(), "Thinking") -def test_thinking_stream_preview_uses_transcript_bullet_after_activity_line(): +def test_thinking_stream_preview_renders_complete_markdown(): block = _ContentBlock(is_think=True, show_thinking_stream=True) block.append("**Preparing report generation**") console = Console(record=True, width=120, color_system=None) @@ -394,7 +394,42 @@ def test_thinking_stream_preview_uses_transcript_bullet_after_activity_line(): output = console.export_text() assert "Thinking" in output - assert "\n\n⏺ **Preparing report generation**" in output + assert "\n\n⏺ Preparing report generation" in output + assert "**Preparing report generation**" not in output + + +def test_thinking_stream_preview_hides_complete_top_level_html_comments(): + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append("Visible before.\n\n\n\nVisible after.") + console = Console(record=True, width=120, color_system=None) + console.print(block.compose()) + output = console.export_text() + + assert "Visible before." in output + assert "Visible after." in output + assert "internal separator" not in output + assert "" not in output + + +def test_thinking_stream_preview_preserves_incomplete_markup(): + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append("**Planning agent\n\n\n```") + console = Console(record=True, width=120, color_system=None) + console.print(block.compose()) + + assert "" in console.export_text() def _style_for(renderable: Text, text: str) -> Style: From 54b63ec34ba3b40562ec14466d24d74281cec201 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 11 Jul 2026 13:51:17 -0400 Subject: [PATCH 2/6] fix(tui): bound thinking-preview comment regex --- src/pythinker_code/ui/shell/visualize/_blocks.py | 3 ++- tests/ui_and_conv/test_streaming_content_block.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index b537872a..bdef9be8 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -562,10 +562,11 @@ def _tail_lines(text: str, n: int) -> str: return text[pos + 1 :] -_COMPLETE_HTML_COMMENT_BLOCK_RE = re.compile(r"(?ms)^[ \t]*[ \t]*(?=\r?$)") +_COMPLETE_HTML_COMMENT_BLOCK_RE = re.compile(r"(?ms)^[ \t]*).)*?-->[ \t]*(?=\r?$)") def _render_thinking_preview(preview: str) -> RenderableType | None: + """Bounded thinking preview as Markdown, top-level HTML comments stripped; None if empty.""" segments: list[str] = [] unfenced: list[str] = [] diff --git a/tests/ui_and_conv/test_streaming_content_block.py b/tests/ui_and_conv/test_streaming_content_block.py index 2c191274..2872d241 100644 --- a/tests/ui_and_conv/test_streaming_content_block.py +++ b/tests/ui_and_conv/test_streaming_content_block.py @@ -412,6 +412,17 @@ def test_thinking_stream_preview_hides_complete_top_level_html_comments(): assert "-->" not in output +def test_thinking_stream_preview_keeps_inline_comment_line_intact(): + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append(" visible middle ") + console = Console(record=True, width=120, color_system=None) + console.print(block.compose()) + output = console.export_text() + + assert "visible middle" in output + assert ""): + console = Console(record=True, width=80, color_system=None) + console.print(render_agent_body(sample)) + print(repr(console.export_text())) +PY +``` + +Expected baseline: emphasis delimiters disappear, while `` remains literal. If the +installed dependency differs, stop and revise the boundary design before coding. + +- [ ] **Step 2: Add failing thinking-preview tests** + +Replace the existing literal-marker assertion and add these tests beside it: + +```python +def test_thinking_stream_preview_renders_complete_markdown(): + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append("**Preparing report generation**") + console = Console(record=True, width=120, color_system=None) + console.print(block.compose()) + output = console.export_text() + + assert "Thinking" in output + assert "\n\n⏺ Preparing report generation" in output + assert "**Preparing report generation**" not in output + + +def test_thinking_stream_preview_hides_complete_top_level_html_comments(): + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append("Visible before.\n\n\n\nVisible after.") + console = Console(record=True, width=120, color_system=None) + console.print(block.compose()) + output = console.export_text() + + assert "Visible before." in output + assert "Visible after." in output + assert "internal separator" not in output + assert "" not in output + + +def test_thinking_stream_preview_preserves_incomplete_markup(): + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append("**Planning agent\n\n\n```") + console = Console(record=True, width=120, color_system=None) + console.print(block.compose()) + + assert "" in console.export_text() +``` + +- [ ] **Step 3: Run the regression seam and observe RED** + +```bash +uv run pytest \ + tests/ui_and_conv/test_streaming_content_block.py::test_thinking_stream_preview_renders_complete_markdown \ + tests/ui_and_conv/test_streaming_content_block.py::test_thinking_stream_preview_hides_complete_top_level_html_comments \ + tests/ui_and_conv/test_streaming_content_block.py::test_thinking_stream_preview_preserves_incomplete_markup \ + tests/ui_and_conv/test_streaming_content_block.py::test_thinking_stream_preview_preserves_comment_example_in_fenced_code \ + -q +``` + +Expected: the first two fail because the live path prints `**` and ``; the malformed +and fenced characterization tests pass. If a target regression passes before code changes, correct +the seam rather than accepting an unproven test. + +- [ ] **Step 4: Add the minimal fence-aware preview boundary** + +Import `iter_fence_aware_lines` and add this private helper near the preview helpers: + +```python +from pythinker_code.ui.shell.markdown.fences import iter_fence_aware_lines + +_COMPLETE_HTML_COMMENT_BLOCK_RE = re.compile( + r"(?ms)^[ \t]*[ \t]*(?=\r?$)" +) + + +def _render_thinking_preview(preview: str) -> RenderableType | None: + segments: list[str] = [] + unfenced: list[str] = [] + + def flush_unfenced() -> None: + if not unfenced: + return + segments.append(_COMPLETE_HTML_COMMENT_BLOCK_RE.sub("", "".join(unfenced))) + unfenced.clear() + + for line, inside_fence in iter_fence_aware_lines(preview): + if inside_fence: + flush_unfenced() + segments.append(line) + else: + unfenced.append(line) + flush_unfenced() + + cleaned = "".join(segments) + if not cleaned.strip(): + return None + return render_agent_body(cleaned) +``` + +Replace the plain `Text(preview, style=preview_style)` body in `_compose_thinking_stream` with: + +```python + rendered_preview = _render_thinking_preview(preview) + if rendered_preview is None: + return spinner + preview_style = tui_rich_style("thinking_text") + Style(italic=True) + return Group( + spinner, + BLANK_ROW, + BulletColumns( + rendered_preview, + bullet=Text(TRANSCRIPT_ASSISTANT_MARKER, style=preview_style), + ), + ) +``` + +Do not alter `_compose_composing`, `compose_final`, `_THINKING_PREVIEW_LINES`, or stored content. + +- [ ] **Step 5: Run the focused module and observe GREEN** + +```bash +uv run pytest tests/ui_and_conv/test_streaming_content_block.py -q +``` + +Expected: all tests pass. Composing-preview tests must still expect literal streaming markers; only +the opt-in thinking-stream preview changes. + +- [ ] **Step 6: Commit Task 1** + +After `pythinker-guard` confirms surgical scope: + +```bash +git add src/pythinker_code/ui/shell/visualize/_blocks.py \ + tests/ui_and_conv/test_streaming_content_block.py +git commit -m "fix(tui): render thinking preview markdown" +``` + +--- + +### Task 2: Reserve coral shimmer for the verb spinner + +**Files:** +- Modify: `tests/ui_and_conv/test_activity_tree.py:1-75` +- Modify: `src/pythinker_code/ui/shell/visualize/_activity_tree.py:1-65` +- Verify unchanged: `src/pythinker_code/ui/shell/visualize/_live_view.py:968-1080` +- Verify unchanged: `src/pythinker_code/ui/shell/motion.py:250-420` + +**Interfaces:** +- Consumes: `shell_style(ShellTone.MUTED) -> Style`, `_row_marker(state, now) -> Text`, and existing + bottom working-indicator shimmer paths. +- Produces: unchanged `render_activity_tree(...) -> RenderableType`; only detail styling changes. + +- [ ] **Step 1: Add a failing static-detail regression** + +Add the required Rich, design-system, and theme imports, then add: + +```python +def _detail_style(renderable: RenderableType, detail: str) -> Style: + assert isinstance(renderable, Group) + row = renderable.renderables[0] + assert isinstance(row, Text) + console = Console(color_system="truecolor") + return row.get_style_at_offset(console, row.plain.index(detail)) + + +def test_running_activity_detail_is_static_muted_text(monkeypatch): + for flag in ( + "NO_COLOR", + "PYTHINKER_REDUCED_MOTION", + "PYTHINKER_NO_ANIMATION", + "PYTHINKER_STATIC_OUTPUT", + ): + monkeypatch.delenv(flag, raising=False) + monkeypatch.setenv("TERM", "xterm-256color") + monkeypatch.setenv("COLORTERM", "truecolor") + rows = [ActivityRow(label="agent", detail="Shell uv run pytest", state="running")] + + first = _detail_style(render_activity_tree(rows, width=80, now=0.88), "Shell") + later = _detail_style(render_activity_tree(rows, width=80, now=1.18), "Shell") + + assert first.color == shell_style(ShellTone.MUTED).color + assert later.color == shell_style(ShellTone.MUTED).color + assert first.color != tui_rich_style("activity_verb").color + assert later.color != tui_rich_style("activity_verb").color +``` + +- [ ] **Step 2: Run it and observe RED** + +```bash +uv run pytest \ + tests/ui_and_conv/test_activity_tree.py::test_running_activity_detail_is_static_muted_text \ + -q +``` + +Expected: FAIL because the running detail is composed from shimmer spans. If `NO_COLOR` remains in +the fixture, fix the fixture before interpreting the result. + +- [ ] **Step 3: Remove shimmer from tree details only** + +Remove `shimmer_text` from `_activity_tree.py` imports and replace the conditional detail block with: + +```python + detail = truncate_to_width(row.detail, available) + text.append(detail, style=shell_style(ShellTone.MUTED)) +``` + +Keep `_row_marker`, marker pulse, branches, truncation, hidden-row accounting, and states unchanged. + +- [ ] **Step 4: Run tree and verb-spinner tests and observe GREEN** + +```bash +uv run pytest \ + tests/ui_and_conv/test_activity_tree.py \ + tests/ui_and_conv/test_live_view_todos.py::test_todo_activity_line_uses_standard_spinner_shimmer_for_generic_verbs \ + tests/ui_and_conv/test_shell_motion.py::test_activity_status_line_uses_platinum_spinner_and_champagne_verb \ + tests/ui_and_conv/test_shell_motion_shimmer.py \ + -q +``` + +Expected: all pass. Do not change `_working_indicator`, `activity_status_line`, `shimmer_text`, or +palette tokens to make this gate pass. + +- [ ] **Step 5: Commit Task 2** + +```bash +git add src/pythinker_code/ui/shell/visualize/_activity_tree.py \ + tests/ui_and_conv/test_activity_tree.py +git commit -m "fix(tui): keep activity rows visually stable" +``` + +--- + +### Task 3: Document the correction and run focused quality reviews + +**Files:** +- Modify: `CHANGELOG.md:15-40` +- Modify: `tasks/todo.md` TUI task section +- Review: all Task 1 and Task 2 files + +**Interfaces:** +- Consumes: completed Task 1 and Task 2 behavior. +- Produces: one Unreleased bullet and current task tracking; no runtime API. + +- [ ] **Step 1: Add the required Unreleased entry** + +Insert at the top of `## Unreleased`: + +```markdown +- **Thinking and subagent activity now render cleanly in the terminal.** Live reasoning previews + render complete Markdown without exposing top-level HTML comments, activity-tree rows remain + visually stable, and the coral shimmer is reserved for the active verb spinner. +``` + +- [ ] **Step 2: Update task tracking truthfully** + +Check only implementation items proven by focused tests. Leave full-gate and final-review items +unchecked until Task 4 produces terminal evidence. + +- [ ] **Step 3: Run reactive quality skills** + +Use `clean-code-guard` on production, `test-guard` on tests, `docs-guard` on changed prose, and +`pythinker-guard` on the complete diff. Fix only findings tracing to the approved behavior; record +unrelated findings under Out of scope in `tasks/todo.md`. + +- [ ] **Step 4: Run the complete focused TUI set** + +```bash +uv run pytest \ + tests/ui_and_conv/test_streaming_content_block.py \ + tests/ui_and_conv/test_activity_tree.py \ + tests/ui_and_conv/test_live_view_todos.py \ + tests/ui_and_conv/test_shell_motion.py \ + tests/ui_and_conv/test_shell_motion_shimmer.py \ + tests/ui_and_conv/test_terminal_capabilities.py \ + -q +git diff --check +``` + +Expected: all selected tests pass and `git diff --check` emits no output. + +- [ ] **Step 5: Commit Task 3** + +```bash +git add CHANGELOG.md tasks/todo.md tasks/lessons.md \ + docs/superpowers/specs/2026-07-11-tui-thinking-markdown-and-activity-motion-design.md \ + docs/superpowers/plans/2026-07-11-tui-thinking-markdown-and-activity-motion.md +git commit -m "docs(tui): record clean activity rendering" +``` + +If `docs/superpowers/` is ignored, use `git add -f` only for the two explicitly named reviewed +files, never the directory broadly. + +--- + +### Task 4: Run release-grade verification and record the outcome + +**Files:** +- Modify: `tasks/todo.md` Review subsection only after all commands finish +- Review: complete branch diff from implementation base `a20bfda0` + +**Interfaces:** +- Consumes: Tasks 1-3 commits. +- Produces: verified completion evidence; no runtime API. + +- [ ] **Step 1: Run the full static gate** + +```bash +make check-pythinker-code +``` + +Expected: Ruff says `All checks passed!`, formatting reports files already formatted, Pyright says +`0 errors`, ty passes, and the command exits 0. A green Ruff line alone is insufficient. + +- [ ] **Step 2: Run package and E2E tests** + +```bash +make test-pythinker-code +``` + +Expected: package and separate `tests_e2e` summaries both pass. Any real failure blocks completion +and must be diagnosed; dependency warnings, known skips, and expected failures are not hidden. + +- [ ] **Step 3: Verify exact branch scope** + +```bash +git log a20bfda0..HEAD --oneline +git diff a20bfda0...HEAD --stat +git diff a20bfda0...HEAD -- \ + src/pythinker_code/ui/shell/visualize/_blocks.py \ + src/pythinker_code/ui/shell/visualize/_activity_tree.py \ + tests/ui_and_conv/test_streaming_content_block.py \ + tests/ui_and_conv/test_activity_tree.py \ + CHANGELOG.md tasks/todo.md tasks/lessons.md +git status --short +``` + +Expected: only planned files and commits appear, with no unexplained working-tree change. + +- [ ] **Step 4: Run final verification and diff review** + +Use `superpowers:verification-before-completion` with fresh outputs. Review C01-C15, especially +silent exceptions, alternate render paths, unbounded parsing, and style-blind tests. Confirm auth, +approval, persistence, providers, tool execution, and scheduling are untouched. + +Expected: PASS with no Critical or Important finding. Any unresolved finding keeps the task active. + +- [ ] **Step 5: Add the final task Review subsection** + +Add `#### Review: TUI thinking Markdown and activity motion` under the active task. Record: + +- The exact resulting behavior for complete Markdown, complete top-level comments, malformed input, + fenced literal examples, stable tree rows, and verb-spinner shimmer. +- The two confirmed root causes: plain `Text` in the thinking preview and `shimmer_text` in running + activity-tree details. +- The literal focused pytest count, full package count, separate E2E count, static-gate verdict, and + `git diff --check` verdict copied from fresh terminal output. +- The verdict from each named quality review, every approved deviation, and any remaining blocker. + +Do not write a count or PASS claim from memory. Missing evidence leaves the corresponding checkbox +open and prevents completion. + +- [ ] **Step 6: Commit the verified review record** + +```bash +git add tasks/todo.md +git commit -m "docs(tasks): record TUI rendering verification" +``` + +Run `pythinker-guard` again first and confirm `git status --short` contains only the review update. diff --git a/docs/superpowers/specs/2026-07-11-tui-thinking-markdown-and-activity-motion-design.md b/docs/superpowers/specs/2026-07-11-tui-thinking-markdown-and-activity-motion-design.md new file mode 100644 index 00000000..e1754a58 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-tui-thinking-markdown-and-activity-motion-design.md @@ -0,0 +1,91 @@ +# TUI thinking Markdown and activity motion design + +**Status:** Approved by the user on 2026-07-11 + +## Problem + +When `show_thinking_stream` is enabled, the live thinking preview displays model Markdown as +plain italic text. Formatting markers such as `**...**` and HTML comments such as `` +therefore leak into the visible TUI instead of rendering cleanly. + +The subagent activity tree also applies the coral shimmer to individual running tool rows. This +makes one row appear specially selected or more active than its siblings. The intended motion +language has one source of truth: only the bottom verb-spinner/status line shimmers while work is +active. + +## Goals + +- Render visible thinking-stream Markdown through the existing trusted TUI Markdown boundary. +- Hide Markdown HTML comments instead of exposing their source syntax. +- Keep activity-tree rows stable and readable in every lifecycle state. +- Apply the coral shimmer only to the single bottom verb-spinner/status line. +- Preserve reduced-motion, no-animation, no-color, truncation, and streaming behavior. + +## Non-goals + +- Changing whether reasoning is emitted, persisted, or enabled by default. +- Hiding the thinking stream or changing its six-line preview limit. +- Animating every running, queued, completed, or failed activity row. +- Changing subagent scheduling, execution state, concurrency, or event semantics. +- Reworking the general Markdown renderer or the composing-response preview. + +## Design + +### Thinking preview + +`_ContentBlock._compose_thinking_stream` will continue to build the bounded live preview from the +existing streaming normalization and wrapping path. At the display boundary, it will pass that +preview to the same agent-body Markdown renderer used for committed assistant content instead of +constructing a plain `Text` object. + +The preview remains nested under the existing thinking bullet and spinner. Rendering is limited to +the already bounded preview, so the change does not parse the complete accumulated reasoning on +every frame. The shared Markdown renderer remains responsible for ANSI sanitization and Markdown +semantics. Rich renders HTML blocks literally, so the thinking-preview boundary removes complete +top-level HTML comment blocks before constructing the Markdown renderable. It preserves comment +syntax inside fenced code and preserves malformed or incomplete comments as readable streaming text. + +Incomplete streaming Markdown must fail safely as readable text; it must not raise out of the Live +render loop. The final committed reasoning path remains unchanged because it already renders through +the agent-body boundary. + +### Activity motion + +`render_activity_tree` will render every row detail with the stable muted activity style, regardless +of whether the row is waiting, running, completed, failed, denied, or interrupted. Existing state +markers and their running pulse remain unchanged because they convey lifecycle state without moving +the verb text. + +The bottom working indicator remains the only verb shimmer. Its current `activity_status_line` / +`_todo_activity_line` paths continue to use the coral shimmer palette, terminal capability checks, +and reduced-motion fallback. No second animation implementation is introduced. + +## Failure behavior + +- Empty thinking previews continue to show only the thinking spinner. +- Malformed or incomplete Markdown remains visible and cannot crash the TUI. +- ANSI control sequences remain sanitized at the established render boundary. +- Reduced-motion and static-output modes keep the verb label stable. +- Completed, failed, denied, and interrupted activity rows never gain motion. + +## Test design + +Tests will be written before production changes and observed failing for the expected reason. + +- A thinking-stream preview containing `**bold**` renders the text without literal `**` markers. +- A thinking-stream preview containing `` does not expose the comment or delimiters. +- Malformed/incomplete Markdown in the preview remains renderable without an exception. +- Running activity-tree detail uses one stable muted style at different timestamps and does not use + shimmer palette spans. +- The bottom verb-spinner retains coral shimmer spans while motion is enabled. +- Reduced-motion behavior remains static and existing lifecycle markers remain correct. + +Focused verification will run the thinking-stream, activity-tree, and shell-motion test modules, +followed by `make check-pythinker-code` and `make test-pythinker-code` before completion. + +## Scope and rollback + +The implementation should touch only the thinking preview render boundary, the activity-tree detail +style, their focused tests, task documentation, and the required Unreleased changelog entry for +shipped-code changes. Rollback is a direct revert of those small rendering changes; no configuration +or persisted-data migration is involved. From 4baefb7dfd2abb0fc8ea0fb07140827e49efc9d2 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 11 Jul 2026 17:00:48 -0400 Subject: [PATCH 5/6] 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. --- .../ui/shell/visualize/_blocks.py | 25 ++++++++++- .../test_streaming_content_block.py | 43 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index bdef9be8..18cef4cb 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -651,6 +651,10 @@ def __init__(self, is_think: bool, *, show_thinking_stream: bool = False, paced: self._scrollback_renderable: RenderableType | None = None self._preview_text_cache_key: tuple[int, int, int, bool, str] | None = None self._preview_text_cache: str | None = None + # Rendered thinking-preview cache (legacy ``show_thinking_stream`` path): + # avoids re-running the markdown parse on every Live tick when unchanged. + self._thinking_render_cache_key: str | None = None + self._thinking_render_cache: RenderableType | None = None # Interactive prompt preamble row budget (``None`` = no limit; Rich Live). self._preview_row_budget: int | None = None self._last_commit_scan_len = 0 @@ -905,6 +909,8 @@ def _pending_text_for_final(self) -> str: def _invalidate_preview_cache(self) -> None: self._preview_text_cache_key = None self._preview_text_cache = None + self._thinking_render_cache_key = None + self._thinking_render_cache = None def _wrap_bullet(self, renderable: RenderableType) -> BulletColumns: """First call gets the ``•`` bullet; subsequent calls get a space.""" @@ -1151,8 +1157,8 @@ def _compose_thinking_stream(self) -> RenderableType: pending = self._pending_text() if not pending: return spinner - preview = self._build_preview(pending, max_lines=_THINKING_PREVIEW_LINES) - rendered_preview = _render_thinking_preview(preview) + preview = self._build_preview_cached(pending, max_lines=_THINKING_PREVIEW_LINES) + rendered_preview = self._render_thinking_preview_cached(preview) if rendered_preview is None: return spinner preview_style = tui_rich_style("thinking_text") + Style(italic=True) @@ -1171,6 +1177,21 @@ def _compose_thinking_spinner(self) -> Text: width=self._layout_width(), ) + def _render_thinking_preview_cached(self, preview: str) -> RenderableType | None: + """Render the thinking preview markdown, caching on the preview string. + + Mirrors :meth:`_build_preview_cached` so the markdown parse only runs when + the preview content changes, not on every Live refresh tick driven by the + spinner animation. The cache is cleared by ``_invalidate_preview_cache`` + on new content, width, or preview-budget changes. + """ + if preview == self._thinking_render_cache_key: + return self._thinking_render_cache + rendered = _render_thinking_preview(preview) + self._thinking_render_cache_key = preview + self._thinking_render_cache = rendered + return rendered + def _layout_width(self) -> int: width = current_console_width() if width != self._block_width: diff --git a/tests/ui_and_conv/test_streaming_content_block.py b/tests/ui_and_conv/test_streaming_content_block.py index 2872d241..4c55c2f0 100644 --- a/tests/ui_and_conv/test_streaming_content_block.py +++ b/tests/ui_and_conv/test_streaming_content_block.py @@ -669,6 +669,49 @@ def test_stream_mode_status_includes_token_count(self): assert "Thinking" in plain assert "tokens" in plain + def test_stream_mode_reuses_rendered_preview_across_ticks(self, monkeypatch): + """The markdown render runs once per preview change, not per Live tick. + + The Live area refreshes on the spinner's own animation cadence with no new + content; ``_render_thinking_preview`` (regex + markdown parse) must be + served from cache on those ticks and only recomputed when pending changes. + """ + from pythinker_code.ui.shell.visualize import _blocks + + calls: list[str] = [] + original = _blocks._render_thinking_preview + + def counting(preview: str): + calls.append(preview) + return original(preview) + + monkeypatch.setattr(_blocks, "_render_thinking_preview", counting) + + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append("**first reasoning line**") + block.compose() + block.compose() # unchanged pending -> served from cache + assert len(calls) == 1 + + block.append("\n**second reasoning line**") + block.compose() # pending changed -> recompute + assert len(calls) == 2 + + def test_stream_mode_cached_preview_matches_uncached_render(self): + """Caching is behavior-preserving: composed output is byte-identical to a + fresh (uncached) render of the same reasoning content across ticks.""" + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append("**Preparing report generation**") + + first = Console(record=True, width=120, color_system=None) + first.print(block.compose()) + second = Console(record=True, width=120, color_system=None) + second.print(block.compose()) # cache hit + first_text = first.export_text() + assert first_text == second.export_text() + assert "Preparing report generation" in first_text + assert block._thinking_render_cache_key is not None + def test_compact_mode_compose_final_returns_trace_line(self): from rich.text import Text From 2ee6e986cfc836fdd5ca5b060d86589264d5dd10 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 11 Jul 2026 17:07:58 -0400 Subject: [PATCH 6/6] test(tui): freeze clock in thinking-preview cache equivalence test The output-equivalence test compared full Console.export_text() across two compose() calls, whose status line embeds elapsed time and token rate. Freeze time.monotonic so the two renders are deterministic and only the cached preview governs the comparison; guards against a rare timing-boundary flake. --- tests/ui_and_conv/test_streaming_content_block.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/ui_and_conv/test_streaming_content_block.py b/tests/ui_and_conv/test_streaming_content_block.py index 4c55c2f0..85e9e1df 100644 --- a/tests/ui_and_conv/test_streaming_content_block.py +++ b/tests/ui_and_conv/test_streaming_content_block.py @@ -697,9 +697,16 @@ def counting(preview: str): block.compose() # pending changed -> recompute assert len(calls) == 2 - def test_stream_mode_cached_preview_matches_uncached_render(self): + def test_stream_mode_cached_preview_matches_uncached_render(self, monkeypatch): """Caching is behavior-preserving: composed output is byte-identical to a fresh (uncached) render of the same reasoning content across ticks.""" + from pythinker_code.ui.shell.visualize import _blocks + + # Freeze the clock so the elapsed/token-rate status line is identical + # across both compose() calls — the comparison targets the cached preview, + # not wall-clock timing. + monkeypatch.setattr(_blocks.time, "monotonic", lambda: 100.0) + block = _ContentBlock(is_think=True, show_thinking_stream=True) block.append("**Preparing report generation**")