diff --git a/AGENTS.md b/AGENTS.md index 1929beff..4d4cdcb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,11 @@ contract and C01–C15 tripwires below; ship the matching tests and verification `uv run ...` or `uv run --directory ...`. - **Keep changes surgical.** Do not perform drive-by refactors, formatting churn, dependency upgrades, or generated-file rewrites unless the task requires them. +- **Never propose deferring an applicable issue or fix.** If a problem is real and a fix applies, + do the fix now — even when it requires a redesign or significantly more work. "Defer", + "follow-up later", "out of scope for now", and equivalents are prohibited recommendations; the + only permitted alternative to fixing immediately is stopping to request expanded scope, then + fixing. - **Do not expose secrets or PII.** Never print, commit, or copy API keys, OAuth tokens, session data, user config, or logs that may contain credentials. - **Do not add new telemetry, hosted endpoints, external services, or third-party dependencies** diff --git a/CHANGELOG.md b/CHANGELOG.md index 28623070..ff105080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- Freeze the public shell prompt compatibility contract with constructor and rendering coverage. - Add xAI Grok OAuth login (browser loopback and device-code). - Add GitHub Copilot device-code OAuth login for individual github.com accounts. - Add DigitalOcean Gradient AI browser OAuth login with dynamically discovered Inference Routers; router-discovery failures (unauthorized, outage, malformed, empty) are now reported distinctly instead of silently yielding no models. @@ -24,6 +25,7 @@ GitHub Releases page; `0.8.0` is the new starting line. - Fix queued follow-up input showing a bordered ghost; pressing Enter during an active turn now shows one intentional queued row. - Harden provider OAuth credential handling: scope token refresh to the active/selected provider, serialize persistence under a lock with atomic config replacement, roll back replaced credentials when a save fails, fail closed on credential migration, and validate OAuth token and implicit-state responses. - Fix an intermittent doubled/"ghost" copy of the running-prompt block (agent tree, spinner, and tip) on long streaming turns: after a scrollback handoff the suppressed live body now waits for the terminal's re-requested absolute cursor position to settle before it re-expands, so it repaints against a correct cursor model instead of the mis-anchored frame left behind by `run_in_terminal`. +- Fix the "update available" footer notice being suppressed on shell-mode frames (and a stale agent-mode notice replaying after a mode switch): the shell prompt render now refreshes the per-frame update-notice snapshot like the agent path, and the snapshot is cleared with the frame. ## 0.60.0 (2026-07-18) diff --git a/docs/superpowers/plans/2026-07-19-tui-content-rendering-standardization.md b/docs/superpowers/plans/2026-07-19-tui-content-rendering-standardization.md new file mode 100644 index 00000000..89a47ec9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-tui-content-rendering-standardization.md @@ -0,0 +1,778 @@ +# TUI Content Rendering Standardization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ensure all user-visible TUI content renders according to an explicit semantic contract so authored Markdown never leaks as raw syntax and literal technical payloads remain exact and safe. + +**Architecture:** Add one deep `render_tui_content` module that owns semantic dispatch, sanitization, reasoning presentation, and degraded fallback. Migrate lifecycle and adapter call sites to classify strings as prose, reasoning, literal, label, or error before rendering, then enforce that seam with behavior tests and a narrow static tripwire. + +**Tech Stack:** Python 3.14, Rich, Pythinker's existing Markdown/report renderer, pytest, Ruff, Pyright, ty, uv, Make. + +## Global Constraints + +- Ordinary user-visible TUI transcript content must never leak complete Markdown formatting or container syntax. +- Literal commands, diffs, logs, JSON, source, paths, and file contents remain literal after unsafe terminal-control sanitization. +- Content semantics must remain invariant across live preview, finalization, scrollback, and replay. +- Existing report, diff, code, tool-card, theme-token, glyph, spacing, motion, and terminal-capability implementations remain authoritative. +- Add no dependency, telemetry, external service, wire event, persisted-session migration, or parallel styling system. +- Unexpected renderer failure must produce a visible sanitized degraded result and a content-free categorized log entry. +- Use `uv` or repository `make` targets for every Python command. +- Keep the implementation surgical; do not convert application chrome or structured renderables into Markdown. + +--- + +## File Structure + +- Create `src/pythinker_code/ui/shell/components/content.py`: semantic content kinds, narrow presentation options, the single rendering interface, sanitization, and degraded fallback. +- Modify `src/pythinker_code/ui/shell/components/__init__.py`: export the shared interface and types. +- Create `tests/ui_and_conv/test_tui_content_rendering.py`: shared interface contract and forced-failure coverage. +- Modify `src/pythinker_code/ui/shell/visualize/_blocks.py`: route composing/reasoning live and final content plus authored notification/progress/suggestion/status prose through the shared interface. +- Modify `tests/ui_and_conv/test_streaming_content_block.py`: lifecycle equivalence and exact raw-header regression tests. +- Modify `src/pythinker_code/ui/shell/components/messages.py`: classify assistant, reasoning, user, custom, and error content. +- Modify `src/pythinker_code/ui/shell/visualize/_transcript.py`: require an explicit content kind when the body is a string. +- Modify `src/pythinker_code/ui/shell/visualize/_approval_panel.py`: render request descriptions and brief display blocks as prose while retaining shell/diff content as literal structured output. +- Modify `src/pythinker_code/ui/shell/components/special_messages.py`: route expanded authored bodies through the shared interface. +- Modify `tests/ui_and_conv/test_tui_card_messages.py`, `tests/ui_and_conv/test_transcript_rows.py`, and `tests/ui_and_conv/test_modal_lifecycle.py`: adapter contract coverage. +- Modify `src/pythinker_code/ui/shell/visualize/_worklog.py` and generic fallback sites in `src/pythinker_code/ui/shell/visualize/_blocks.py`: make prose versus literal tool-result decisions explicit. +- Modify `tests/ui_and_conv/test_worklog_render.py`, `tests/ui_and_conv/test_tui_card_tool_renderers.py`, and `tests/ui_and_conv/test_render_hardening.py`: tool-fallback behavior and architecture tripwire. +- Modify `CHANGELOG.md`: user-visible Unreleased entry. + +--- + +### Task 1: Establish the semantic content-rendering module + +**Files:** +- Create: `src/pythinker_code/ui/shell/components/content.py` +- Modify: `src/pythinker_code/ui/shell/components/__init__.py` +- Create: `tests/ui_and_conv/test_tui_content_rendering.py` + +**Interfaces:** +- Consumes: `render_agent_body(text: str, *, theme: ThemeName | None = None) -> RenderableType`, `sanitize_ansi(text: str) -> str`, and `tui_rich_style(token: str) -> Style`. +- Produces: `ContentKind`, `ContentPresentation`, and `render_tui_content(text: str, *, kind: ContentKind, presentation: ContentPresentation | None = None) -> RenderableType`. + +- [ ] **Step 1: Write the failing semantic-renderer contract tests** + +Create `tests/ui_and_conv/test_tui_content_rendering.py` with parameterized coverage for prose, +reasoning, literal, label, error, empty input, control-sequence sanitization, and renderer failure: + +```python +from __future__ import annotations + +import pytest + +from tests.ui_and_conv._md_contract_helpers import render_ansi, render_plain + + +@pytest.mark.parametrize( + ("markup", "visible", "forbidden"), + [ + ("**bold**", "bold", "**"), + ("_italic_", "italic", "_italic_"), + ("# Heading", "Heading", "# Heading"), + ("- first\n- second", "first", "- first"), + ("`value`", "value", "`value`"), + ("```python\nprint('ok')\n```", "print('ok')", "```"), + ], +) +def test_prose_renders_complete_markdown(markup: str, visible: str, forbidden: str) -> None: + from pythinker_code.ui.shell.components.content import ContentKind, render_tui_content + + output = render_plain(render_tui_content(markup, kind=ContentKind.PROSE)) + assert visible in output + assert forbidden not in output + + +def test_reasoning_uses_markdown_and_reasoning_style() -> None: + from pythinker_code.ui.shell.components.content import ContentKind, render_tui_content + + rendered = render_tui_content("**Planning**", kind=ContentKind.REASONING) + assert "**" not in render_plain(rendered) + assert "Planning" in render_plain(rendered) + assert "Planning" in render_ansi(rendered) + + +@pytest.mark.parametrize("kind_name", ["LITERAL", "LABEL", "ERROR"]) +def test_literal_kinds_preserve_markdown_punctuation(kind_name: str) -> None: + from pythinker_code.ui.shell.components.content import ContentKind, render_tui_content + + text = "path/[x] **literal** `value`" + assert text in render_plain(render_tui_content(text, kind=ContentKind[kind_name])) + + +def test_all_kinds_strip_terminal_controls() -> None: + from pythinker_code.ui.shell.components.content import ContentKind, render_tui_content + + payload = "safe\x1b]0;owned\x07 text\x1b[2J" + for kind in ContentKind: + output = render_plain(render_tui_content(payload, kind=kind)) + assert output.strip() == "safe text" + assert "\x1b" not in output + + +def test_empty_input_returns_empty_visible_output() -> None: + from pythinker_code.ui.shell.components.content import ContentKind, render_tui_content + + assert render_plain(render_tui_content("", kind=ContentKind.PROSE)) == "\n" + + +def test_markdown_failure_is_visible_sanitized_and_logged(monkeypatch) -> None: + from pythinker_code.ui.shell.components import content + + logged: list[tuple[str, tuple[object, ...]]] = [] + + class CapturingLogger: + def opt(self, **_kwargs): + return self + + def warning(self, message: str, *args: object) -> None: + logged.append((message, args)) + + def fail(_text: str): + raise ValueError("parser failed") + + monkeypatch.setattr(content, "render_agent_body", fail) + monkeypatch.setattr(content, "logger", CapturingLogger()) + payload = "**still visible**\x1b[2J" + output = render_plain(content.render_tui_content(payload, kind=content.ContentKind.PROSE)) + assert "**still visible**" in output + assert "\x1b" not in output + assert logged == [ + ("tui_content_render_degraded kind={} length={}", ("prose", 17)) + ] + assert all(payload not in message for message, _args in logged) +``` + +- [ ] **Step 2: Run the new test module and verify the missing-module failure** + +Run: + +```bash +uv run pytest tests/ui_and_conv/test_tui_content_rendering.py -q +``` + +Expected: FAIL during collection with `ModuleNotFoundError` for +`pythinker_code.ui.shell.components.content`. + +- [ ] **Step 3: Implement the semantic rendering interface** + +Create `src/pythinker_code/ui/shell/components/content.py` with this interface and behavior: + +```python +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from loguru import logger +from rich.console import RenderableType +from rich.style import Style +from rich.styled import Styled +from rich.text import Text + +from pythinker_code.ui.shell.components.render_utils import sanitize_ansi +from pythinker_code.ui.shell.components.report import render_agent_body +from pythinker_code.ui.theme import tui_rich_style + + +class ContentKind(StrEnum): + PROSE = "prose" + REASONING = "reasoning" + LITERAL = "literal" + LABEL = "label" + ERROR = "error" + + +@dataclass(frozen=True, slots=True) +class ContentPresentation: + style_token: str | None = None + italic: bool = False + + +def _literal_style(kind: ContentKind, presentation: ContentPresentation) -> Style: + token = presentation.style_token + if token is None: + token = "error" if kind is ContentKind.ERROR else None + style = tui_rich_style(token) if token is not None else Style() + return style + Style(italic=presentation.italic) + + +def render_tui_content( + text: str, + *, + kind: ContentKind, + presentation: ContentPresentation | None = None, +) -> RenderableType: + presentation = presentation or ContentPresentation() + safe_text = sanitize_ansi(text) + if not safe_text: + return Text("") + if kind in {ContentKind.LITERAL, ContentKind.LABEL, ContentKind.ERROR}: + return Text(safe_text, style=_literal_style(kind, presentation)) + try: + rendered = render_agent_body(safe_text) + except Exception: # noqa: BLE001 - TUI degradation must remain visible + logger.opt(exception=True).warning( + "tui_content_render_degraded kind={} length={}", + kind.value, + len(safe_text), + ) + fallback_style = tui_rich_style("thinking_text") if kind is ContentKind.REASONING else Style() + return Text(safe_text, style=fallback_style + Style(italic=kind is ContentKind.REASONING)) + if kind is ContentKind.REASONING: + token = presentation.style_token or "thinking_text" + reasoning_style = tui_rich_style(token) + Style(italic=presentation.italic) + return Styled(rendered, reasoning_style) + return rendered +``` + +The `Styled` wrapper supplies reasoning's muted italic default while nested Markdown elements keep +their own semantic styles. Do not stringify or flatten the renderable. + +- [ ] **Step 4: Export the new interface** + +Update `components/__init__.py`: + +```python +from pythinker_code.ui.shell.components.content import ( + ContentKind, + ContentPresentation, + render_tui_content, +) +``` + +Add all three names to `__all__` in alphabetical order. + +- [ ] **Step 5: Run the focused contract tests and static checks** + +Run: + +```bash +uv run pytest tests/ui_and_conv/test_tui_content_rendering.py -q +uv run ruff check src/pythinker_code/ui/shell/components/content.py tests/ui_and_conv/test_tui_content_rendering.py +uv run pyright src/pythinker_code/ui/shell/components/content.py +``` + +Expected: all tests pass; Ruff reports `All checks passed!`; Pyright reports zero errors. + +- [ ] **Step 6: Commit the shared module** + +```bash +git add src/pythinker_code/ui/shell/components/content.py \ + src/pythinker_code/ui/shell/components/__init__.py \ + tests/ui_and_conv/test_tui_content_rendering.py +git commit -m "feat(tui): add semantic content renderer" +``` + +--- + +### Task 2: Make streaming, finalization, and scrollback semantically invariant + +**Files:** +- Modify: `src/pythinker_code/ui/shell/visualize/_blocks.py:568-590, 808-850, 974-997, 1120-1193` +- Modify: `tests/ui_and_conv/test_streaming_content_block.py:350-443, 623-790` + +**Interfaces:** +- Consumes: `ContentKind`, `ContentPresentation`, and `render_tui_content` from Task 1. +- Produces: `_ContentBlock` live/final renderables whose Markdown semantics do not change on `TOOL_START`, `THINK_TO_TEXT`, `TEXT_TO_THINK`, or `TURN_END`. + +- [ ] **Step 1: Add the exact regression and lifecycle contract tests** + +Add tests that compare live and final reasoning output using the reported header: + +```python +@pytest.mark.parametrize( + "reason", + [FlushReason.TOOL_START, FlushReason.THINK_TO_TEXT, FlushReason.TURN_END], +) +def test_complete_reasoning_markdown_never_leaks_when_finalized(reason: FlushReason) -> None: + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append("**Clarifying AGENTS.md file location**") + + live = _plain(block.compose()) + block.prepare_for_finalize(reason) + final = _plain(block.compose_final()) + + for output in (live, final): + assert "Clarifying AGENTS.md file location" in output + assert "**Clarifying AGENTS.md file location**" not in output +``` + +Add corresponding complete-markup tests for composing content finalized at tool start and turn end. +Retain an incomplete-delimiter test proving that `**Planning agent` is readable during streaming; +do not require incomplete syntax to disappear before completion. + +- [ ] **Step 2: Run the exact regression and observe final scrollback fail** + +Run: + +```bash +uv run pytest tests/ui_and_conv/test_streaming_content_block.py \ + -k 'complete_reasoning_markdown_never_leaks_when_finalized' -q +``` + +Expected: FAIL because `compose_final()` currently returns `Text(remaining, ...)` and preserves +the literal `**` delimiters. + +- [ ] **Step 3: Route every `_ContentBlock` authored-content path through the shared interface** + +Replace the independent prose/reasoning choices with small private adapters: + +```python +def _render_reasoning(self, text: str) -> RenderableType: + return render_tui_content( + text, + kind=ContentKind.REASONING, + presentation=ContentPresentation(style_token="thinking_text", italic=True), + ) + +def _render_prose(self, text: str) -> RenderableType: + return render_tui_content(text, kind=ContentKind.PROSE) +``` + +Use `_render_reasoning(remaining)` in `compose_final()` instead of `Text(remaining, ...)`. Route +`_render_thinking_preview`, `_flush_committed`, `_render_body`, and final prose through the same +semantic adapter. Preserve: + +- report detection and suppression; +- `_has_printed_bullet` state; +- preview caching keys; +- six-line reasoning preview; +- streaming caret and paced reveal behavior; +- existing `BulletColumns`, spacing, and transcript marker styles. + +Remove comments that claim final reasoning intentionally bypasses Markdown. + +- [ ] **Step 4: Run streaming and transition coverage** + +Run: + +```bash +uv run pytest tests/ui_and_conv/test_streaming_content_block.py \ + tests/ui_and_conv/test_empty_think_part_indicator.py \ + tests/ui_and_conv/test_tui_blocks_integration.py -q +``` + +Expected: all tests pass. Inspect failures for intentional raw-preview assertions; update only +assertions contradicted by the approved rendering contract, while retaining incomplete-stream and +literal-code expectations. + +- [ ] **Step 5: Run motion and scrollback regressions** + +Run: + +```bash +uv run pytest tests/ui_and_conv/test_stream_pacing.py \ + tests/ui_and_conv/test_redraw_throttle.py \ + tests/ui_and_conv/test_visualize_running_prompt.py -q +``` + +Expected: all tests pass with no changed handoff ordering, flicker contract, or pacing behavior. + +- [ ] **Step 6: Commit lifecycle standardization** + +```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): preserve markdown across transcript lifecycle" +``` + +--- + +### Task 3: Migrate message, transcript, replay, and modal adapters + +**Files:** +- Modify: `src/pythinker_code/ui/shell/components/messages.py` +- Modify: `src/pythinker_code/ui/shell/components/special_messages.py` +- Modify: `src/pythinker_code/ui/shell/visualize/_transcript.py` +- Modify: `src/pythinker_code/ui/shell/visualize/_approval_panel.py` +- Modify: `src/pythinker_code/ui/shell/visualize/_blocks.py:1849-2095` +- Modify: `tests/ui_and_conv/test_tui_card_messages.py` +- Modify: `tests/ui_and_conv/test_transcript_rows.py` +- Modify: `tests/ui_and_conv/test_modal_lifecycle.py` +- Modify: `tests/ui_and_conv/test_replay.py` + +**Interfaces:** +- Consumes: the semantic renderer from Task 1 and lifecycle behavior from Task 2. +- Produces: explicit semantic classification for every generic message, transcript string, replayed content string, approval description/brief, and authored status block. + +- [ ] **Step 1: Add adapter-level failing tests** + +Add focused assertions: + +```python +def test_visible_assistant_thinking_renders_markdown() -> None: + rendered = render_assistant_message( + [AssistantContent(kind="thinking", text="**Inspecting state**")] + ) + output = render_plain(rendered, width=60) + assert "Inspecting state" in output + assert "**Inspecting state**" not in output + + +def test_custom_message_body_renders_markdown_even_with_custom_text_style() -> None: + output = render_plain( + render_custom_message(CustomMessageInput(custom_type="notice", text="**Ready**")), + width=60, + ) + assert "Ready" in output + assert "**Ready**" not in output + + +def test_transcript_string_requires_and_honors_semantic_kind() -> None: + output = _plain( + render_transcript_row("assistant", "**Ready**", content_kind=ContentKind.PROSE) + ) + assert "Ready" in output + assert "**Ready**" not in output + + +def test_transcript_literal_body_preserves_markdown_punctuation() -> None: + text = "git show path/[x] --format=**raw**" + output = _plain(render_transcript_row("tool", text, content_kind=ContentKind.LITERAL)) + assert text in output +``` + +Add modal tests proving approval descriptions and `BriefDisplayBlock.text` render Markdown, while +`ShellDisplayBlock.command` and diff content preserve punctuation literally. Add a replay test that +replays assistant/reasoning Markdown and asserts no complete delimiters leak. + +- [ ] **Step 2: Run the adapter tests and observe the raw-marker failures** + +Run: + +```bash +uv run pytest tests/ui_and_conv/test_tui_card_messages.py \ + tests/ui_and_conv/test_transcript_rows.py \ + tests/ui_and_conv/test_modal_lifecycle.py \ + tests/ui_and_conv/test_replay.py -q +``` + +Expected: new reasoning, custom-message, transcript, approval, or replay assertions fail because +those paths currently construct plain `Text` directly. + +- [ ] **Step 3: Migrate message and special-message bodies** + +In `messages.py`: + +- use `PROSE` for user text, assistant `kind="text"`, and custom message bodies; +- use `REASONING` for visible assistant `kind="thinking"`; +- use `LABEL` for hidden-thinking labels and custom-type labels; +- use `ERROR` for aborted/error messages; +- remove the branch that reconstructs `Text(message.text, ...)` merely because the Markdown + renderable is a `Text` subclass; +- retain card backgrounds, padding, separators, and stop-reason wording. + +In `special_messages.py`, use `PROSE` for expanded skill, compaction, and branch summary bodies, +and keep collapsed names/hints as `LABEL`. + +- [ ] **Step 4: Make transcript string classification explicit** + +Change the transcript interface to: + +```python +def render_transcript_row( + role: Role, + content: str | RenderableType, + *, + content_kind: ContentKind | None = None, + status: Status | None = None, +) -> RenderableType: + ... +``` + +If `content` is a string and `content_kind` is `None`, raise `ValueError` with an actionable message +instead of guessing. If `content` is already a Rich renderable, reject a non-`None` `content_kind` +as conflicting input. Update every caller to pass the semantic kind. This makes ambiguity explicit +without altering wire payloads. + +- [ ] **Step 5: Migrate modal and generic authored-status bodies** + +Use `PROSE` for approval request descriptions and `BriefDisplayBlock.text`; retain +`PythinkerSyntax` for `ShellDisplayBlock.command` and existing diff renderers for diffs. Use +`LABEL` for sender, action, option labels, source identifiers, key hints, and feedback UI. + +In `_blocks.py`, classify notification bodies, progress notes, suggestions, status explanations, +and compaction prose as `PROSE` only where their contract describes authored explanatory text. +Titles, severity labels, counters, and application-owned status words remain `LABEL`. Preserve +existing preview line budgets and expand behavior. + +- [ ] **Step 6: Run adapter, replay, and modal suites** + +Run: + +```bash +uv run pytest tests/ui_and_conv/test_tui_card_messages.py \ + tests/ui_and_conv/test_transcript_rows.py \ + tests/ui_and_conv/test_modal_lifecycle.py \ + tests/ui_and_conv/test_replay.py \ + tests/ui_and_conv/test_btw.py -q +``` + +Expected: all tests pass; shell commands and diffs remain byte-equivalent in plain captures. + +- [ ] **Step 7: Commit adapter migration** + +```bash +git add src/pythinker_code/ui/shell/components/messages.py \ + src/pythinker_code/ui/shell/components/special_messages.py \ + src/pythinker_code/ui/shell/visualize/_transcript.py \ + src/pythinker_code/ui/shell/visualize/_approval_panel.py \ + src/pythinker_code/ui/shell/visualize/_blocks.py \ + tests/ui_and_conv/test_tui_card_messages.py \ + tests/ui_and_conv/test_transcript_rows.py \ + tests/ui_and_conv/test_modal_lifecycle.py \ + tests/ui_and_conv/test_replay.py +git commit -m "refactor(tui): classify visible transcript content" +``` + +--- + +### Task 4: Standardize generic tool fallbacks and add the architecture tripwire + +**Files:** +- Modify: `src/pythinker_code/ui/shell/visualize/_worklog.py` +- Modify: `src/pythinker_code/ui/shell/visualize/_blocks.py:1300-1685` +- Modify: `tests/ui_and_conv/test_worklog_render.py` +- Modify: `tests/ui_and_conv/test_tui_card_tool_renderers.py` +- Modify: `tests/ui_and_conv/test_render_hardening.py` + +**Interfaces:** +- Consumes: `render_tui_content` and content kinds from Task 1. +- Produces: explicit prose/literal classification for generic tool output and a static guard against new direct content-bearing `Text(...)` paths. + +- [ ] **Step 1: Add tool-fallback semantic tests** + +Add tests for both TUI styles: + +```python +def test_authored_report_fallback_renders_markdown() -> None: + output = _render_worklog_result(tool="Report", text="**Summary**") + assert "Summary" in output + assert "**Summary**" not in output + + +@pytest.mark.parametrize("tool", ["Shell", "ReadFile", "Grep", "FetchURL"]) +def test_literal_tool_fallback_preserves_markdown_punctuation(tool: str) -> None: + text = "path/[x] **literal** `value`" + assert text in _render_tool_fallback(tool=tool, text=text) +``` + +Use existing test factories and style fixtures rather than introducing parallel fake wire types. +Cover streamed stdout and stderr separately; stderr uses `ERROR` styling but remains literal. + +- [ ] **Step 2: Run fallback tests and record which existing paths misclassify content** + +Run: + +```bash +uv run pytest tests/ui_and_conv/test_worklog_render.py \ + tests/ui_and_conv/test_tui_card_tool_renderers.py \ + -k 'fallback or markdown_punctuation or authored_report' -q +``` + +Expected: at least the authored prose fallback fails by exposing Markdown or the literal fallback +fails if it is indiscriminately converted to Markdown. + +- [ ] **Step 3: Centralize fallback classification without hard-coding provider lists** + +Add a small internal tool-output classifier adjacent to the existing tool-style metadata. It must +classify by established tool/display contract, not by searching payload punctuation: + +```python +_PROSE_RESULT_TOOLS = frozenset({"Report", "Agent", "RunAgents"}) + + +def _fallback_content_kind(tool_name: str, *, is_error: bool, streamed: bool) -> ContentKind: + if is_error: + return ContentKind.ERROR + if streamed: + return ContentKind.LITERAL + if tool_name in _PROSE_RESULT_TOOLS: + return ContentKind.PROSE + return ContentKind.LITERAL +``` + +Before accepting the final set, inspect every registered built-in renderer and existing worklog +style entry. Prefer structured display blocks and registered renderers over this fallback. Keep the +set restricted to tools whose contract explicitly returns authored prose; do not classify unknown +MCP tools as prose. Unknown and unregistered tool output fails safe as `LITERAL`. + +Use the classifier for legacy worklog results and `_ToolCallBlock` streamed/generic fallback +children. Do not route diff, syntax, activity-tree, file-listing, or structured display renderables +through Markdown. + +- [ ] **Step 4: Add a narrow AST-based architecture tripwire** + +Extend `tests/ui_and_conv/test_render_hardening.py` with an AST visitor that detects direct +`Text(variable)` calls where the variable name is one of the known content-bearing names: + +```python +_CONTENT_VARIABLE_NAMES = { + "body", + "content", + "description", + "message_text", + "preview", + "remaining", + "response", + "summary", +} + + +def test_content_bearing_strings_use_semantic_renderer() -> None: + violations: list[str] = [] + for path in _shell_render_modules(): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if not _is_direct_text_call_for_content_name(node, _CONTENT_VARIABLE_NAMES): + continue + key = f"{path.relative_to(REPO_ROOT)}:{node.lineno}" + if key not in _DIRECT_TEXT_ALLOWLIST: + violations.append(key) + assert not violations, "content bypassed render_tui_content:\n" + "\n".join(violations) +``` + +Implement `_is_direct_text_call_for_content_name` against `ast.Call`, `ast.Name`, and +`ast.Attribute`; include keyword arguments such as `Text(text=message.body)`. Keep an exact +path-and-line allowlist only for verified literal/specialized implementation internals, with an +inline semantic explanation for each entry. Do not exempt an entire file or every `Text` call. + +- [ ] **Step 5: Run the tripwire and review every allowlist entry** + +Run: + +```bash +uv run pytest tests/ui_and_conv/test_render_hardening.py -q +``` + +Expected: PASS. Read each allowlisted source line and confirm it is application chrome, +already-sanitized literal content, or a structured renderer. Rename ambiguous local variables to +semantic names where that makes the classification self-evident; do not broaden the allowlist to +silence a real authored-content bypass. + +- [ ] **Step 6: Run both TUI style suites** + +Run: + +```bash +PYTHINKER_TUI_STYLE=card uv run pytest tests/ui_and_conv/test_tui_card_tool_renderers.py -q +PYTHINKER_TUI_STYLE=pythinker uv run pytest tests/ui_and_conv/test_worklog_render.py \ + tests/ui_and_conv/test_tui_blocks_integration.py -q +``` + +Expected: all tests pass. Literal output remains exact, authored fallback prose has no raw complete +Markdown markers, and unknown tools remain literal. + +- [ ] **Step 7: Commit fallback standardization and enforcement** + +```bash +git add src/pythinker_code/ui/shell/visualize/_worklog.py \ + src/pythinker_code/ui/shell/visualize/_blocks.py \ + tests/ui_and_conv/test_worklog_render.py \ + tests/ui_and_conv/test_tui_card_tool_renderers.py \ + tests/ui_and_conv/test_render_hardening.py +git commit -m "refactor(tui): enforce semantic rendering fallbacks" +``` + +--- + +### Task 5: Changelog, compatibility audit, and full verification + +**Files:** +- Modify: `CHANGELOG.md` +- Verify only: `docs/en/release-notes/changelog.md` through its documented generator if preparing a PR + +**Interfaces:** +- Consumes: completed behavior from Tasks 1-4. +- Produces: user-facing release note and evidence that the full Pythinker package gates pass. + +- [ ] **Step 1: Add the required Unreleased changelog entry** + +Add under `## Unreleased` in `CHANGELOG.md`: + +```markdown +- Standardize shell TUI content rendering so Markdown remains formatted across live, finalized, + scrollback, and replay views while commands, diffs, logs, JSON, and source output remain literal. +``` + +Do not edit `docs/en/release-notes/changelog.md` manually. + +- [ ] **Step 2: Run the complete focused TUI verification set** + +Run: + +```bash +uv run pytest \ + tests/ui_and_conv/test_tui_content_rendering.py \ + tests/ui_and_conv/test_streaming_content_block.py \ + tests/ui_and_conv/test_empty_think_part_indicator.py \ + tests/ui_and_conv/test_tui_card_messages.py \ + tests/ui_and_conv/test_transcript_rows.py \ + tests/ui_and_conv/test_modal_lifecycle.py \ + tests/ui_and_conv/test_replay.py \ + tests/ui_and_conv/test_worklog_render.py \ + tests/ui_and_conv/test_tui_card_tool_renderers.py \ + tests/ui_and_conv/test_render_hardening.py \ + tests/ui_and_conv/test_stream_pacing.py \ + tests/ui_and_conv/test_redraw_throttle.py \ + tests/ui_and_conv/test_visualize_running_prompt.py -q +``` + +Expected: all selected tests pass with no warnings introduced by changed code. + +- [ ] **Step 3: Verify terminal capability and Markdown compatibility** + +Run: + +```bash +uv run pytest tests/ui/test_shell_markdown.py \ + tests/ui/test_console_theme.py \ + tests/ui_and_conv/test_md_normalization_matrix.py \ + tests/ui_and_conv/test_md_wrapping_contract.py \ + tests/ui_and_conv/test_code_theme_opt_in.py \ + tests/ui_and_conv/test_spacing_primitives.py -q +``` + +Expected: all tests pass in the existing dark/light, width, no-color, and formatting contracts. + +- [ ] **Step 4: Run the full package quality gate** + +Run: + +```bash +make check-pythinker-code +make test-pythinker-code +``` + +Expected: check target prints its successful Ruff format/check, Pyright, and ty summaries; +test target reports all `tests` and `tests_e2e` passing. If a required system tool is missing, +record the exact unavailable command and error rather than claiming success. + +- [ ] **Step 5: Inspect the complete diff and architecture surface** + +Run: + +```bash +git diff --check +git diff --stat HEAD~4..HEAD +git diff HEAD~4..HEAD -- src/pythinker_code/ui/shell tests/ui_and_conv CHANGELOG.md +git status --short +``` + +Expected: no whitespace errors; only the planned TUI, tests, and changelog files changed; no direct +authored-content `Text(...)` bypass or generated changelog edit appears. + +- [ ] **Step 6: Commit the changelog and any verification-only corrections** + +```bash +git add CHANGELOG.md +git commit -m "docs: note standardized TUI content rendering" +``` + +Do not fold unrelated formatter churn or pre-existing worktree changes into this commit. + +- [ ] **Step 7: Request final code review before integration** + +Use `superpowers:requesting-code-review` against the complete implementation diff. Resolve every +confirmed correctness, security, compatibility, or maintainability finding immediately, then rerun +the smallest affected focused tests and both full package gates before claiming completion. diff --git a/docs/superpowers/specs/2026-07-19-tui-content-rendering-standardization-design.md b/docs/superpowers/specs/2026-07-19-tui-content-rendering-standardization-design.md new file mode 100644 index 00000000..201b8488 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-tui-content-rendering-standardization-design.md @@ -0,0 +1,250 @@ +# TUI content rendering standardization design + +**Status:** Approved by the user on 2026-07-19 + +## Problem + +The shell TUI does not have one content-rendering contract. Individual live, finalized, +scrollback, replay, message, panel, and tool paths independently choose between Rich `Text`, the +Pythinker Markdown renderer, and specialized renderers. The same model-authored content can +therefore render correctly while live and expose raw Markdown delimiters after a lifecycle +transition. For example, a live thinking header renders `**Heading**` as bold text, but starting a +tool finalizes that reasoning through plain `Text` and prints the literal `**` markers. + +The absence of a shared semantic classification also makes a blanket Markdown conversion unsafe. +Commands, diffs, logs, JSON, source code, file contents, paths, application labels, and error text +have literal-display requirements that differ from authored prose. + +## Goals + +- Ensure ordinary user-visible TUI transcript content never leaks raw Markdown container or + formatting syntax. +- Preserve authored Markdown semantics consistently across live preview, finalization, scrollback, + and replay. +- Preserve literal technical payloads exactly inside explicit code, diff, log, or output + presentations without displaying their surrounding Markdown fence markers. +- Centralize semantic content classification, ANSI/control-sequence sanitization, fallback + behavior, and presentation selection behind one small interface. +- Preserve reports, tables, fenced code, links, lists, inline code, Unicode width, terminal + capability behavior, streaming motion, and scrollback ownership. +- Prevent new rendering paths from bypassing the shared contract accidentally. + +## Non-goals + +- Changing wire events, persisted session formats, model prompts, provider output, or whether + reasoning is emitted or stored. +- Changing prompt input editing, shell command execution, web/dashboard rendering, or ACP wire + semantics. +- Treating every string as Markdown. Application chrome and literal technical payloads remain + literal by explicit semantic classification. +- Replacing specialized report, diff, syntax-highlighted code, tool-card, or activity renderers. +- Introducing a new dependency, styling system, theme token family, or parser. + +## Rendering contract + +Every content-bearing TUI path must classify its input before rendering it. The classification is +semantic rather than lifecycle-specific: + +| Content kind | Meaning | Rendering behavior | +| --- | --- | --- | +| `PROSE` | Model-, user-, extension-, or tool-authored explanatory text | Sanitize and render through the existing agent-body/Pythinker Markdown pipeline. | +| `REASONING` | Model reasoning that is configured to be visible | Use the same Markdown semantics as prose with the established muted reasoning presentation. | +| `LITERAL` | Commands, source, diffs, logs, JSON, file content, and raw tool streams | Sanitize and preserve content exactly in an explicit literal/code/output presentation. | +| `LABEL` | Application-owned titles, status words, counters, paths, key hints, and identifiers | Sanitize and render as plain text with caller-selected theme styling. | +| `ERROR` | User-visible failure text that must not interpret payload markup | Sanitize and render literally with the established error presentation. | + +The application owns the classification. Untrusted content cannot select its own kind. Callers +must not infer the kind from whether the string happens to contain Markdown punctuation. + +The primary interface will be a pure TUI rendering function under `ui/shell/components/`. It will +accept the string, semantic kind, and a narrow presentation descriptor and return a Rich +`RenderableType`. The presentation descriptor may select existing theme styles or literal +presentation variants, but it must not allow callers to substitute an arbitrary parser or bypass +sanitization. + +This forms a deep module: callers learn one interface while the implementation owns Markdown +normalization, report promotion, control-sequence sanitization, reasoning styling, literal +preservation, empty-input behavior, and safe fallback. + +## Architecture and ownership + +The shared content-rendering module owns: + +- sanitizing ANSI and unsafe terminal control sequences before interpretation; +- dispatching by semantic content kind; +- routing `PROSE` and `REASONING` through `render_agent_body` and the existing Pythinker Markdown + pipeline; +- routing `LITERAL`, `LABEL`, and `ERROR` through safe literal renderers; +- applying the established theme tokens without introducing raw colors; +- producing a safe degraded renderable if authored Markdown rendering fails; +- emitting categorized diagnostics without logging the input content. + +Specialized modules retain ownership of their established semantics: + +- report parsing and presentation remain in `components/report.py`; +- Markdown normalization and element rendering remain in `ui/shell/markdown/`; +- diff layout remains in `components/diff.py` and file-diff tool renderers; +- activity labels, glyphs, spacing, and motion remain in their existing modules; +- tool renderers retain structured argument/result interpretation. + +These modules call or sit behind the shared interface as appropriate; the design does not wrap +already-structured Rich renderables in Markdown again. + +## Data flow and lifecycle invariance + +The normal data flow is: + +```text +wire/model/tool content + -> caller assigns semantic content kind + -> shared content-rendering interface sanitizes and dispatches + -> existing Markdown/report or literal/specialized implementation + -> Rich renderable + -> live preview / final scrollback / replay adapter +``` + +Lifecycle state must not change content semantics. A `REASONING` fragment remains `REASONING` +when it moves from the live six-line preview to final scrollback. A `PROSE` fragment remains +`PROSE` when a tool starts, a think-to-text transition occurs, or the turn ends. Live paths may +temporarily limit rows or defer incomplete constructs, but finalization must render the same +authored content with the same Markdown semantics. + +The renderable need not be the same object across phases. Its visible text and semantic styling +must be equivalent except for intentional lifecycle chrome such as a spinner, caret, elapsed-time +label, preview truncation, or transcript bullet. + +## Streaming and incomplete Markdown + +Streaming paths must not flash raw structured payloads or crash when a construct is incomplete. +They continue to use the established commit-boundary and fence-aware buffering rules. + +- Complete Markdown inside the visible preview renders normally. +- An incomplete inline delimiter may remain temporarily literal until enough input arrives to + interpret it safely; once complete, the next render removes the delimiter syntax. +- Open ordinary code fences hide their fence marker and use the existing bounded code-preview + behavior. +- Open `report` fences must never reveal report JSON before validation and promotion. +- Complete top-level HTML comments remain hidden outside fenced code; comment examples inside + literal code remain visible. +- Finalized complete Markdown must not use the temporary plain streaming fallback. + +## Literal-content behavior + +Literal content preserves its meaningful characters, whitespace, line structure, and ordering +after unsafe terminal controls are removed. Markdown punctuation inside a command, path, diff, +log, JSON document, or source file is data and is not interpreted. + +When literal content originates inside a Markdown fence, the fence is a container instruction and +is not displayed. The code/output renderer displays only the body, with the established language +label or tool context when available. Existing truncation must remain explicit through an expand +hint or omitted-line count; it must never masquerade as complete output. + +## Error and degraded behavior + +- Empty input returns an empty renderable and does not manufacture spacing or success output. +- Malformed or incomplete authored Markdown must remain readable and cannot escape the live render + loop as an exception. +- If the Markdown/report implementation raises unexpectedly, the shared module returns a visibly + degraded, sanitized literal presentation. It must not return an empty renderable for non-empty + input or present the fallback as successfully formatted Markdown. +- The failure is logged with a stable category, semantic kind, content length, and rendering phase. + Logs must not contain the content, credentials, tool arguments, raw output, or a user-visible + stack trace. +- ANSI, OSC, APC, Rich markup, and other terminal-control input cannot become active terminal + control through either the primary or fallback path. +- Rendering remains local and deterministic. It adds no retry, network call, telemetry, or + background lifecycle. + +## Migration scope + +The first implementation migrates every model- or extension-authored transcript seam and the +generic fallbacks capable of receiving such content: + +- `_ContentBlock` live composing, live reasoning, final reasoning, final assistant prose, and + transition flushes; +- assistant, reasoning, user, and custom-message renderers; +- transcript and session replay helpers; +- progress notes, suggestions, notifications, compaction/status prose, and question/approval + explanatory prose; +- card-style and legacy-worklog generic result fallbacks when the tool contract identifies prose; +- existing literal fallbacks where migration is required to make their classification explicit or + to guarantee sanitization. + +Structured labels, spinners, glyphs, counters, timestamps, paths, identifiers, diff rows, and +syntax-highlighted payload bodies do not become Markdown. They either remain in their specialized +renderer or use `LABEL`/`LITERAL` explicitly. + +The migration must remove obsolete direct `Text(content)` decisions rather than layering the new +module in front of and behind old policy branches. + +## Enforcement + +A focused static architecture test will scan the shell TUI modules for direct construction of +plain Rich `Text` from known content-bearing fields and variables. A narrow allowlist will cover +application chrome, already-sanitized literal implementations, and specialized renderers whose +interface guarantees literal data. + +The static test is a tripwire, not the primary correctness proof. Runtime contract tests exercise +the shared interface and every lifecycle adapter. Any allowlist entry must name the semantic reason +it cannot use the shared interface; file-wide exemptions are prohibited. + +## Test design + +Tests are written before production changes and observed failing for the expected raw-Markdown +leak or missing interface. + +### Shared interface contract + +Parameterized fixtures cover: + +- bold, italic, strikethrough, headings, links, lists, block quotes, tables, and inline code; +- fenced code with and without a language; +- report blocks, malformed report blocks, and open report fences; +- complete and incomplete emphasis delimiters and code fences; +- complete top-level HTML comments and comments inside fenced code; +- ANSI, OSC, APC, Rich-markup-looking text, control characters, and Unicode-width cases; +- empty, whitespace-only, multiline, and large bounded inputs; +- forced Markdown-renderer failure and the visible degraded literal result. + +### Lifecycle adapter contract + +The same authored fixtures run through live preview, tool-start finalization, think-to-text, +text-to-think, turn-end finalization, cancellation/abort, scrollback emission, and session replay. +Tests assert that raw formatting delimiters do not appear after the construct is complete and that +semantic styling remains equivalent across transitions. + +The exact reported regression is permanent coverage: both live and finalized rendering of +`**Clarifying AGENTS.md file location**` display `Clarifying AGENTS.md file location` without +literal `**` markers. + +### Literal-content contract + +Commands, diffs, logs, JSON, paths, source, and file contents containing Markdown punctuation are +preserved exactly after control-sequence sanitization. Fenced literal fixtures prove that the body +remains exact while opening and closing fence markers are absent from visible output. + +### Compatibility contract + +Existing tests continue to cover no-color, reduced-motion, static output, ASCII/safe glyphs, +narrow/wide terminal widths, stream pacing, redraw throttling, scrollback handoff, report +suppression, tool cards, and legacy worklog style. + +Focused verification runs the shared rendering, streaming content block, replay, transcript, +message, modal, tool-card, and worklog tests. Because the implementation changes shipped shell +code, completion also requires `make check-pythinker-code` and `make test-pythinker-code`. + +## Documentation and compatibility + +This is a presentation correction, not a wire or persistence migration. Public configuration keys +and `show_thinking_stream` semantics remain compatible. User-visible behavior changes only where +raw Markdown syntax was previously exposed or authored prose was incorrectly treated as literal. + +Implementation must add a user-facing bullet under `## Unreleased` in `CHANGELOG.md`. The generated +docs changelog is updated only through the documented `npm run sync` workflow if that workflow is +part of the eventual PR preparation. + +## Rollback + +The change is isolated to the shared rendering module, migrated shell adapters, tests, and +changelog. It introduces no persisted state or dependency migration. Rollback is a code revert; +wire data and saved sessions remain readable because their stored content is unchanged. diff --git a/src/pythinker_code/ui/shell/prompt.py b/src/pythinker_code/ui/shell/prompt.py index cc512bf9..2a560df8 100644 --- a/src/pythinker_code/ui/shell/prompt.py +++ b/src/pythinker_code/ui/shell/prompt.py @@ -81,6 +81,13 @@ normalize_pasted_text, sanitize_surrogates, ) +from pythinker_code.ui.shell.prompting import ( + FrozenFragments, + PromptFrame, + PromptFrameCollector, + PromptSceneBudget, + allocate_prompt_scene_rows, +) from pythinker_code.ui.shell.spacing import ( PREAMBLE_EARLIER_OUTPUT_HIDDEN_HINT, ensure_prompt_newline, @@ -820,13 +827,52 @@ def _fit_formatted_text_to_rows( return out +def _fit_prompt_scene_to_rows( + fragments: FormattedText, + columns: int, + max_rows: int, + *, + show_clip_hint: bool = True, + drop_blank_rows: bool = False, +) -> FormattedText: + """Tail-clip a complete scene, changing nothing unless it overflows.""" + if max_rows <= 0: + return FormattedText() + rows = _formatted_text_display_rows(fragments, max(1, columns)) + if len(rows) <= max_rows: + return fragments + if drop_blank_rows: + rows = [row for row in rows if any(text for _, text, *_ in row)] + while rows and not any(text for _, text, *_ in rows[-1]): + rows.pop() + if not rows: + return FormattedText() + if len(rows) <= max_rows: + out = FormattedText() + _extend_rows(out, rows) + return out + if max_rows == 1 or not show_clip_hint: + selected = rows[-max_rows:] + out = FormattedText() + _extend_rows(out, selected) + return out + out = FormattedText( + [ + ( + "class:dim", + _truncate_right(PREAMBLE_EARLIER_OUTPUT_HIDDEN_HINT, columns), + ), + ("", "\n"), + ] + ) + _extend_rows(out, rows[-(max_rows - 1) :]) + return out + + def _prompt_preamble_max_rows(terminal_rows: int | None) -> int: if terminal_rows is None or terminal_rows <= 0: return 20 - # Reserve rows for: spacer, separator, input row, toolbar separator, footer - # rows, and one safety row. This prevents large tool cards from painting - # underneath the prompt/footer on short terminals. - return max(1, terminal_rows - 7) + return PromptSceneBudget(terminal_rows=terminal_rows).preamble_rows def _wrap_to_width(text: str, width: int, *, max_lines: int | None = None) -> list[str]: @@ -2321,6 +2367,8 @@ def __init__( self._fast_refresh_provider = fast_refresh_provider self._background_task_count_provider = background_task_count_provider self._update_notice_provider = update_notice_provider + self._prompt_frame_update_notice: str | None = None + self._prompt_footer_row_budget = 0 self._editor_command_provider = editor_command_provider self._turn_recaps_provider = turn_recaps_provider self._plan_mode_toggle_callback = plan_mode_toggle_callback @@ -2784,6 +2832,19 @@ def _(event: KeyPressEvent) -> None: style=get_prompt_style(), lexer=self._input_highlight_lexer, ) + self._current_prompt_frame: PromptFrame | None = None + self._prompt_frame_collector = self._make_prompt_frame_collector() + + def _capture_prompt_frame(app: Application[str]) -> None: + size = app.output.get_size() + self._current_prompt_frame = self._prompt_frame_collector.capture( + columns=size.columns, + terminal_rows=size.rows, + ) + + self._session.app.before_render.add_handler(_capture_prompt_frame) + self._session.app.after_render.add_handler(self._clear_prompt_frame_snapshot) + # Throttle redraws so the fast streaming-reveal cadence can't overwhelm # slower terminals (best practice for "invalidate is called a lot"). # prompt_toolkit's renderer is already differential (only emits changed @@ -3061,9 +3122,22 @@ def _render_message(self) -> FormattedText: return self._render_agent_prompt_message() def _render_shell_prompt_message(self) -> FormattedText: - app = get_app_or_none() - size = app.output.get_size() if app is not None else None - columns = size.columns if size is not None else 80 + frame = self._prompt_frame_for_render() + columns = frame.columns + # Shell mode has no running-prompt scene allocator, so the footer keeps + # its natural height within the terminal. Refresh the budget every render + # (not just on the agent path) so a mode switch or resize cannot leave + # _fit_toolbar_to_terminal clipping against a stale agent-mode value. + self._prompt_footer_row_budget = frame.terminal_rows + # Snapshot the update notice for this frame too. The agent path caches it + # in _render_agent_prompt_message; without the same refresh here, + # _append_update_notice would replay a stale agent-mode notice (or the + # initial None, suppressing a live notice) once a frame is captured. + provider = cast( + Callable[[], str | None] | None, + getattr(self, "_update_notice_provider", None), + ) + self._prompt_frame_update_notice = provider() if callable(provider) else None fragments: FormattedText = FormattedText() if getattr(self, "_shortcut_help_open", False): @@ -3073,27 +3147,27 @@ def _render_shell_prompt_message(self) -> FormattedText: # Dynamic preamble (agent status + modal/interactive body). Keep it # within the visible terminal area so it cannot overlap the input/footer. preamble: FormattedText = FormattedText() - agent_status = self._render_agent_status(columns) + agent_status = self._render_agent_status(frame.agent_status) if agent_status: preamble.extend(agent_status) ensure_prompt_newline(preamble) - body = self._render_interactive_body(columns) + body = self._render_interactive_body(frame.interactive_body) if body: preamble.extend(body) ensure_prompt_newline(preamble) - pinned = self._render_pinned_status_tail(columns) + pinned = self._render_pinned_status_tail(frame.pinned_tail) if preamble or pinned: preamble = self._fit_preamble_with_pinned_tail( preamble, pinned, columns, - _prompt_preamble_max_rows(getattr(size, "rows", None)), + _prompt_preamble_max_rows(frame.terminal_rows), ) fragments.extend(preamble) - if self._active_modal_delegate() is not None: + if frame.modal_active: return fragments if is_card_style(): ensure_prompt_newline(fragments) @@ -3184,6 +3258,43 @@ def _active_prompt_delegate(self) -> RunningPromptDelegate | None: return delegate return getattr(self, "_running_prompt_delegate", None) + def _make_prompt_frame_collector(self) -> PromptFrameCollector: + return PromptFrameCollector( + resolve_modal=self._active_modal_delegate, + resolve_running=lambda: getattr(self, "_running_prompt_delegate", None), + render_background_status=self._render_background_working_status, + render_status_block=self._render_status_block, + input_is_empty=lambda: ( + not getattr( + getattr(getattr(self, "_session", None), "default_buffer", None), "text", "" + ) + ), + turn_is_starting=lambda: getattr(self, "_turn_starting", False), + ) + + def _clear_prompt_frame_snapshot(self, _app: Application[str] | None = None) -> None: + """after_render handler: drop the captured frame and its per-frame update + notice together, so a later render can never read a snapshot captured for a + stale frame/mode.""" + self._current_prompt_frame = None + self._prompt_frame_update_notice = None + + def _prompt_frame_for_render(self, *, columns: int | None = None) -> PromptFrame: + current = getattr(self, "_current_prompt_frame", None) + if current is not None: + return current + app = get_app_or_none() + size = app.output.get_size() if app is not None else None + frame_columns = ( + columns if columns is not None else (size.columns if size is not None else 80) + ) + terminal_rows = getattr(size, "rows", 24) if size is not None else 24 + collector = getattr(self, "_prompt_frame_collector", None) + if collector is None: + collector = self._make_prompt_frame_collector() + self._prompt_frame_collector = collector + return collector.capture(columns=frame_columns, terminal_rows=terminal_rows) + def _active_ui_state(self) -> PromptUIState: delegate = self._active_modal_delegate() if delegate is None: @@ -3194,7 +3305,7 @@ def _active_ui_state(self) -> PromptUIState: return PromptUIState.MODAL_TEXT_INPUT return PromptUIState.NORMAL_INPUT - def _input_card_hidden_pre_stream(self) -> bool: + def _input_card_hidden_pre_stream(self, captured: bool | None = None) -> bool: """Gate the empty pre-stream input surface until the first commit. Most running frames keep the input card visible. The only exception is @@ -3204,6 +3315,8 @@ def _input_card_hidden_pre_stream(self) -> bool: Skipped when the user has typed (non-empty buffer) or a modal owns the input line. """ + if captured is not None: + return captured if self._active_modal_delegate() is not None: return False # Direct attribute access (not getattr-with-default): these are set in @@ -3289,52 +3402,151 @@ def _sync_prompt_ui_state(self) -> None: self._last_ui_state = new_state def _render_agent_prompt_message(self) -> FormattedText: - app = get_app_or_none() - size = app.output.get_size() if app is not None else None - columns = size.columns if size is not None else 80 + frame = self._prompt_frame_for_render() + columns = frame.columns fragments: FormattedText = FormattedText() + provider = cast( + Callable[[], str | None] | None, + getattr(self, "_update_notice_provider", None), + ) + update_notice = provider() if callable(provider) else None + self._prompt_frame_update_notice = update_notice + footer_rows = 3 + (1 if update_notice else 0) + + def finish(scene: FormattedText, *, input_rows: int) -> FormattedText: + scene_rows = len(_formatted_text_display_rows(scene, columns)) + if scene_rows + footer_rows <= frame.terminal_rows: + self._prompt_footer_row_budget = footer_rows + return scene + has_content_before_pinned = bool(body_rows) or bool( + agent_status and any(text for _, text, *_ in agent_status) + ) + allocation = allocate_prompt_scene_rows( + PromptSceneBudget(terminal_rows=frame.terminal_rows), + modal_rows=body_rows if modal_active else 0, + input_rows=input_rows, + footer_rows=footer_rows, + pinned_rows=pinned_rows, + separator_rows=( + 1 if not modal_active and pinned_rows and has_content_before_pinned else 0 + ), + body_rows=0 if modal_active else body_rows, + status_rows=status_rows, + shortcut_rows=( + len(_formatted_text_display_rows(self._render_shortcut_help(columns), columns)) + if getattr(self, "_shortcut_help_open", False) and not modal_active + else 0 + ), + ) + self._prompt_footer_row_budget = allocation.footer_rows + if modal_active: + modal_scene = FormattedText() + if allocation.status_rows: + modal_scene.extend( + _fit_prompt_scene_to_rows( + agent_status, + columns, + allocation.status_rows, + ) + ) + ensure_prompt_newline(modal_scene) + if allocation.modal_rows: + modal_scene.extend( + _fit_prompt_scene_to_rows(body, columns, allocation.modal_rows) + ) + if allocation.pinned_rows: + ensure_prompt_newline(modal_scene) + modal_scene.extend( + _fit_prompt_scene_to_rows( + pinned, + columns, + allocation.pinned_rows, + show_clip_hint=False, + ) + ) + return _fit_prompt_scene_to_rows( + modal_scene, + columns, + allocation.prompt_rows, + ) + + scene_rows = _formatted_text_display_rows(scene, columns) + input_region = FormattedText() + if allocation.input_rows: + _extend_rows(input_region, scene_rows[-input_rows:]) + + overflow_scene = FormattedText() + + def append_region(region: FormattedText, rows: int) -> None: + if rows <= 0: + return + fitted = _fit_prompt_scene_to_rows( + region, + columns, + rows, + show_clip_hint=False, + drop_blank_rows=True, + ) + if not fitted: + return + if overflow_scene: + ensure_prompt_newline(overflow_scene) + overflow_scene.extend(fitted) + + if allocation.shortcut_rows: + append_region(self._render_shortcut_help(columns), allocation.shortcut_rows) + append_region(agent_status, allocation.status_rows) + append_region(body, allocation.body_rows) + if ( + allocation.separator_rows + and allocation.pinned_rows + and (allocation.body_rows or allocation.status_rows) + ): + ensure_prompt_newline(overflow_scene) + overflow_scene.append(("", "\n")) + append_region(pinned, allocation.pinned_rows) + append_region(input_region, allocation.input_rows) + return _fit_prompt_scene_to_rows( + overflow_scene, + columns, + allocation.prompt_rows, + show_clip_hint=False, + ) + # 1–2. Dynamic preamble — agent status is always rendered from the # running prompt delegate, and body comes from the active modal/delegate. # Cap the visible rows so large cards do not overwrite the input/footer. # When a modal is active, preserve the whole modal body and clip older # agent status above it first; approval/question controls must remain usable. - agent_status = self._render_agent_status(columns) - body = self._render_interactive_body(columns) - pinned = self._render_pinned_status_tail(columns) + agent_status = self._render_agent_status(frame.agent_status) + body = self._render_interactive_body(frame.interactive_body) + pinned = self._render_pinned_status_tail(frame.pinned_tail) body_rows = ( len(_formatted_text_display_rows(body, columns)) if body and any(fragment for _, fragment, *_ in body) else 0 ) + status_rows = ( + len(_formatted_text_display_rows(agent_status, columns)) + if agent_status and any(fragment for _, fragment, *_ in agent_status) + else 0 + ) pinned_rows = ( len(_formatted_text_display_rows(pinned, columns)) if pinned and any(fragment for _, fragment, *_ in pinned) else 0 ) - max_rows = _prompt_preamble_max_rows(getattr(size, "rows", None)) - modal_active = self._active_modal_delegate() is not None + max_rows = _prompt_preamble_max_rows(frame.terminal_rows) + modal_active = frame.modal_active if getattr(self, "_shortcut_help_open", False) and not modal_active: fragments.extend(self._render_shortcut_help(columns)) ensure_prompt_newline(fragments) - running_prompt_delegate = getattr(self, "_running_prompt_delegate", None) - if not modal_active and running_prompt_delegate is not None and is_card_style(): - input_card_hidden = self._input_card_hidden_pre_stream() - render_running_body_attr = getattr( - running_prompt_delegate, "render_running_prompt_body", None - ) - render_running_body = ( - cast(Callable[[int], AnyFormattedText], render_running_body_attr) - if callable(render_running_body_attr) - else None - ) - running_body = ( - to_formatted_text(render_running_body(columns)) - if render_running_body is not None - else FormattedText() - ) + if not modal_active and frame.running_prompt_active and is_card_style(): + input_card_hidden = self._input_card_hidden_pre_stream(frame.input_card_hidden) + running_body = body preamble = FormattedText() if agent_status and any(text for _, text, *_ in agent_status): preamble.extend(agent_status) @@ -3352,14 +3564,8 @@ def _render_agent_prompt_message(self) -> FormattedText: if preamble and any(text for _, text, *_ in preamble): fragments.extend(preamble) - if input_card_hidden: - hide_chrome = getattr( - running_prompt_delegate, - "running_prompt_hide_input_card_chrome", - lambda: False, - )() - if hide_chrome: - return fragments + if input_card_hidden and self._input_card_chrome_hidden(frame.input_chrome_hidden): + return finish(fragments, input_rows=0) tc = get_toolbar_colors() scene_fragments: FormattedText = FormattedText() @@ -3367,20 +3573,11 @@ def _render_agent_prompt_message(self) -> FormattedText: scene_fragments.extend(fragments) ensure_prompt_newline(scene_fragments) - render_placeholder_attr = getattr( - running_prompt_delegate, "running_prompt_placeholder", None - ) - render_placeholder = ( - cast(Callable[[], AnyFormattedText | None], render_placeholder_attr) - if callable(render_placeholder_attr) - else None - ) - placeholder_value: AnyFormattedText | None = ( - render_placeholder() - if not input_card_hidden and render_placeholder is not None + placeholder_fragments = ( + self._render_running_prompt_placeholder(frame.placeholder) + if not input_card_hidden else FormattedText() ) - placeholder_fragments = to_formatted_text(placeholder_value) scene_fragments.extend(self._render_input_top_border(columns, tc.separator)) scene_fragments.append(("", "\n")) @@ -3390,7 +3587,7 @@ def _render_agent_prompt_message(self) -> FormattedText: ) if placeholder_fragments: scene_fragments.extend(placeholder_fragments) - return scene_fragments + return finish(scene_fragments, input_rows=2) if modal_active and body: status_budget = max(0, max_rows - body_rows - pinned_rows) @@ -3427,23 +3624,14 @@ def _render_agent_prompt_message(self) -> FormattedText: # 3. When a modal is active, skip the normal input chrome. if modal_active: - return fragments + return finish(fragments, input_rows=0) # Hide editable input content during the narrow pre-stream/first-handoff # frame, but keep the empty card chrome visible so the prompt bar does not # disappear while the agent is loading. - if self._input_card_hidden_pre_stream(): - running_prompt_delegate = getattr(self, "_running_prompt_delegate", None) - hide_chrome = ( - running_prompt_delegate is not None - and getattr( - running_prompt_delegate, - "running_prompt_hide_input_card_chrome", - lambda: False, - )() - ) - if hide_chrome: - return fragments + if self._input_card_hidden_pre_stream(frame.input_card_hidden): + if self._input_card_chrome_hidden(frame.input_chrome_hidden): + return finish(fragments, input_rows=0) if is_card_style(): ensure_prompt_newline(fragments) tc = get_toolbar_colors() @@ -3455,7 +3643,7 @@ def _render_agent_prompt_message(self) -> FormattedText: fragments.append( (self._thinking_prompt_prefix_style(), f"{PROMPT_SYMBOL_AGENT_INPUT} ") ) - return fragments + return finish(fragments, input_rows=2 if is_card_style() else 1) if is_card_style(): ensure_prompt_newline(fragments) @@ -3466,7 +3654,7 @@ def _render_agent_prompt_message(self) -> FormattedText: else: fragments.append(("", "\n")) fragments.append((self._thinking_prompt_prefix_style(), f"{PROMPT_SYMBOL_AGENT_INPUT} ")) - return fragments + return finish(fragments, input_rows=2 if is_card_style() else 1) def _render_shortcut_help(self, columns: int) -> FormattedText: """Render a small keyboard-shortcuts popup above the prompt.""" @@ -3525,18 +3713,26 @@ def _render_shortcut_help(self, columns: int) -> FormattedText: fragments.append((tc.separator, f"╰{border}╯")) return fragments - def _render_agent_status(self, columns: int) -> FormattedText: - """Render agent streaming output (always visible, independent of modals).""" - running = self._running_prompt_delegate + def _render_agent_status(self, captured: int | FrozenFragments) -> FormattedText: + """Render captured agent output without consulting the pinned-tail provider.""" + if not isinstance(captured, int): + return FormattedText(list(captured)) + + columns = captured + running = getattr(self, "_running_prompt_delegate", None) + pinned_active = False + if running is not None and isinstance(running, PinnedStatusTailProvider): + pinned = to_formatted_text(running.render_pinned_status_tail(columns)) + pinned_active = any(text for _, text, *_ in pinned) if running is not None and isinstance(running, AgentStatusProvider): rendered = to_formatted_text(running.render_agent_status(columns)) - if any(fragment for _, fragment, *_ in rendered): + if any(text for _, text, *_ in rendered): # A blocking foreground TaskOutput card can be visible while the # actual background agent is still running. If the live view does # not expose a pinned tail for that state, keep the background # verb spinner visible above the prompt instead of showing only # the footer count. - if not self._render_pinned_status_tail(columns): + if not pinned_active: background = self._render_background_working_status(columns) if background: ensure_prompt_newline(rendered) @@ -3551,7 +3747,6 @@ def _render_agent_status(self, columns: int) -> FormattedText: # An in-flight turn pins its own working indicator (the verb spinner) # and the bottom toolbar already reports background work — rendering a # count line here too would duplicate it under the executing step. - pinned_active = bool(self._render_pinned_status_tail(columns)) fragments = ( FormattedText([]) if pinned_active else self._render_background_working_status(columns) ) @@ -3561,12 +3756,14 @@ def _render_agent_status(self, columns: int) -> FormattedText: fragments.extend(status) return fragments - def _render_pinned_status_tail(self, columns: int) -> FormattedText: - """Trailing verb spinner that stays pinned below a clipped agent stream.""" - running = self._running_prompt_delegate + def _render_pinned_status_tail(self, captured: int | FrozenFragments) -> FormattedText: + """Render the captured trailing status tail.""" + if not isinstance(captured, int): + return FormattedText(list(captured)) + running = getattr(self, "_running_prompt_delegate", None) if running is not None and isinstance(running, PinnedStatusTailProvider): - rendered = to_formatted_text(running.render_pinned_status_tail(columns)) - if any(fragment for _, fragment, *_ in rendered): + rendered = to_formatted_text(running.render_pinned_status_tail(captured)) + if any(text for _, text, *_ in rendered): return rendered return FormattedText() @@ -3581,25 +3778,45 @@ def _fit_preamble_with_pinned_tail( (the verb spinner) below it, so the clip hint never covers the spinner. """ if not (pinned and any(fragment for _, fragment, *_ in pinned)): - # No separate pinned tail: keep the old behavior of preserving the - # last status row so delegates that don't split stay correct. - return _fit_formatted_text_to_rows(preamble, columns, max_rows, preserve_tail_rows=1) - pinned_rows = len(_formatted_text_display_rows(pinned, columns)) - body_budget = max(1, max_rows - pinned_rows) - clipped = _fit_formatted_text_to_rows(preamble, columns, body_budget) + # Status is composed before the current live body. Tail clipping + # therefore drops older status first and keeps the newest live row. + return _fit_prompt_scene_to_rows(preamble, columns, max_rows) out: FormattedText = FormattedText() - out.extend(clipped) - ensure_prompt_newline(out) - # Keep the pinned verb spinner visually separated from preceding tool - # output/background summaries; when it is the first visible row, this - # also creates the initial breathing room above the spinner. - out.append(("", "\n")) - out.extend(pinned) - # Mirror the breathing room below the pinned tail so the todo list / verb - # spinner is never flush against the prompt separator beneath it. - ensure_prompt_newline(out) - out.append(("", "\n")) - return out + if max_rows <= 0: + return out + original: FormattedText = FormattedText() + original.extend(preamble) + ensure_prompt_newline(original) + original.append(("", "\n")) + original.extend(pinned) + ensure_prompt_newline(original) + original.append(("", "\n")) + if len(_formatted_text_display_rows(original, columns)) <= max_rows: + return original + + pinned_rows = _formatted_text_display_rows(pinned, columns) + visible_pinned_rows = pinned_rows[-max_rows:] + pinned_budget = len(visible_pinned_rows) + separator_budget = 1 if preamble and max_rows > pinned_budget else 0 + body_budget = max(0, max_rows - pinned_budget - separator_budget) + if body_budget: + out.extend( + _fit_prompt_scene_to_rows( + preamble, + columns, + body_budget, + show_clip_hint=body_budget >= 2, + drop_blank_rows=True, + ) + ) + ensure_prompt_newline(out) + if separator_budget: + out.append(("", "\n")) + if pinned_budget: + pinned_out = FormattedText() + _extend_rows(pinned_out, visible_pinned_rows) + out.extend(pinned_out) + return _fit_prompt_scene_to_rows(out, columns, max_rows) def update_pinned_todos(self, items: Sequence[TodoDisplayItem]) -> None: """Remember the latest agent todo list for between-turn background waits.""" @@ -3785,12 +4002,22 @@ def _bg_refresh_active(self) -> bool: return True return time.monotonic() - last_active < _BG_QUIET_THRESHOLD_S - def _render_interactive_body(self, columns: int) -> FormattedText: - """Render the interactive area from the active delegate (modal or running prompt).""" + def _render_interactive_body(self, captured: int | FrozenFragments) -> FormattedText: + """Render the interactive area captured from the active delegate.""" + if not isinstance(captured, int): + return FormattedText(list(captured)) delegate = self._active_prompt_delegate() if delegate is None: return FormattedText([]) - return to_formatted_text(delegate.render_running_prompt_body(columns)) + return to_formatted_text(delegate.render_running_prompt_body(captured)) + + @staticmethod + def _render_running_prompt_placeholder(captured: FrozenFragments) -> FormattedText: + return FormattedText(list(captured)) + + @staticmethod + def _input_card_chrome_hidden(captured: bool) -> bool: + return captured def _render_status_block(self, columns: int) -> FormattedText: status_block_provider = getattr(self, "_status_block_provider", None) @@ -4138,10 +4365,16 @@ def _append_update_notice(self, fragments: list[tuple[str, str]], columns: int) none) and adds no trailing newline, so it never leaves a blank row at the bottom. No-op when no update is pending; style-agnostic across both toolbar layouts.""" - provider = getattr(self, "_update_notice_provider", None) - if provider is None: - return - text = provider() + if getattr(self, "_current_prompt_frame", None) is not None and hasattr( + self, "_prompt_frame_update_notice" + ): + text = self._prompt_frame_update_notice + else: + provider = cast( + Callable[[], str | None] | None, + getattr(self, "_update_notice_provider", None), + ) + text = provider() if callable(provider) else None if not text: return line = _truncate_right(text, max(0, columns - 1)) @@ -4151,6 +4384,20 @@ def _append_update_notice(self, fragments: list[tuple[str, str]], columns: int) style = f"fg:{tokens.warning or 'ansiyellow'} bold" fragments.extend([("", "\n"), (style, line)]) + def _fit_toolbar_to_terminal(self, fragments: FormattedText, columns: int) -> FormattedText: + app = get_app_or_none() + size = app.output.get_size() if app is not None else None + rows = getattr(size, "rows", None) + if not isinstance(rows, int) or rows <= 0: + return fragments + max_rows = min(rows, max(0, getattr(self, "_prompt_footer_row_budget", rows))) + return _fit_prompt_scene_to_rows( + fragments, + columns, + max_rows, + show_clip_hint=False, + ) + def _render_bottom_toolbar(self) -> FormattedText: if ( hasattr(self, "_session") @@ -4295,7 +4542,7 @@ def _render_bottom_toolbar(self) -> FormattedText: fragments.append((secondary_style, right_text)) self._append_update_notice(fragments, columns) - return FormattedText(fragments) + return self._fit_toolbar_to_terminal(FormattedText(fragments), columns) def _build_statusline_context(self, columns: int) -> StatusLineContext: from pythinker_code.ui.shell.statusline import ( @@ -4469,7 +4716,7 @@ def _render_card_bottom_toolbar(self, columns: int) -> FormattedText: fragments.append(("", " " * max(0, usable - left_width - right_width))) fragments.extend(line2_right) self._append_update_notice(fragments, columns) - return FormattedText(fragments) + return self._fit_toolbar_to_terminal(FormattedText(fragments), columns) def _get_two_rotating_tips(self) -> str | None: """Return a string with exactly 2 tips from the rotation, or fewer if not enough.""" diff --git a/src/pythinker_code/ui/shell/prompting/__init__.py b/src/pythinker_code/ui/shell/prompting/__init__.py new file mode 100644 index 00000000..f62b473c --- /dev/null +++ b/src/pythinker_code/ui/shell/prompting/__init__.py @@ -0,0 +1,23 @@ +"""Immutable prompt-render frame capture.""" + +from pythinker_code.ui.shell.prompting.frame import ( + FrozenFragments, + PromptFrame, + PromptFrameCollector, + freeze_fragments, +) +from pythinker_code.ui.shell.prompting.renderer import ( + PromptSceneAllocation, + PromptSceneBudget, + allocate_prompt_scene_rows, +) + +__all__ = ( + "FrozenFragments", + "PromptFrame", + "PromptFrameCollector", + "PromptSceneAllocation", + "PromptSceneBudget", + "allocate_prompt_scene_rows", + "freeze_fragments", +) diff --git a/src/pythinker_code/ui/shell/prompting/frame.py b/src/pythinker_code/ui/shell/prompting/frame.py new file mode 100644 index 00000000..5fa8e0c2 --- /dev/null +++ b/src/pythinker_code/ui/shell/prompting/frame.py @@ -0,0 +1,147 @@ +"""Capture dynamic prompt delegates once for a prompt_toolkit render frame.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, cast + +from prompt_toolkit.formatted_text import ( + AnyFormattedText, + FormattedText, + OneStyleAndTextTuple, + to_formatted_text, +) + +from pythinker_code.ui.shell.prompting.renderer import PromptSceneBudget + +FrozenFragments = tuple[OneStyleAndTextTuple, ...] + + +def freeze_fragments(value: AnyFormattedText | None) -> FrozenFragments: + """Normalize formatted text into an immutable frame-owned tuple.""" + return tuple(to_formatted_text(value or FormattedText())) + + +def _has_text(fragments: FrozenFragments) -> bool: + return any(text for _, text, *_ in fragments) + + +def _with_newline(fragments: FrozenFragments) -> FrozenFragments: + if not fragments or fragments[-1][1].endswith("\n"): + return fragments + return (*fragments, ("", "\n")) + + +@dataclass(frozen=True, slots=True) +class PromptFrame: + """All dynamic values used to render one prompt frame.""" + + columns: int + terminal_rows: int + body_rows: int + agent_status: FrozenFragments + interactive_body: FrozenFragments + pinned_tail: FrozenFragments + placeholder: FrozenFragments + input_card_hidden: bool + input_chrome_hidden: bool + modal_active: bool + running_prompt_active: bool + + +class PromptFrameCollector: + """Resolve delegates and sample their render-facing methods once per frame.""" + + def __init__( + self, + *, + resolve_modal: Callable[[], Any], + resolve_running: Callable[[], Any], + render_background_status: Callable[[int], AnyFormattedText], + render_status_block: Callable[[int], AnyFormattedText], + input_is_empty: Callable[[], bool], + turn_is_starting: Callable[[], bool], + ) -> None: + self._resolve_modal = resolve_modal + self._resolve_running = resolve_running + self._render_background_status = render_background_status + self._render_status_block = render_status_block + self._input_is_empty = input_is_empty + self._turn_is_starting = turn_is_starting + + def capture(self, *, columns: int, terminal_rows: int) -> PromptFrame: + """Capture every render-facing delegate value exactly once.""" + modal = self._resolve_modal() + running = self._resolve_running() + body_rows = PromptSceneBudget( + terminal_rows=terminal_rows, + input_rows=0 if modal is not None else 2, + ).preamble_rows + + pinned_method = cast( + Callable[[int], AnyFormattedText] | None, + getattr(running, "render_pinned_status_tail", None), + ) + pinned = freeze_fragments(pinned_method(columns) if callable(pinned_method) else None) + + status_method = cast( + Callable[[int], AnyFormattedText] | None, + getattr(running, "render_agent_status", None), + ) + rendered_status = freeze_fragments( + status_method(columns) if callable(status_method) else None + ) + if _has_text(rendered_status): + if not _has_text(pinned): + background = freeze_fragments(self._render_background_status(columns)) + if _has_text(background): + rendered_status = (*_with_newline(rendered_status), *background) + agent_status = (*_with_newline(rendered_status), ("", "\n")) + else: + background: FrozenFragments = ( + () + if _has_text(pinned) + else freeze_fragments(self._render_background_status(columns)) + ) + status_block = freeze_fragments(self._render_status_block(columns)) + agent_status = background + if _has_text(status_block): + agent_status = (*_with_newline(agent_status), *status_block) + + active = modal if modal is not None else running + body_method = cast( + Callable[[int], AnyFormattedText] | None, + getattr(active, "render_running_prompt_body", None), + ) + interactive_body = freeze_fragments(body_method(columns) if callable(body_method) else None) + + placeholder_method = cast( + Callable[[], AnyFormattedText | None] | None, + getattr(running, "running_prompt_placeholder", None), + ) + placeholder = freeze_fragments( + placeholder_method() if callable(placeholder_method) else None + ) + hide_card_method = getattr(running, "running_prompt_hide_input_card", None) + hide_card = bool(hide_card_method()) if callable(hide_card_method) else False + hide_chrome_method = getattr(running, "running_prompt_hide_input_card_chrome", None) + hide_chrome = bool(hide_chrome_method()) if callable(hide_chrome_method) else False + modal_active = modal is not None + input_card_hidden = ( + not modal_active and (self._turn_is_starting() or hide_card) and self._input_is_empty() + ) + + return PromptFrame( + columns=columns, + terminal_rows=terminal_rows, + body_rows=body_rows, + agent_status=agent_status, + interactive_body=interactive_body, + pinned_tail=pinned, + placeholder=placeholder, + input_card_hidden=input_card_hidden, + input_chrome_hidden=hide_chrome, + modal_active=modal_active, + running_prompt_active=running is not None, + ) diff --git a/src/pythinker_code/ui/shell/prompting/renderer.py b/src/pythinker_code/ui/shell/prompting/renderer.py new file mode 100644 index 00000000..7146154c --- /dev/null +++ b/src/pythinker_code/ui/shell/prompting/renderer.py @@ -0,0 +1,88 @@ +"""Terminal-row budgeting for prompt scenes.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PromptSceneBudget: + """Rows available to the dynamic preamble after higher-priority surfaces.""" + + terminal_rows: int + input_rows: int = 2 + footer_rows: int = 3 + menu_rows: int = 0 + safety_rows: int = 0 + + @property + def preamble_rows(self) -> int: + reserved = ( + max(0, self.input_rows) + + max(0, self.footer_rows) + + max(0, self.menu_rows) + + max(0, self.safety_rows) + ) + return max(0, self.terminal_rows - reserved) + + +@dataclass(frozen=True) +class PromptSceneAllocation: + """Row allowances in the renderer's deterministic overflow priority order.""" + + modal_rows: int = 0 + input_rows: int = 0 + footer_rows: int = 0 + pinned_rows: int = 0 + separator_rows: int = 0 + body_rows: int = 0 + status_rows: int = 0 + shortcut_rows: int = 0 + + @property + def prompt_rows(self) -> int: + return ( + self.modal_rows + + self.input_rows + + self.pinned_rows + + self.separator_rows + + self.body_rows + + self.status_rows + + self.shortcut_rows + ) + + +def allocate_prompt_scene_rows( + budget: PromptSceneBudget, + *, + modal_rows: int = 0, + input_rows: int = 0, + footer_rows: int = 0, + pinned_rows: int = 0, + separator_rows: int = 0, + body_rows: int = 0, + status_rows: int = 0, + shortcut_rows: int = 0, +) -> PromptSceneAllocation: + """Allocate rows by surface priority without ever exceeding the terminal.""" + remaining = max( + 0, + budget.terminal_rows - max(0, budget.menu_rows) - max(0, budget.safety_rows), + ) + + def take(requested: int) -> int: + nonlocal remaining + allocated = min(remaining, max(0, requested)) + remaining -= allocated + return allocated + + return PromptSceneAllocation( + modal_rows=take(modal_rows), + input_rows=take(input_rows), + footer_rows=take(footer_rows), + pinned_rows=take(pinned_rows), + separator_rows=take(separator_rows), + body_rows=take(body_rows), + status_rows=take(status_rows), + shortcut_rows=take(shortcut_rows), + ) diff --git a/src/pythinker_code/ui/shell/visualize/_interactive.py b/src/pythinker_code/ui/shell/visualize/_interactive.py index 57ef78da..9524438e 100644 --- a/src/pythinker_code/ui/shell/visualize/_interactive.py +++ b/src/pythinker_code/ui/shell/visualize/_interactive.py @@ -887,17 +887,12 @@ def render_agent_status(self, columns: int) -> ANSI: # transient body for the duration of the handoff. if self._suppress_transient_preamble: return ANSI("") - from prompt_toolkit.application import get_app_or_none - - from pythinker_code.ui.shell.prompt import _prompt_preamble_max_rows - - app = get_app_or_none() - terminal_rows = app.output.get_size().rows if app is not None else None - # Reserve one row for the pinned verb spinner rendered below the clip hint. - body_budget = max(1, _prompt_preamble_max_rows(terminal_rows) - 1) content_block = getattr(self, "_current_content_block", None) if content_block is not None: - content_block.set_preview_row_budget(body_budget) + # The prompt renderer owns the single authoritative scene allocation. + # Pre-clipping here would spend the same rows a second time before + # pinned/status regions have been measured. + content_block.set_preview_row_budget(None) # Exclude activity rows here — the prompt pins the active spinner # separately via ``render_pinned_status_tail`` so a clipped agent stream # cannot hide it or place it between committed prose and the live tail. diff --git a/tasks/lessons.md b/tasks/lessons.md index b1b12d44..3fa58370 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -208,3 +208,48 @@ Format: trigger → rule. - **When the pipeline's fix stage edits code after clean-room verification**, expect a formatting/import-sort defect in the final tree; run the repo formatter on fixer-touched files after integration and re-run the gate before committing. + +## Uncommitted working-tree cruft can be swept into a feature commit + +- **Trigger:** starting feature work while the repo has unstaged, unrelated in-progress + changes (here: a prompt_toolkit screen-mode refactor across `prompt.py`, a `config.py` + docstring, and three prompt tests). A `git add -A` / broad commit silently captured the + *test* half of that feature into an auth commit, while the *source* half got reverted — + leaving tests ahead of source and 4 failures that looked like an auth regression. +- **Rule:** before the first commit on a feature branch, run `git status` and, for any file + outside the task's scope, diff it against `origin/main`. Commit only scoped paths + (`git add `), never a blind `git add -A`, when the tree isn't clean. +- **Recovery:** when a test fails on a file the PR should not touch, check + `git diff origin/main -- ` and `git log origin/main..HEAD -- `. If a + non-scope file was captured, restore the whole feature (source *and* tests) to + `origin/main` so the PR carries only its intended change. +- **Verification trap:** `make ... | tail -N` reports `tail`'s exit code (0), not `make`'s. + Redirect to a file and check `$?` with `set -o pipefail`, or the real failure hides. + +## Codex-lane operating contract (TUI refactor delegations, 2026-07-19) + +- **Producer sandbox rules that void candidates** (each cost one 10-25-min run): + no `rm`/deletions (rejection kills structured output → `invalid-output`); no git + commands (index writes denied — leave edits uncommitted, the runtime snapshots the + tree); no repo copies or new top-level dirs (freeze `out-of-scope-write`); + `src/pythinker_code/CHANGELOG.md` is a tracked symlink — any touch is + `modified-symlink`; pytest MUST run with `--basetemp=.venv/pytest-tmp` or + `pytest-of-*` root dirs void the freeze. +- **Producer env:** shared uv cache is sandbox-inaccessible; first action + `UV_CACHE_DIR=.venv/.uv-cache uv sync --all-packages --all-groups` (offline + fallback works). A producer that cannot run tests flies blind and rewrites + behavior — always give it a working test recipe and require a passing focused + run before finish. +- **Byte-identical constraint must be structural:** when a refactor must not change + fitting-scene output, put the untouched-path short-circuit in the spec ("return + unchanged unless overflow") and freeze the legacy behavior test files in + `forbiddenScope` so the producer cannot adjust them to pass. +- **PTY e2e tests are cold-worktree-flaky** (fail at clean baseline in disposable + worktrees, pass in the main checkout): never use them as clean-room gates; + verify locally at integration instead. +- **`/reload-plugins` kills in-flight MCP delegations** ("Connection closed"); + concurrent delegations from other sessions can also break the shared runtime + ("git status output exceeded the runtime bound") — serialize sessions' delegations + and don't reload plugins mid-run. +- **Recover a killed run's spec** from the session transcript jsonl (`tool_use` + input of the last `delegatePipeline` call) — `run-start.json` does not persist it. diff --git a/tests/e2e/test_shell_pty_prompt_layout_e2e.py b/tests/e2e/test_shell_pty_prompt_layout_e2e.py index 18587c22..1bfaf21e 100644 --- a/tests/e2e/test_shell_pty_prompt_layout_e2e.py +++ b/tests/e2e/test_shell_pty_prompt_layout_e2e.py @@ -30,6 +30,7 @@ import pytest from tests.e2e.shell_pty_helpers import ( + _set_window_size, list_turn_begin_inputs, make_home_dir, make_work_dir, @@ -221,6 +222,70 @@ def test_input_card_stays_visible_during_initial_loading_and_mid_turn(tmp_path: shell.close() +def _render_sized(chunks: list[bytes], columns: int, rows: int) -> list[str]: + screen = pyte.Screen(columns, rows) + stream = pyte.ByteStream(screen) + stream.feed(b"".join(chunks)) + return [line.rstrip() for line in screen.display] + + +def test_prompt_scene_survives_shrinking_terminal_heights(tmp_path: Path) -> None: + """Mid-turn resizes down to tiny heights never crash or fossilize the card. + + Resizes the live PTY through heights 12 → 8 → 6 → 4 while a slow tool keeps + the running prompt on screen. prompt_toolkit fully redraws on SIGWINCH, so + each post-resize frame is rendered from only the bytes emitted after that + resize, on a pyte screen of the new geometry. After restoring the original + size, the turn must still complete and the idle input card must return. + """ + slow = {"id": "r1", "name": "Shell", "arguments": json.dumps({"command": "sleep 6"})} + config_path = write_scripted_config( + tmp_path, + [f"tool_call: {json.dumps(slow)}", "text: Resize turn finished."], + capabilities=["thinking"], + ) + work_dir = make_work_dir(tmp_path) + home_dir = make_home_dir(tmp_path) + shell = start_shell_pty( + config_path=config_path, + work_dir=work_dir, + home_dir=home_dir, + yolo=True, + columns=_COLS, + lines=_ROWS, + ) + try: + shell.read_until_contains("think first, then code") + read_until_prompt_ready(shell, after=shell.mark()) + shell.send_line(_PROMPT_TEXT) + shell.read_until_contains("Bash(sleep 6", timeout=15.0) + + for height in (12, 8, 6, 4): + resize_chunk_start = len(shell._raw_chunks) + _set_window_size(shell.master_fd, columns=_COLS, lines=height) + deadline = time.monotonic() + 2.5 + while time.monotonic() < deadline: + shell.read_available(timeout=0.08) + assert shell.process.poll() is None, f"shell died after resize to {height} rows" + post_resize = shell._raw_chunks[resize_chunk_start:] + if post_resize: + # pyte always yields exactly `height` lines, so assert observable + # behavior instead: the redraw never wraps a row past the terminal + # width and never fossilizes an input-card border above content. + rows = _render_sized(post_resize, _COLS, height) + assert all(len(row) <= _COLS for row in rows) + assert not _has_fossil_border_above_content(rows) + + _set_window_size(shell.master_fd, columns=_COLS, lines=_ROWS) + shell.read_until_contains("Resize turn finished.", timeout=20.0) + shell.wait_for_quiet(timeout=6.0, quiet_period=0.3) + assert any(_is_input_card_border(r) for r in _render(shell._raw_chunks)), ( + "idle input-card border did not return after the resize sequence" + ) + finally: + shell.close() + + def test_mid_turn_queued_input_renders_once_and_executes_once(tmp_path: Path) -> None: slow = { "id": "queued-slow", diff --git a/tests/ui_and_conv/test_prompt_height_budget.py b/tests/ui_and_conv/test_prompt_height_budget.py new file mode 100644 index 00000000..18ea685a --- /dev/null +++ b/tests/ui_and_conv/test_prompt_height_budget.py @@ -0,0 +1,480 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Literal + +import pytest +from prompt_toolkit.formatted_text import FormattedText + +from pythinker_code.ui.shell import prompt as shell_prompt +from pythinker_code.ui.shell.prompt import CustomPromptSession, PromptMode +from pythinker_code.ui.shell.prompting import ( + FrozenFragments, + PromptFrame, + PromptSceneBudget, + allocate_prompt_scene_rows, + freeze_fragments, +) + +Scene = Literal[ + "body", + "body_pinned", + "tall_pinned", + "modal", + "shortcuts", + "update", + "status_body", +] + + +def _text(value: str) -> FrozenFragments: + return freeze_fragments(FormattedText([("", value)])) + + +def _session_for_scene( + scene: Scene, + *, + width: int, + height: int, + card_style: bool, + monkeypatch: pytest.MonkeyPatch, +) -> CustomPromptSession: + session = object.__new__(CustomPromptSession) + session._mode = PromptMode.AGENT + session._model_name = None + session._model_capabilities = set() + session._thinking = False + session._thinking_effort = "off" + session._shortcut_help_open = scene == "shortcuts" + session._update_notice_provider = (lambda: "Update ready") if scene == "update" else None + + body = "live row 1\nlive row 2" + status = "" + pinned = "" + modal = scene == "modal" + if scene == "body_pinned": + pinned = "Working…" + elif scene == "tall_pinned": + pinned = "\n".join(f"pinned {index}" for index in range(20)) + elif scene == "modal": + body = "\n".join(f"modal control {index}" for index in range(20)) + status = "\n".join(f"old status {index}" for index in range(10)) + elif scene == "status_body": + status = "\n".join(f"old status {index}" for index in range(10)) + + session._current_prompt_frame = PromptFrame( + columns=width, + terminal_rows=height, + body_rows=PromptSceneBudget( + terminal_rows=height, + input_rows=0 if modal else 2, + ).preamble_rows, + agent_status=_text(status), + interactive_body=_text(body), + pinned_tail=_text(pinned), + placeholder=(), + input_card_hidden=False, + input_chrome_hidden=False, + modal_active=modal, + running_prompt_active=True, + ) + monkeypatch.setattr(shell_prompt, "is_card_style", lambda: card_style) + return session + + +@pytest.mark.parametrize("height", range(1, 13)) +@pytest.mark.parametrize("width", (20, 40, 80, 120)) +@pytest.mark.parametrize( + "scene", + ("body", "body_pinned", "tall_pinned", "modal", "shortcuts", "update", "status_body"), +) +@pytest.mark.parametrize("card_style", (False, True), ids=("pythinker", "card")) +def test_prompt_scene_never_exceeds_terminal_height( + height: int, + width: int, + scene: Scene, + card_style: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = _session_for_scene( + scene, + width=width, + height=height, + card_style=card_style, + monkeypatch=monkeypatch, + ) + + message = session._render_agent_prompt_message() + message_rows = len(shell_prompt._formatted_text_display_rows(message, width)) if message else 0 + footer = FormattedText( + [("", "\n".join(f"footer {index}" for index in range(4 if scene == "update" else 3)))] + ) + monkeypatch.setattr( + shell_prompt, + "get_app_or_none", + lambda: SimpleNamespace( + output=SimpleNamespace(get_size=lambda: SimpleNamespace(columns=width, rows=height)) + ), + ) + rendered_footer = session._fit_toolbar_to_terminal(footer, width) + footer_rows = ( + len(shell_prompt._formatted_text_display_rows(rendered_footer, width)) + if rendered_footer + else 0 + ) + + assert message_rows + footer_rows <= height + + +def test_one_row_scene_keeps_modal_then_input_before_footer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modal = _session_for_scene( + "modal", width=40, height=1, card_style=False, monkeypatch=monkeypatch + ) + modal_text = "".join(fragment[1] for fragment in modal._render_agent_prompt_message()) + prompt = _session_for_scene( + "body", width=40, height=1, card_style=False, monkeypatch=monkeypatch + ) + prompt_text = "".join(fragment[1] for fragment in prompt._render_agent_prompt_message()) + + assert "modal control 19" in modal_text + assert "earlier output hidden" not in modal_text + assert shell_prompt.PROMPT_SYMBOL_AGENT_INPUT in prompt_text + assert modal._prompt_footer_row_budget == 0 + assert prompt._prompt_footer_row_budget == 0 + + +def test_shell_render_refreshes_update_notice_snapshot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # _append_update_notice trusts the per-frame snapshot _prompt_frame_update_notice + # whenever a frame is captured. Only the agent render path refreshed it, so a + # shell frame would replay a stale agent-mode notice — or the initial None, + # suppressing a live notice. The shell render must refresh the snapshot too. + session = _session_for_scene( + "body", width=80, height=10, card_style=False, monkeypatch=monkeypatch + ) + session._mode = PromptMode.SHELL + + # A live notice must overwrite a stale agent-mode value, and — because the + # captured frame makes _append_update_notice read the snapshot rather than the + # provider — actually reach the rendered footer. + session._prompt_frame_update_notice = "STALE agent-mode notice" + session._update_notice_provider = lambda: "↑ Update available" + session._render_shell_prompt_message() + assert session._prompt_frame_update_notice == "↑ Update available" + fragments: list[tuple[str, str]] = [] + session._append_update_notice(fragments, 80) + assert any("Update available" in text for _, text in fragments) + assert not any("STALE" in text for _, text in fragments) + + # No pending notice must clear the snapshot, never leave it stale — and the + # footer stays empty instead of replaying the old text. + session._prompt_frame_update_notice = "STALE agent-mode notice" + session._update_notice_provider = lambda: None + session._render_shell_prompt_message() + assert session._prompt_frame_update_notice is None + fragments = [] + session._append_update_notice(fragments, 80) + assert fragments == [] + + # The registered after_render cleanup drops the snapshot with the frame, so a + # completed frame never leaves a value for the next render to read. + session._current_prompt_frame = object() # type: ignore[assignment] + session._prompt_frame_update_notice = "↑ Update available" + session._clear_prompt_frame_snapshot() + assert session._current_prompt_frame is None + assert session._prompt_frame_update_notice is None + + +def test_two_row_modal_uses_hint_then_tail(monkeypatch: pytest.MonkeyPatch) -> None: + session = _session_for_scene( + "modal", + width=40, + height=2, + card_style=False, + monkeypatch=monkeypatch, + ) + + plain = "".join(fragment[1] for fragment in session._render_agent_prompt_message()) + + assert "earlier output hidden" in plain + assert "modal control 19" in plain + + +def test_rendered_overflow_keeps_pinned_before_live_and_old_status( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pinned = _session_for_scene( + "body_pinned", + width=40, + height=6, + card_style=False, + monkeypatch=monkeypatch, + ) + pinned_plain = "".join(fragment[1] for fragment in pinned._render_agent_prompt_message()) + status = _session_for_scene( + "status_body", + width=40, + height=6, + card_style=False, + monkeypatch=monkeypatch, + ) + status_plain = "".join(fragment[1] for fragment in status._render_agent_prompt_message()) + + assert "Working…" in pinned_plain + assert "live row" not in pinned_plain + assert shell_prompt.PROMPT_SYMBOL_AGENT_INPUT in pinned_plain + assert "live row 2" in status_plain + assert "old status" not in status_plain + + +@pytest.mark.parametrize("card_style", (False, True), ids=("pythinker", "card")) +def test_pinned_separator_is_budgeted_before_live_body( + card_style: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + without_body_height = 7 + with_body_height = without_body_height + 1 + without_body = _session_for_scene( + "body_pinned", + width=40, + height=without_body_height, + card_style=card_style, + monkeypatch=monkeypatch, + ) + with_body = _session_for_scene( + "body_pinned", + width=40, + height=with_body_height, + card_style=card_style, + monkeypatch=monkeypatch, + ) + + without_body_plain = "".join( + fragment[1] for fragment in without_body._render_agent_prompt_message() + ) + with_body_rendered = with_body._render_agent_prompt_message() + with_body_plain = "".join(fragment[1] for fragment in with_body_rendered) + + assert "Working…" in without_body_plain + assert "live row" not in without_body_plain + assert "live row 2\n\nWorking…" in with_body_plain + assert len(shell_prompt._formatted_text_display_rows(with_body_rendered, 40)) == ( + 5 if card_style else 4 + ) + + +@pytest.mark.parametrize("card_style", (False, True), ids=("pythinker", "card")) +def test_tall_pinned_tail_clips_from_the_front( + card_style: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = _session_for_scene( + "tall_pinned", + width=40, + height=7, + card_style=card_style, + monkeypatch=monkeypatch, + ) + + plain = "".join(fragment[1] for fragment in session._render_agent_prompt_message()) + + assert "pinned 19" in plain + assert "pinned 18" in plain + assert "pinned 17" not in plain + assert "live row" not in plain + + +@pytest.mark.parametrize("card_style", (False, True), ids=("pythinker", "card")) +def test_footer_and_live_body_precede_shortcut_help( + card_style: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + hidden_height = 6 if not card_style else 7 + visible_height = hidden_height + 1 + hidden = _session_for_scene( + "shortcuts", + width=40, + height=hidden_height, + card_style=card_style, + monkeypatch=monkeypatch, + ) + visible = _session_for_scene( + "shortcuts", + width=40, + height=visible_height, + card_style=card_style, + monkeypatch=monkeypatch, + ) + + hidden_plain = "".join(fragment[1] for fragment in hidden._render_agent_prompt_message()) + visible_plain = "".join(fragment[1] for fragment in visible._render_agent_prompt_message()) + + assert "live row 1" in hidden_plain + assert "close shortcuts" not in hidden_plain + assert "live row 1" in visible_plain + assert "╰" in visible_plain + + +@pytest.mark.parametrize("card_style", (False, True), ids=("pythinker", "card")) +def test_update_footer_precedes_live_body( + card_style: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + hidden_height = 5 if not card_style else 6 + visible_height = hidden_height + 1 + hidden = _session_for_scene( + "update", + width=40, + height=hidden_height, + card_style=card_style, + monkeypatch=monkeypatch, + ) + visible = _session_for_scene( + "update", + width=40, + height=visible_height, + card_style=card_style, + monkeypatch=monkeypatch, + ) + + hidden_plain = "".join(fragment[1] for fragment in hidden._render_agent_prompt_message()) + visible_plain = "".join(fragment[1] for fragment in visible._render_agent_prompt_message()) + + assert "live row" not in hidden_plain + assert hidden._prompt_footer_row_budget == 4 + assert "live row 2" in visible_plain + assert visible._prompt_footer_row_budget == 4 + + +@pytest.mark.parametrize( + ("height", "expected"), + ( + (1, (1, 0, 0, 0, 0, 0, 0)), + (2, (2, 0, 0, 0, 0, 0, 0)), + (4, (2, 2, 0, 0, 0, 0, 0)), + (5, (2, 3, 0, 0, 0, 0, 0)), + (6, (2, 3, 1, 0, 0, 0, 0)), + (7, (2, 3, 1, 1, 0, 0, 0)), + (8, (2, 3, 1, 1, 1, 0, 0)), + (9, (2, 3, 1, 1, 1, 1, 0)), + ), +) +def test_scene_allocator_follows_overflow_priority( + height: int, + expected: tuple[int, int, int, int, int, int, int], +) -> None: + allocation = allocate_prompt_scene_rows( + PromptSceneBudget(terminal_rows=height), + input_rows=2, + footer_rows=3, + pinned_rows=1, + body_rows=1, + status_rows=1, + shortcut_rows=1, + ) + + assert ( + allocation.input_rows, + allocation.footer_rows, + allocation.pinned_rows, + allocation.body_rows, + allocation.status_rows, + allocation.shortcut_rows, + allocation.modal_rows, + ) == expected + + +def test_modal_consumes_rows_before_every_other_surface() -> None: + allocation = allocate_prompt_scene_rows( + PromptSceneBudget(terminal_rows=5), + modal_rows=10, + input_rows=2, + footer_rows=3, + pinned_rows=1, + body_rows=1, + status_rows=1, + shortcut_rows=1, + ) + + assert allocation.modal_rows == 5 + assert allocation.prompt_rows == 5 + assert allocation.footer_rows == 0 + + +def test_scene_allocator_reserves_menu_and_safety_rows() -> None: + allocation = allocate_prompt_scene_rows( + PromptSceneBudget(terminal_rows=5, menu_rows=2, safety_rows=1), + input_rows=2, + footer_rows=3, + ) + + assert allocation.input_rows == 2 + assert allocation.footer_rows == 0 + assert allocation.prompt_rows + allocation.footer_rows + 2 + 1 == 5 + + +def test_scene_allocator_counts_pinned_separator_inside_budget() -> None: + allocation = allocate_prompt_scene_rows( + PromptSceneBudget(terminal_rows=8), + input_rows=2, + footer_rows=3, + pinned_rows=1, + separator_rows=1, + body_rows=2, + ) + + assert allocation.pinned_rows == 1 + assert allocation.separator_rows == 1 + assert allocation.body_rows == 1 + assert allocation.prompt_rows + allocation.footer_rows == 8 + + +def test_update_notice_is_sampled_once_for_prompt_and_footer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = 0 + + def update_notice() -> str | None: + nonlocal calls + calls += 1 + return None if calls == 1 else "unexpected second sample" + + session = _session_for_scene( + "body", width=40, height=8, card_style=False, monkeypatch=monkeypatch + ) + session._update_notice_provider = update_notice + session._render_agent_prompt_message() + footer: list[tuple[str, str]] = [] + + session._append_update_notice(footer, 40) + + assert calls == 1 + assert footer == [] + + +def test_scene_budget_reserves_higher_priority_rows_before_pinned_tail() -> None: + budget = PromptSceneBudget( + terminal_rows=1, + input_rows=0, + footer_rows=1, + menu_rows=0, + safety_rows=0, + ) + + assert budget.preamble_rows == 0 + + +@pytest.mark.parametrize("terminal_rows", range(-2, 13)) +def test_scene_budget_is_zero_safe(terminal_rows: int) -> None: + budget = PromptSceneBudget( + terminal_rows=terminal_rows, + input_rows=2, + footer_rows=3, + menu_rows=6, + safety_rows=1, + ) + + assert budget.preamble_rows == max(0, terminal_rows - 12) diff --git a/tests/ui_and_conv/test_prompt_public_contract.py b/tests/ui_and_conv/test_prompt_public_contract.py new file mode 100644 index 00000000..05ed4ed1 --- /dev/null +++ b/tests/ui_and_conv/test_prompt_public_contract.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +from collections import Counter +from types import SimpleNamespace + +import pytest + +from pythinker_code.soul import StatusSnapshot +from pythinker_code.ui.shell import prompt as shell_prompt +from pythinker_code.ui.shell.prompt import ( + PROMPT_SYMBOL, + PROMPT_SYMBOL_AGENT_INPUT, + AttachmentCache, + BgTaskCounts, + CachedAttachment, + CustomPromptSession, + CwdLostError, + InputHighlightLexer, + LocalFileMentionCompleter, + LocalFileMentionMenuControl, + PromptMode, + PromptUIState, + RunningPromptDelegate, + SlashCommandAutoSuggest, + SlashCommandCompleter, + SlashCommandMenuControl, + UserInput, + sanitize_surrogates, +) +from tests.ui_and_conv.test_prompt_tips import _DummyReadOnlyModal, _DummyRunningPrompt + +PUBLIC_PROMPT_CONTRACT = { + "AttachmentCache": AttachmentCache, + "BgTaskCounts": BgTaskCounts, + "CachedAttachment": CachedAttachment, + "CustomPromptSession": CustomPromptSession, + "CwdLostError": CwdLostError, + "Document": shell_prompt.Document, + "HSplit": shell_prompt.HSplit, + "InputHighlightLexer": InputHighlightLexer, + "LocalFileMentionCompleter": LocalFileMentionCompleter, + "LocalFileMentionMenuControl": LocalFileMentionMenuControl, + "PROMPT_SYMBOL": PROMPT_SYMBOL, + "PROMPT_SYMBOL_AGENT_INPUT": PROMPT_SYMBOL_AGENT_INPUT, + "PromptMode": PromptMode, + "PromptUIState": PromptUIState, + "RunningPromptDelegate": RunningPromptDelegate, + "SlashCommandAutoSuggest": SlashCommandAutoSuggest, + "SlashCommandCompleter": SlashCommandCompleter, + "SlashCommandMenuControl": SlashCommandMenuControl, + "UserInput": UserInput, + "Window": shell_prompt.Window, + "Dimension": shell_prompt.Dimension, + "sanitize_surrogates": sanitize_surrogates, +} + + +def test_prompt_module_preserves_repository_import_surface() -> None: + """Names found by searching prompt imports and ``shell_prompt`` references stay exported.""" + for name, imported in PUBLIC_PROMPT_CONTRACT.items(): + assert getattr(shell_prompt, name) is imported + + +@pytest.fixture +def prompt_session() -> CustomPromptSession: + return CustomPromptSession( + status_provider=lambda: StatusSnapshot(context_usage=0.0), + model_capabilities=set(), + model_name=None, + thinking=False, + shell_mode_slash_commands=(), + history_enabled=False, + ) + + +def test_custom_prompt_session_keyword_initialization_contract( + prompt_session: CustomPromptSession, +) -> None: + assert prompt_session._mode is PromptMode.AGENT + assert prompt_session._session.default_buffer.completer is prompt_session._agent_mode_completer + assert prompt_session._session.app.max_render_postpone_time == pytest.approx(1 / 30) + + +class _CountingRunningPrompt(_DummyRunningPrompt): + def __init__(self) -> None: + self.calls: Counter[str] = Counter() + self.hide_card = False + + def render_agent_status(self, columns: int) -> str: + self.calls["agent_status"] += 1 + return f"agent status ({columns})" + + def render_running_prompt_body(self, columns: int) -> str: + self.calls["body"] += 1 + return f"live view ({columns})" + + def render_pinned_status_tail(self, columns: int) -> str: + self.calls["pinned"] += 1 + return f"pinned ({columns})" + + def running_prompt_placeholder(self) -> str: + self.calls["placeholder"] += 1 + return "placeholder" + + def running_prompt_hide_input_card(self) -> bool: + self.calls["hide_card"] += 1 + return self.hide_card + + def running_prompt_hide_input_card_chrome(self) -> bool: + self.calls["hide_chrome"] += 1 + return False + + +def _sample_render_calls( + prompt_session: CustomPromptSession, + monkeypatch: pytest.MonkeyPatch, +) -> Counter[str]: + delegate = _CountingRunningPrompt() + prompt_session._running_prompt_delegate = delegate + monkeypatch.setattr(shell_prompt, "is_card_style", lambda: True) + monkeypatch.setattr( + shell_prompt, + "get_app_or_none", + lambda: SimpleNamespace( + output=SimpleNamespace(get_size=lambda: SimpleNamespace(columns=80, rows=24)) + ), + ) + + prompt_session._render_agent_prompt_message() + return delegate.calls + + +def test_running_prompt_delegate_render_methods_are_sampled( + prompt_session: CustomPromptSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = _sample_render_calls(prompt_session, monkeypatch) + + for method in ( + "agent_status", + "body", + "pinned", + "placeholder", + "hide_card", + "hide_chrome", + ): + assert calls[method] >= 1 + + +@pytest.mark.parametrize( + "method", + ("agent_status", "body", "pinned", "placeholder", "hide_card", "hide_chrome"), +) +def test_running_prompt_delegate_render_method_is_sampled_exactly_once( + method: str, + prompt_session: CustomPromptSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = _sample_render_calls(prompt_session, monkeypatch) + + assert calls[method] == 1 + + +def test_modal_frame_samples_running_status_and_tail_and_modal_body_once( + prompt_session: CustomPromptSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + running = _CountingRunningPrompt() + + class _CountingModal(_DummyReadOnlyModal): + def __init__(self) -> None: + self.calls: Counter[str] = Counter() + + def render_running_prompt_body(self, columns: int) -> str: + self.calls["body"] += 1 + return f"modal ({columns})" + + modal = _CountingModal() + prompt_session._running_prompt_delegate = running + prompt_session._modal_delegates = [modal] + monkeypatch.setattr( + shell_prompt, + "get_app_or_none", + lambda: SimpleNamespace( + output=SimpleNamespace(get_size=lambda: SimpleNamespace(columns=80, rows=24)) + ), + ) + + prompt_session._render_agent_prompt_message() + + assert running.calls["agent_status"] == 1 + assert running.calls["pinned"] == 1 + assert running.calls["body"] == 0 + assert modal.calls["body"] == 1 + + +def test_running_prompt_layers_do_not_overflow_terminal_height( + prompt_session: CustomPromptSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _TallRunningPrompt(_DummyRunningPrompt): + def render_agent_status(self, columns: int) -> str: + return "\n".join(f"agent {index}" for index in range(10)) + + class _TallReadOnlyModal(_DummyReadOnlyModal): + def render_running_prompt_body(self, columns: int) -> str: + return "\n".join(f"modal {index}" for index in range(10)) + + prompt_session._running_prompt_delegate = _TallRunningPrompt() + prompt_session._modal_delegates = [_TallReadOnlyModal()] + rows = 8 + monkeypatch.setattr( + shell_prompt, + "get_app_or_none", + lambda: SimpleNamespace( + output=SimpleNamespace(get_size=lambda: SimpleNamespace(columns=80, rows=rows)) + ), + ) + + rendered = prompt_session._render_agent_prompt_message() + plain = "".join(fragment[1] for fragment in rendered) + + assert len(plain.splitlines()) <= rows diff --git a/tests/ui_and_conv/test_prompt_tips.py b/tests/ui_and_conv/test_prompt_tips.py index 81d6322b..f7f72885 100644 --- a/tests/ui_and_conv/test_prompt_tips.py +++ b/tests/ui_and_conv/test_prompt_tips.py @@ -145,7 +145,7 @@ class _DummyRunningPrompt: def render_running_prompt_body(self, columns: int) -> str: return f"live view ({columns})" - def running_prompt_placeholder(self) -> None: + def running_prompt_placeholder(self) -> str | None: return None def running_prompt_allows_text_input(self) -> bool: diff --git a/tests/ui_and_conv/test_visualize_running_prompt.py b/tests/ui_and_conv/test_visualize_running_prompt.py index 5a6deb2e..48c5b98c 100644 --- a/tests/ui_and_conv/test_visualize_running_prompt.py +++ b/tests/ui_and_conv/test_visualize_running_prompt.py @@ -723,7 +723,7 @@ def test_render_agent_prompt_message_keeps_prompt_marker_when_card_gate_hides_bu monkeypatch.setattr(prompt_module, "get_toolbar_colors", lambda: SimpleNamespace(separator="")) def _rendered(hidden: bool) -> str: - monkeypatch.setattr(session, "_input_card_hidden_pre_stream", lambda: hidden) + monkeypatch.setattr(session, "_input_card_hidden_pre_stream", lambda *_a, **_k: hidden) return "".join(text for _style, text, *_ in session._render_agent_prompt_message()) hidden_frame = _rendered(True) @@ -785,7 +785,9 @@ def test_render_agent_prompt_message_uses_scene_order_for_stream_and_input_card( session = _card_session(delegate=_body_delegate("assistant chunk", hide_card=True)) session._shortcut_help_open = False monkeypatch.setattr(session, "_render_agent_status", lambda _c: FormattedText()) - monkeypatch.setattr(session, "_render_interactive_body", lambda _c: FormattedText()) + monkeypatch.setattr( + session, "_render_interactive_body", lambda captured: FormattedText(list(captured)) + ) monkeypatch.setattr(session, "_render_pinned_status_tail", lambda _c: FormattedText()) monkeypatch.setattr(session, "_render_input_top_border", lambda _c, _f: [("", border)]) monkeypatch.setattr(prompt_module, "is_card_style", lambda: True) @@ -830,7 +832,9 @@ def handle_running_prompt_key(self, key: str, event) -> None: # noqa: ANN001 session = _card_session(delegate=_StyledDelegate()) session._shortcut_help_open = False monkeypatch.setattr(session, "_render_agent_status", lambda _c: FormattedText()) - monkeypatch.setattr(session, "_render_interactive_body", lambda _c: FormattedText()) + monkeypatch.setattr( + session, "_render_interactive_body", lambda captured: FormattedText(list(captured)) + ) monkeypatch.setattr(session, "_render_pinned_status_tail", lambda _c: FormattedText()) monkeypatch.setattr( session, "_render_input_top_border", lambda _c, _f: [("class:border", border)] @@ -858,14 +862,7 @@ def test_render_agent_prompt_message_keeps_live_view_chrome_before_first_commit( from prompt_toolkit.formatted_text import FormattedText border = "──────── ● off" - view = object.__new__(_PromptLiveView) - view._scrollback_handoff_depth = 0 - view._turn_ended = False - view._committed_scrollback_this_turn = False - view._current_approval_request_panel = None - view._transient_command_output = None - view._queued_messages = [] - view._awaiting_input_card_restore_anchor = False + view = _make_prompt_live_view() session = _card_session(delegate=view) session._shortcut_help_open = False