From 9de0ba8ae5d5f143c125006bcdd93779aa0af20a Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 20 Jul 2026 20:55:59 -0400 Subject: [PATCH 1/3] fix(tui): commit queued-drain echo through the scrollback handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Echoing a drained queued command with a raw console.print left the input card in the frame prompt_toolkit's run_in_terminal teardown erases, so under heavy CPU load the card's top border fossilized into scrollback just above the echoed command (the rare "queued follow-up ghost"). Route the echo through the view's scrollback handoff via a new _PromptLiveView.commit_scrollback_echo, which raises the handoff depth first so running_prompt_hide_input_card is True at the instant the echo is written — keeping the border out of the torn-down frame, exactly as streamed turn content is committed. Adds a deterministic regression test asserting the card is hidden when the echo commits (verified to fail on the raw-print path). The steer echo already commits via pending-scrollback/handoff; the initial-input echo runs at stable idle geometry with no view and was verified fossil-free under heavy load. --- CHANGELOG.md | 1 + src/pythinker_code/ui/shell/__init__.py | 8 +++- .../ui/shell/visualize/_interactive.py | 14 ++++++ .../test_visualize_running_prompt.py | 44 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75556432..52674811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- Fix a rare queued-follow-up "ghost card": echoing a drained queued command now commits through the scrollback handoff (which hides the input card before the terminal teardown erases the prompt), so the input-card border can no longer fossilize into scrollback above the echoed command under heavy load. - Freeze the public shell prompt compatibility contract with constructor and rendering coverage. - Unify slash and file-mention completion behind one canonical completion context, adding quoted `@"path with spaces"` file mentions. - Index workspace file mentions asynchronously with a cwd-aware, generation-owned snapshot index so completion never blocks on disk or Git scans. diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 775d6472..eec1ecd7 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -1498,7 +1498,13 @@ def _on_view_ready(view: Any) -> None: if not pending: break queued = pending.pop(0) - console.print(render_user_echo_text(queued.resolved_command)) + # Commit the echo through the view's scrollback handoff (not a raw + # console.print): the handoff hides the input card before the + # terminal teardown erases the prompt, so the card border cannot + # fossilize into scrollback above the echoed command under load. + await captured_view.commit_scrollback_echo( + render_user_echo_text(queued.resolved_command) + ) if prompt_session is not None: prompt_session.mark_turn_starting() if runtime is not None: diff --git a/src/pythinker_code/ui/shell/visualize/_interactive.py b/src/pythinker_code/ui/shell/visualize/_interactive.py index 9524438e..f64ec6a9 100644 --- a/src/pythinker_code/ui/shell/visualize/_interactive.py +++ b/src/pythinker_code/ui/shell/visualize/_interactive.py @@ -550,6 +550,20 @@ def drain_queued_messages(self) -> list[UserInput]: self._queued_messages.clear() return msgs + async def commit_scrollback_echo(self, renderable: RenderableType) -> None: + """Print a user-echo line to scrollback through the scrollback handoff. + + The shell echoes each drained queued command above the next turn. Doing + that with a raw ``console.print`` erases and repaints the prompt with the + input card still in the pre-handoff frame, so under load the card's top + border fossilizes into scrollback just above the echoed command. Routing + the echo through ``_run_scrollback_handoff`` raises the handoff depth + first — which drives ``running_prompt_hide_input_card`` True and keeps the + border out of the frame the terminal teardown erases — exactly as streamed + turn content is committed. + """ + await self._run_scrollback_handoff(lambda: console.print(renderable), reason="queued_echo") + async def wait_for_btw_dismiss(self) -> None: """Wait for btw LLM completion + user dismiss, then clean up. diff --git a/tests/ui_and_conv/test_visualize_running_prompt.py b/tests/ui_and_conv/test_visualize_running_prompt.py index 48c5b98c..966dc907 100644 --- a/tests/ui_and_conv/test_visualize_running_prompt.py +++ b/tests/ui_and_conv/test_visualize_running_prompt.py @@ -734,6 +734,50 @@ def _rendered(hidden: bool) -> str: assert prompt_module.PROMPT_SYMBOL_AGENT_INPUT in shown_frame +@pytest.mark.asyncio +async def test_commit_scrollback_echo_hides_input_card_during_emit(monkeypatch) -> None: + """The queued-drain echo commits while the input card is hidden. + + Regression guard for the queued-follow-up ghost: echoing a drained command + with a raw ``console.print`` leaves the input-card border in the frame the + terminal teardown erases, so under load the border fossilizes above the echo. + ``commit_scrollback_echo`` routes through the scrollback handoff, which raises + the handoff depth so ``running_prompt_hide_input_card`` is True at the exact + moment the echo is written. + """ + from pythinker_code.ui.shell.console import console as shared_console + + class _PromptSession: + def update_pinned_todos(self, _items: object) -> None: + pass + + def invalidate(self) -> None: + pass + + view = _PromptLiveView( + StatusUpdate(), + prompt_session=cast(Any, _PromptSession()), + steer=lambda _content: None, + ) + # First turn has ended: without the handoff the card is shown — the + # fossil-prone state the raw console.print used to commit against. + view._turn_ended = True + assert view.running_prompt_hide_input_card() is False + + hidden_when_written: list[bool] = [] + monkeypatch.setattr(shared_console, "_force_terminal", False) + monkeypatch.setattr( + shared_console, + "print", + lambda *_a, **_k: hidden_when_written.append(view.running_prompt_hide_input_card()), + ) + + await view.commit_scrollback_echo(Text("❯ queued command")) + + assert hidden_when_written == [True] # card hidden at the instant the echo committed + assert view.running_prompt_hide_input_card() is False # restored for the next turn + + def test_render_agent_prompt_message_keeps_prompt_marker_in_classic_style_pre_stream( monkeypatch, ) -> None: From ed8501487228febc713c744b05d1cde75f19ac92 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 20 Jul 2026 21:12:59 -0400 Subject: [PATCH 2/3] fix(tui): make queued-drain echo best-effort and harden its test Address review on the queued-follow-up ghost fix: - commit_scrollback_echo now logs a failed scrollback handoff instead of raising it. The echo is cosmetic and the command still runs via run_soul, so a handoff error must not drop the queued command the shell already popped. Cancellation still propagates (CancelledError is not an Exception). - Add a failure-path regression asserting the best-effort behavior. - Stop patching Rich's private _force_terminal in the hide-card test; use a public Console(force_terminal=False) swapped into the module reference. --- .../ui/shell/visualize/_interactive.py | 14 +++++- .../test_visualize_running_prompt.py | 43 +++++++++++++++++-- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/pythinker_code/ui/shell/visualize/_interactive.py b/src/pythinker_code/ui/shell/visualize/_interactive.py index f64ec6a9..753be311 100644 --- a/src/pythinker_code/ui/shell/visualize/_interactive.py +++ b/src/pythinker_code/ui/shell/visualize/_interactive.py @@ -561,8 +561,20 @@ async def commit_scrollback_echo(self, renderable: RenderableType) -> None: first — which drives ``running_prompt_hide_input_card`` True and keeps the border out of the frame the terminal teardown erases — exactly as streamed turn content is committed. + + Best-effort: the echo is cosmetic, so a failed handoff is logged rather + than raised — the queued command still runs via ``run_soul`` and must not + be dropped just because its scrollback echo could not be painted (the + handoff resets the renderer for recovery before it re-raises). Cancellation + still propagates, since ``CancelledError`` is not an ``Exception``. """ - await self._run_scrollback_handoff(lambda: console.print(renderable), reason="queued_echo") + try: + await self._run_scrollback_handoff( + lambda: console.print(renderable), reason="queued_echo" + ) + except Exception as exc: # noqa: BLE001 — cosmetic echo; must not drop the queued command + _handoff_trace(f"QUEUED_ECHO_FAIL\t{type(exc).__name__}:{exc}") + logger.warning("Queued-echo scrollback commit failed; the command still runs: {}", exc) async def wait_for_btw_dismiss(self) -> None: """Wait for btw LLM completion + user dismiss, then clean up. diff --git a/tests/ui_and_conv/test_visualize_running_prompt.py b/tests/ui_and_conv/test_visualize_running_prompt.py index 966dc907..a29f0e95 100644 --- a/tests/ui_and_conv/test_visualize_running_prompt.py +++ b/tests/ui_and_conv/test_visualize_running_prompt.py @@ -745,7 +745,7 @@ async def test_commit_scrollback_echo_hides_input_card_during_emit(monkeypatch) the handoff depth so ``running_prompt_hide_input_card`` is True at the exact moment the echo is written. """ - from pythinker_code.ui.shell.console import console as shared_console + from rich.console import Console class _PromptSession: def update_pinned_todos(self, _items: object) -> None: @@ -765,19 +765,54 @@ def invalidate(self) -> None: assert view.running_prompt_hide_input_card() is False hidden_when_written: list[bool] = [] - monkeypatch.setattr(shared_console, "_force_terminal", False) + # A non-terminal console makes the handoff emit synchronously (no + # run_in_terminal); patch the module reference rather than Rich internals. + test_console = Console(force_terminal=False) + monkeypatch.setattr(_interactive_mod, "console", test_console) monkeypatch.setattr( - shared_console, + test_console, "print", lambda *_a, **_k: hidden_when_written.append(view.running_prompt_hide_input_card()), ) - await view.commit_scrollback_echo(Text("❯ queued command")) + await view.commit_scrollback_echo(Text("queued command echo")) assert hidden_when_written == [True] # card hidden at the instant the echo committed assert view.running_prompt_hide_input_card() is False # restored for the next turn +@pytest.mark.asyncio +async def test_commit_scrollback_echo_is_best_effort_when_handoff_fails(monkeypatch) -> None: + """A failed handoff must not drop the queued command. + + The echo is cosmetic — the command still runs via ``run_soul`` — so + ``commit_scrollback_echo`` logs a handoff failure instead of propagating it. + The shell drain loop pops the queued item before echoing, so a raising echo + would otherwise lose that command. + """ + + class _PromptSession: + def update_pinned_todos(self, _items: object) -> None: + pass + + def invalidate(self) -> None: + pass + + view = _PromptLiveView( + StatusUpdate(), + prompt_session=cast(Any, _PromptSession()), + steer=lambda _content: None, + ) + + async def _boom(*_a: object, **_k: object) -> None: + raise RuntimeError("handoff teardown exploded") + + monkeypatch.setattr(view, "_run_scrollback_handoff", _boom) + + # Must not raise: the failure is swallowed and logged, not propagated. + await view.commit_scrollback_echo(Text("queued command echo")) + + def test_render_agent_prompt_message_keeps_prompt_marker_in_classic_style_pre_stream( monkeypatch, ) -> None: From 1c725ffc2333755910d08076af9b10edbb4d8ce7 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 20 Jul 2026 21:25:48 -0400 Subject: [PATCH 3/3] test(tui): exercise the real emit seam in the best-effort echo test Instead of monkeypatching the private _run_scrollback_handoff, make a non-terminal Console.print raise so the handoff runs its real emit and commit_scrollback_echo swallows the failure from the actual seam. Asserts the print was reached and no exception propagated. --- .../test_visualize_running_prompt.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/ui_and_conv/test_visualize_running_prompt.py b/tests/ui_and_conv/test_visualize_running_prompt.py index a29f0e95..7db8a0b9 100644 --- a/tests/ui_and_conv/test_visualize_running_prompt.py +++ b/tests/ui_and_conv/test_visualize_running_prompt.py @@ -803,14 +803,26 @@ def invalidate(self) -> None: prompt_session=cast(Any, _PromptSession()), steer=lambda _content: None, ) + view._turn_ended = True # card shown → the handoff actually runs its emit - async def _boom(*_a: object, **_k: object) -> None: + from rich.console import Console + + # Exercise the real emit seam: a non-terminal console makes the handoff call + # console.print directly, and that raise must be swallowed by the best-effort + # commit rather than propagating out and dropping the queued command. + test_console = Console(force_terminal=False) + monkeypatch.setattr(_interactive_mod, "console", test_console) + attempted: list[bool] = [] + + def _fail_print(*_a: object, **_k: object) -> None: + attempted.append(True) raise RuntimeError("handoff teardown exploded") - monkeypatch.setattr(view, "_run_scrollback_handoff", _boom) + monkeypatch.setattr(test_console, "print", _fail_print) - # Must not raise: the failure is swallowed and logged, not propagated. - await view.commit_scrollback_echo(Text("queued command echo")) + await view.commit_scrollback_echo(Text("queued command echo")) # must not raise + + assert attempted == [True] # the real print seam was reached and its raise swallowed def test_render_agent_prompt_message_keeps_prompt_marker_in_classic_style_pre_stream(