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..753be311 100644 --- a/src/pythinker_code/ui/shell/visualize/_interactive.py +++ b/src/pythinker_code/ui/shell/visualize/_interactive.py @@ -550,6 +550,32 @@ 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. + + 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``. + """ + 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 48c5b98c..7db8a0b9 100644 --- a/tests/ui_and_conv/test_visualize_running_prompt.py +++ b/tests/ui_and_conv/test_visualize_running_prompt.py @@ -734,6 +734,97 @@ 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 rich.console import 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] = [] + # 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( + test_console, + "print", + lambda *_a, **_k: hidden_when_written.append(view.running_prompt_hide_input_card()), + ) + + 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, + ) + view._turn_ended = True # card shown → the handoff actually runs its emit + + 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(test_console, "print", _fail_print) + + 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( monkeypatch, ) -> None: