diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd30f17..6c5aa5fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- Fix queued follow-up input showing a bordered ghost; pressing Enter during an active turn now shows one intentional queued row. + ## 0.60.0 (2026-07-18) - **Leaf subagent prompt profile.** All 12 built-in subagent roles (implementer, diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 3e134e89..380fea1f 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -17,6 +17,8 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- Fix queued follow-up input showing a bordered ghost; pressing Enter during an active turn now shows one intentional queued row. + ## 0.60.0 (2026-07-18) - **Leaf subagent prompt profile.** All 12 built-in subagent roles (implementer, diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index 27825334..57f9f108 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -987,9 +987,8 @@ class TUIConfig(BaseModel): sticky_input: bool = Field( default=True, description=( - "Keep the prompt composer pinned to the bottom of the terminal while " - "an agent turn is running. Disable for terminals that mishandle " - "prompt_toolkit fullscreen rendering." + "Legacy compatibility setting retained for backward compatibility. " + "The running prompt composer stays inline and available during active turns." ), ) code_theme: str = Field( diff --git a/src/pythinker_code/ui/shell/prompt.py b/src/pythinker_code/ui/shell/prompt.py index 27f51607..cc512bf9 100644 --- a/src/pythinker_code/ui/shell/prompt.py +++ b/src/pythinker_code/ui/shell/prompt.py @@ -2349,7 +2349,6 @@ def __init__( # _input_card_hidden_pre_stream. self._turn_starting: bool = False self._sticky_input = sticky_input - self._previous_full_screen: bool | None = None self._latest_todos: tuple[TodoDisplayItem, ...] = () self._modal_delegates: list[RunningPromptDelegate] = [] self._shortcut_help_open = False @@ -3168,27 +3167,7 @@ def _apply_mode(self, event: KeyPressEvent | None = None) -> None: def _sync_erase_when_done(self) -> None: app = getattr(self._session, "app", None) if app is not None: - app.erase_when_done = getattr( - self, "_mode", PromptMode.AGENT - ) == PromptMode.AGENT and not getattr(app, "full_screen", False) - - def _set_running_fullscreen(self, active: bool) -> None: - if not getattr(self, "_sticky_input", True): - return - app = getattr(getattr(self, "_session", None), "app", None) - if app is None: - return - if active: - if getattr(self, "_previous_full_screen", None) is None: - self._previous_full_screen = bool(getattr(app, "full_screen", False)) - app.full_screen = True - self._sync_erase_when_done() - return - previous = getattr(self, "_previous_full_screen", None) - self._previous_full_screen = None - if previous is not None: - app.full_screen = previous - self._sync_erase_when_done() + app.erase_when_done = getattr(self, "_mode", PromptMode.AGENT) == PromptMode.AGENT def _active_modal_delegate(self) -> RunningPromptDelegate | None: modal_delegates = getattr(self, "_modal_delegates", []) @@ -4009,7 +3988,6 @@ def mark_turn_starting(self) -> None: # not cost an extra repaint. if not self._turn_starting: self._turn_starting = True - self._set_running_fullscreen(True) self.invalidate() def clear_turn_starting(self) -> None: @@ -4021,7 +3999,6 @@ def clear_turn_starting(self) -> None: without reaching into the private ``_turn_starting`` attribute. """ self._turn_starting = False - self._set_running_fullscreen(False) self.invalidate() def attach_running_prompt(self, delegate: RunningPromptDelegate) -> None: @@ -4035,7 +4012,6 @@ def attach_running_prompt(self, delegate: RunningPromptDelegate) -> None: self._turn_starting = False self._mode = PromptMode.AGENT self._apply_mode() - self._set_running_fullscreen(True) self.invalidate() def detach_running_prompt(self, delegate: RunningPromptDelegate) -> None: @@ -4048,7 +4024,6 @@ def detach_running_prompt(self, delegate: RunningPromptDelegate) -> None: if previous_mode is not None: self._mode = previous_mode self._apply_mode() - self._set_running_fullscreen(False) self.invalidate() def attach_modal(self, delegate: RunningPromptDelegate) -> None: diff --git a/tests/e2e/test_shell_pty_prompt_layout_e2e.py b/tests/e2e/test_shell_pty_prompt_layout_e2e.py index df7d5abd..18587c22 100644 --- a/tests/e2e/test_shell_pty_prompt_layout_e2e.py +++ b/tests/e2e/test_shell_pty_prompt_layout_e2e.py @@ -2,9 +2,9 @@ Unlike the byte-stream PTY helpers, these feed the raw terminal bytes to a pyte virtual screen so assertions run against the *rendered* frame — the only place -an incomplete-erase "ghost"/duplicate row is visible. They pin Focus TUI -fossilization behavior and the normal prompt card's visible loading/mid-turn -contract. +an incomplete-erase accepted-buffer "ghost"/duplicate row is visible. They pin +Focus TUI fossilization behavior and the normal prompt card's visible +loading/mid-turn contract. This is a manual/local check, not a CI-enforced one — it is skipped on CI (see ``pytestmark`` below: scripted_echo + prompt_toolkit hang on GitHub Actions' @@ -30,6 +30,7 @@ import pytest from tests.e2e.shell_pty_helpers import ( + list_turn_begin_inputs, make_home_dir, make_work_dir, read_until_prompt_ready, @@ -49,6 +50,7 @@ _COLS, _ROWS = 120, 40 _PROMPT_TEXT = "this is a prompt to the agent" +_QUEUED_FOLLOW_UP = "queued follow-up ghost regression 7f3a" def _render(chunks: list[bytes]): @@ -83,6 +85,30 @@ def _has_fossil_border_above_content(rows: list[str]) -> bool: return any(_is_input_card_border(rows[i]) for i in range(echo_i + 1, content_i)) +def _queued_text_fossilized_as_card(rows: list[str], text: str) -> bool: + """True if the queued follow-up rendered as a fossilized accepted-input card. + + The queued-input ghost commits the accepted follow-up into scrollback as a + bordered ``● `` input card, so its text ends up wedged between a card + border directly above and committed ``⏺`` turn content directly below. That + signature excludes the two healthy renderings: the live queued display (text + plus the ``↑ to edit`` hint, with no card border directly above) and the + execute-echo once the queued turn drains (committed like any turn input, again + with no card border directly above). A/B-verified: fires on the pre-fix code + and stays silent on the fixed code across every rendered frame. + """ + for i, row in enumerate(rows): + if text not in row: + continue + border_above = any(_is_input_card_border(rows[j]) for j in range(max(0, i - 2), i)) + content_below = any( + rows[j].strip().startswith("⏺") for j in range(i + 1, min(len(rows), i + 3)) + ) + if border_above and content_below: + return True + return False + + def test_focus_tui_hides_files_and_never_fossilizes_prompt(tmp_path: Path) -> None: write = { "id": "w1", @@ -193,3 +219,76 @@ def test_input_card_stays_visible_during_initial_loading_and_mid_turn(tmp_path: ) finally: shell.close() + + +def test_mid_turn_queued_input_renders_once_and_executes_once(tmp_path: Path) -> None: + slow = { + "id": "queued-slow", + "name": "Shell", + "arguments": json.dumps({"command": "sleep 3"}), + } + config_path = write_scripted_config( + tmp_path, + [ + f"tool_call: {json.dumps(slow)}", + "text: First turn finished.", + "text: Queued follow-up executed.", + ], + 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()) + assert any(_is_input_card_border(row) for row in _render(shell._raw_chunks)) + + first_turn_mark = shell.mark() + shell.send_line(_PROMPT_TEXT) + shell.read_until_contains("Bash(sleep 3", after=first_turn_mark, timeout=15.0) + shell.send_line(_QUEUED_FOLLOW_UP) + + # The queued follow-up must render as the intentional ``❯ … / ↑ to edit`` + # row and never fossilize into a bordered accepted-input card (the ghost). + # Steady state legitimately shows the text twice — the live queued display + # and, after drain, the execute-echo — so the guard is "never a fossilized + # accepted-input card", not a raw occurrence count. + queued_hint_seen = False + deadline = time.monotonic() + 12.0 + while time.monotonic() < deadline: + shell.read_available(timeout=0.08) + rows = _render(shell._raw_chunks) + joined = "\n".join(rows) + + assert not _queued_text_fossilized_as_card(rows, _QUEUED_FOLLOW_UP), ( + "queued follow-up fossilized as a bordered ghost card" + ) + if _QUEUED_FOLLOW_UP in joined and "↑ to edit · ctrl-s to send immediately" in joined: + queued_hint_seen = True + if "First turn finished." in shell.normalized_text(): + break + + assert queued_hint_seen, "intentional queued-message row was never rendered" + shell.read_until_contains("Queued follow-up executed.", timeout=15.0) + shell.wait_for_quiet(timeout=6.0, quiet_period=0.3) + + assert not _queued_text_fossilized_as_card(_render(shell._raw_chunks), _QUEUED_FOLLOW_UP), ( + "queued follow-up fossilized as a bordered ghost card in the settled frame" + ) + + turn_inputs = list_turn_begin_inputs(home_dir, work_dir) + assert turn_inputs == [_PROMPT_TEXT, _QUEUED_FOLLOW_UP] + assert turn_inputs.count(_QUEUED_FOLLOW_UP) == 1 + assert any(_is_input_card_border(row) for row in _render(shell._raw_chunks)), ( + "idle input-card border did not return after the queued turn ended" + ) + finally: + shell.close() diff --git a/tests/ui_and_conv/test_prompt_tips.py b/tests/ui_and_conv/test_prompt_tips.py index e03dad22..81d6322b 100644 --- a/tests/ui_and_conv/test_prompt_tips.py +++ b/tests/ui_and_conv/test_prompt_tips.py @@ -1546,7 +1546,7 @@ def test_apply_mode_syncs_erase_when_done_with_current_mode() -> None: prompt_session._session = cast( Any, SimpleNamespace( - app=SimpleNamespace(erase_when_done=False), + app=SimpleNamespace(erase_when_done=False, full_screen=True), default_buffer=SimpleNamespace(completer=None), ), ) @@ -1558,12 +1558,14 @@ def test_apply_mode_syncs_erase_when_done_with_current_mode() -> None: assert prompt_session._session.default_buffer.completer is prompt_session._agent_mode_completer assert prompt_session._session.app.erase_when_done is True + assert prompt_session._session.app.full_screen is True prompt_session._mode = PromptMode.SHELL prompt_session._apply_mode() assert prompt_session._session.default_buffer.completer is prompt_session._shell_mode_completer assert prompt_session._session.app.erase_when_done is False + assert prompt_session._session.app.full_screen is True def test_attach_running_prompt_enables_erase_when_done_and_detach_restores_state() -> None: @@ -1571,45 +1573,32 @@ def test_attach_running_prompt_enables_erase_when_done_and_detach_restores_state prompt_session._mode = PromptMode.SHELL prompt_session._running_prompt_delegate = None prompt_session._running_prompt_previous_mode = None - prompt_session._sticky_input = False - prompt_session._session = cast(Any, SimpleNamespace(app=SimpleNamespace(erase_when_done=False))) + prompt_session._turn_starting = False + app = SimpleNamespace( + erase_when_done=False, + full_screen=True, + renderer=SimpleNamespace(full_screen=False), + ) + prompt_session._session = cast( + Any, + SimpleNamespace(app=app, default_buffer=SimpleNamespace(completer=None)), + ) + prompt_session._agent_mode_completer = cast(Any, object()) + prompt_session._shell_mode_completer = cast(Any, object()) delegate = _DummyRunningPrompt() - trace: list[tuple[str, object, object, object]] = [] - - def fake_apply_mode(event=None) -> None: - prompt_session._session.app.erase_when_done = prompt_session._mode == PromptMode.AGENT - trace.append( - ( - "apply", - prompt_session._mode, - prompt_session._session.app.erase_when_done, - prompt_session._running_prompt_delegate, - ) - ) + invalidations: list[tuple[PromptMode, bool, object | None]] = [] + initial_screen_modes = (app.full_screen, app.renderer.full_screen) def fake_invalidate() -> None: - trace.append( + invalidations.append( ( - "invalidate", prompt_session._mode, prompt_session._session.app.erase_when_done, prompt_session._running_prompt_delegate, ) ) - async def fake_prompt_once(*, append_history: bool) -> UserInput: - trace.append( - ( - "prompt", - append_history, - prompt_session._session.app.erase_when_done, - prompt_session._running_prompt_delegate, - ) - ) - return UserInput(mode=PromptMode.AGENT, command="hi", resolved_command="hi", content=[]) - - prompt_session._apply_mode = fake_apply_mode prompt_session.invalidate = fake_invalidate prompt_session.attach_running_prompt(delegate) @@ -1617,13 +1606,18 @@ async def fake_prompt_once(*, append_history: bool) -> UserInput: assert prompt_session._mode == PromptMode.AGENT assert prompt_session._running_prompt_delegate is delegate assert prompt_session._session.app.erase_when_done is True + assert (app.full_screen, app.renderer.full_screen) == initial_screen_modes prompt_session.detach_running_prompt(delegate) assert prompt_session._mode == PromptMode.SHELL assert prompt_session._running_prompt_delegate is None assert prompt_session._session.app.erase_when_done is False - assert [entry[0] for entry in trace] == ["apply", "invalidate", "apply", "invalidate"] + assert (app.full_screen, app.renderer.full_screen) == initial_screen_modes + assert invalidations == [ + (PromptMode.AGENT, True, delegate), + (PromptMode.SHELL, False, None), + ] # ── Prompt async contract ───────────────────────────────────────────────────── diff --git a/tests/ui_and_conv/test_visualize_running_prompt.py b/tests/ui_and_conv/test_visualize_running_prompt.py index 260200c3..e68477c3 100644 --- a/tests/ui_and_conv/test_visualize_running_prompt.py +++ b/tests/ui_and_conv/test_visualize_running_prompt.py @@ -444,44 +444,53 @@ def test_mark_turn_starting_is_idempotent_and_cleared_on_attach_detach() -> None assert session._turn_starting is False -def test_sticky_input_turn_start_enables_fullscreen_once() -> None: +def test_running_prompt_lifecycle_preserves_prompt_toolkit_screen_mode() -> None: from types import SimpleNamespace session = object.__new__(prompt_module.CustomPromptSession) - app = SimpleNamespace(full_screen=False, erase_when_done=True) - session._session = cast(Any, SimpleNamespace(app=app, default_buffer=SimpleNamespace(text=""))) - session._sticky_input = True - session._previous_full_screen = None + renderer = SimpleNamespace(full_screen=False) + app = SimpleNamespace(full_screen=True, erase_when_done=False, renderer=renderer) + session._session = cast( + Any, + SimpleNamespace(app=app, default_buffer=SimpleNamespace(text="", completer=None)), + ) + session._mode = prompt_module.PromptMode.AGENT + session._agent_mode_completer = cast(Any, object()) + session._shell_mode_completer = cast(Any, object()) + session._running_prompt_delegate = None + session._running_prompt_previous_mode = None session._turn_starting = False invalidations: list[int] = [] - session.invalidate = lambda: invalidations.append(1) # type: ignore[method-assign] + cast(Any, session).invalidate = lambda: invalidations.append(1) + screen_modes = (app.full_screen, renderer.full_screen) - session.mark_turn_starting() - session.mark_turn_starting() + session._apply_mode() + assert app.erase_when_done is True + assert (app.full_screen, renderer.full_screen) == screen_modes - assert app.full_screen is True - assert app.erase_when_done is False - assert session._previous_full_screen is False - assert len(invalidations) == 1 + session.mark_turn_starting() + assert session._turn_starting is True + assert app.erase_when_done is True + assert (app.full_screen, renderer.full_screen) == screen_modes + session.clear_turn_starting() + assert session._turn_starting is False + assert app.erase_when_done is True + assert (app.full_screen, renderer.full_screen) == screen_modes -def test_sticky_input_clear_turn_starting_restores_fullscreen_on_pre_attach_error() -> None: - from types import SimpleNamespace + session.mark_turn_starting() + delegate = cast(Any, object()) + session.attach_running_prompt(delegate) + assert session._turn_starting is False + assert app.erase_when_done is True + assert (app.full_screen, renderer.full_screen) == screen_modes - session = object.__new__(prompt_module.CustomPromptSession) - app = SimpleNamespace(full_screen=True, erase_when_done=False) - session._session = cast(Any, SimpleNamespace(app=app, default_buffer=SimpleNamespace(text=""))) - session._sticky_input = True - session._previous_full_screen = False session._turn_starting = True - invalidations: list[int] = [] - session.invalidate = lambda: invalidations.append(1) # type: ignore[method-assign] - - session.clear_turn_starting() - + session.detach_running_prompt(delegate) assert session._turn_starting is False - assert app.full_screen is False - assert invalidations == [1] + assert app.erase_when_done is True + assert (app.full_screen, renderer.full_screen) == screen_modes + assert invalidations == [1, 1, 1, 1, 1] def test_clear_turn_starting_is_the_public_api_for_belt_and_suspenders_cleanup() -> None: @@ -2227,6 +2236,8 @@ def test_handle_local_input_queues_message_by_default() -> None: view._turn_ended = False view._queued_messages = [] view._prompt_session = MagicMock() + view._emit_steer_echo = MagicMock() + view._steer = MagicMock() user_in = prompt_module.UserInput( mode=prompt_module.PromptMode.AGENT, @@ -2238,7 +2249,11 @@ def test_handle_local_input_queues_message_by_default() -> None: # Default Enter queues instead of steering assert len(view._queued_messages) == 1 + assert view._queued_messages[0] is user_in assert view._queued_messages[0].command == "[Pasted text #1 +3 lines]" + view._prompt_session.invalidate.assert_called_once_with() + view._emit_steer_echo.assert_not_called() + view._steer.assert_not_called() def test_handle_local_input_ignores_finished_turn(monkeypatch) -> None: