Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion src/pythinker_code/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if prompt_session is not None:
prompt_session.mark_turn_starting()
if runtime is not None:
Expand Down
26 changes: 26 additions & 0 deletions src/pythinker_code/ui/shell/visualize/_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
91 changes: 91 additions & 0 deletions tests/ui_and_conv/test_visualize_running_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)

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
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@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:
Expand Down
Loading