mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
prompting: end an open tool line before writing a prompt
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user