From 1068ea0025ed6179032f4d307b4f20fbb2992efa Mon Sep 17 00:00:00 2001 From: worldmozara Date: Tue, 6 Oct 2026 19:06:22 +0800 Subject: [PATCH] prompting: end an open tool line before writing a prompt --- src/plyngent/cli/limits.py | 3 +++ src/plyngent/prompting.py | 31 +++++++++++++++++++++++++++++-- tests/test_cli/test_display.py | 23 +++++++++++++++++++++++ tests/test_cli/test_limits.py | 5 ++++- tests/test_prompting.py | 22 ++++++++++++++++++++++ 5 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/plyngent/cli/limits.py b/src/plyngent/cli/limits.py index 84a74ce..387f0e3 100644 --- a/src/plyngent/cli/limits.py +++ b/src/plyngent/cli/limits.py @@ -7,6 +7,7 @@ import sys from dataclasses import dataclass from typing import TYPE_CHECKING, Literal +from plyngent.cli.display import close_open_pretty_line from plyngent.cli.interrupt import off_loop_prompt, pause_task_cancel_for_prompt from plyngent.prompting import ( ChoiceOption, @@ -18,6 +19,7 @@ from plyngent.prompting import ( configure_prompting, confirm, get_prompt_backend, + set_prompt_output_hook, ) from plyngent.tools.access import AccessDecision from plyngent.tools.process.pty_session import PtyManager @@ -580,4 +582,5 @@ def install_cli_limit_hooks() -> None: process-global hook). """ configure_prompting(pause_factory=off_loop_prompt) + set_prompt_output_hook(close_open_pretty_line) PtyManager.set_limit_continue_hook(prompt_continue_limit) diff --git a/src/plyngent/prompting.py b/src/plyngent/prompting.py index 561a2aa..4c7fdbf 100644 --- a/src/plyngent/prompting.py +++ b/src/plyngent/prompting.py @@ -93,6 +93,30 @@ class PromptBackend(Protocol): def secho(self, message: str, *, fg: str | None = None, err: bool = False) -> None: ... +# Hook run right before a backend writes prompt chrome (a confirm box, a question +# label). Hosts install one so an open pretty tool line — see +# ``plyngent.cli.display`` — is ended before the prompt takes that line over. +_before_output: Callable[[], None] | None = None + + +def set_prompt_output_hook(hook: Callable[[], None] | None) -> None: + """Install the hook run before prompt output (None clears it).""" + global _before_output # noqa: PLW0603 + _before_output = hook + + +def get_prompt_output_hook() -> Callable[[], None] | None: + """The installed prompt-output hook, if any.""" + return _before_output + + +def _break_output() -> None: + """Run the prompt-output hook, if one is installed.""" + hook = _before_output + if hook is not None: + hook() + + def _readline_input(prompt: str, *, completions: Sequence[str] | None = None) -> str: """``input()`` with optional Tab completion via readline when available.""" try: @@ -255,9 +279,11 @@ class ClickPromptBackend: raise NonInteractiveError(msg) from exc def echo(self, message: str = "", *, err: bool = False) -> None: + _break_output() click.echo(message, err=err) def secho(self, message: str, *, fg: str | None = None, err: bool = False) -> None: + _break_output() click.secho(message, fg=fg, err=err) @@ -319,10 +345,11 @@ def get_prompt_backend() -> PromptBackend: def reset_prompting() -> None: - """Restore default Click backend, clear the pause hook and cancel flag (tests).""" - global _backend, _pause_factory # noqa: PLW0603 + """Restore the default backend; clear the pause/output hooks and cancel flag (tests).""" + global _backend, _pause_factory, _before_output # noqa: PLW0603 _backend = ClickPromptBackend() _pause_factory = None + _before_output = None _prompt_cancel.clear() diff --git a/tests/test_cli/test_display.py b/tests/test_cli/test_display.py index f9e0b84..0da8f01 100644 --- a/tests/test_cli/test_display.py +++ b/tests/test_cli/test_display.py @@ -7,6 +7,7 @@ from plyngent.cli.display import ( _clear_streamed_lines, _line_count_for_clear, _pretty_line_for, + close_open_pretty_line, get_markdown_enabled, markdown_render_available, print_markdown, @@ -1002,6 +1003,28 @@ async def test_pretty_parallel_batch_keeps_the_line_above_the_prefix( assert "\x1b[" not in out +async def test_pretty_line_closed_by_out_of_band_output( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """A prompt/confirm printed while the call runs ends the open prefix line. + + The prompt backend runs :func:`close_open_pretty_line` before writing, so its + box starts on a fresh line and the result then prints whole. + """ + monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True) + + async def _events(): + yield _pretty_call("read_file", '{"path": "a.txt"}') + close_open_pretty_line() # what a confirm box does mid-call + yield _result("L1-4\none\n") + + await render_events(_events()) + out = capsys.readouterr().out + assert out == ("\n* Read 'a.txt' \n\n* Read 'a.txt' L1-4 (done)\n\n") + assert "\x1b[" not in out + + def test_clear_streamed_lines_clears_exactly_the_given_rows(capsys: pytest.CaptureFixture[str]) -> None: """Two rows means two clears; the cursor lands on the topmost cleared row.""" _clear_streamed_lines(2) diff --git a/tests/test_cli/test_limits.py b/tests/test_cli/test_limits.py index 05faa73..ce2840c 100644 --- a/tests/test_cli/test_limits.py +++ b/tests/test_cli/test_limits.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING import pytest +from plyngent.cli.display import close_open_pretty_line from plyngent.cli.limits import ( auto_continue_enabled, format_tool_confirm_box, @@ -14,7 +15,7 @@ from plyngent.cli.limits import ( reset_auto_continue_turn, set_auto_continue_default, ) -from plyngent.prompting import NonInteractiveBackend, get_prompt_backend, temporary_backend +from plyngent.prompting import NonInteractiveBackend, get_prompt_backend, get_prompt_output_hook, temporary_backend from plyngent.tools.process.pty_session import PtyManager from tests.test_prompting import ScriptedBackend @@ -121,6 +122,8 @@ def test_format_tool_confirm_box_multiline() -> None: def test_install_cli_limit_hooks() -> None: install_cli_limit_hooks() assert callable(getattr(PtyManager, "_limit_continue", None)) + # A prompt raised mid-call must end the open pretty tool line first. + assert get_prompt_output_hook() is close_open_pretty_line PtyManager.set_limit_continue_hook(None) # Backend remains usable after install. assert get_prompt_backend().is_interactive() or True diff --git a/tests/test_prompting.py b/tests/test_prompting.py index 3708173..22a0b35 100644 --- a/tests/test_prompting.py +++ b/tests/test_prompting.py @@ -19,11 +19,14 @@ from plyngent.prompting import ( configure_prompting, confirm, form, + get_prompt_backend, + get_prompt_output_hook, prompt_cancelled, read_line_with_timeout, reset_prompting, run_cancellable_prompt_async, run_prompt_async, + set_prompt_output_hook, temporary_backend, ) @@ -257,3 +260,22 @@ def test_non_interactive_confirm_uses_default() -> None: with temporary_backend(NonInteractiveBackend()): assert confirm("ok?", default=False) is False assert confirm("ok?", default=True) is True + + +def test_prompt_output_hook_runs_before_chrome(capsys: pytest.CaptureFixture[str]) -> None: + """A registered hook runs before echo/secho write; clearing it removes it. + + The CLI registers one so an open pretty tool line ends before a prompt box. + """ + calls: list[str] = [] + assert get_prompt_output_hook() is None + set_prompt_output_hook(lambda: calls.append("break")) + try: + backend = get_prompt_backend() + backend.echo("box") + backend.secho("question", fg="yellow") + finally: + set_prompt_output_hook(None) + assert calls == ["break", "break"] + assert get_prompt_output_hook() is None + assert "box" in capsys.readouterr().out