diff --git a/src/plyngent/cli/display.py b/src/plyngent/cli/display.py index 59375a2..56a43f8 100644 --- a/src/plyngent/cli/display.py +++ b/src/plyngent/cli/display.py @@ -182,9 +182,10 @@ class PrettyLine: ``prefix`` is written as soon as the call starts — a blocking tool call is then visible while it runs — and ``detail`` is appended once its result lands, so the two land on one line. Together they equal the single line the - whole-line path prints. When something else is printed in between (a - parallel call, a confirm/ask prompt) the prefix stays as its own line and - the result is printed as a complete line instead. + whole-line path prints. A later call of the same round waits its turn (see + :class:`_PrettyToolStream`); only when *other* output takes the line — a + confirm/ask prompt — does the prefix stay as its own line and the result + print as a complete line instead. """ prefix: Callable[[str], str] @@ -941,37 +942,38 @@ class _PrettyToolStream: Writing the prefix immediately keeps a blocking tool call visible; appending the detail later reproduces the single whole-line write byte for byte, so - non-interactive output is unchanged. Nothing ever moves the cursor: when - something else is printed in between — a second call of the batch (parallel - tools) or a confirm/ask prompt — the open prefix is *closed* as its own line - and the rest of the batch prints whole lines (``prefix + detail``). A prefix - wider than the terminal therefore degrades cleanly instead of leaving a - half-erased row behind. + non-interactive output is unchanged. Nothing ever moves the cursor: a round + that issues several calls holds the prefixes of the later ones until the open + row takes its own detail (results arrive in call order), so every call ends + up with exactly one finished row. Output that genuinely takes the line — a + confirm/ask prompt, streamed text, an error — closes the open prefix as its + own line instead and the rest of the batch prints whole lines; a prefix wider + than the terminal thus degrades cleanly rather than leaving a half-erased row + behind. """ _interactive: bool _open: bool _whole_lines: bool + _queued: list[str] def __init__(self, *, interactive: bool) -> None: self._interactive = interactive self._open = False self._whole_lines = False + self._queued = [] def start_call(self, prefix: str) -> None: """Show *prefix* for a call that is about to run (cursor left mid-line).""" if not (self._interactive and prefix) or self._whole_lines: return if self._open: - # Parallel batch: the open prefix can never receive its detail, so - # end it as its own line and print whole lines from here on. - self.close_line() + # Sibling call of the same round: hold its prefix back instead of + # closing the open row, which would leave the earlier call as an + # abandoned half-line and repeat its prefix on the whole line. + self._queued.append(prefix) return - click.echo(f"\n{prefix}", nl=False) - with contextlib.suppress(OSError): - _ = sys.stdout.flush() - self._open = True - _mark_open_prefix(self) + self._show_prefix(prefix) def finish_call(self, detail: str) -> bool: """Append *detail* to the open prefix; False when a whole line is needed.""" @@ -980,10 +982,14 @@ class _PrettyToolStream: click.echo(f"{detail}\n", nl=False) self._open = False _mark_open_prefix(None) + if self._queued: + # This row is complete, so the next call of the batch can have its own. + self._show_prefix(self._queued.pop(0)) return True def close_line(self) -> None: """End a dangling prefix line before other output takes the next line.""" + self._queued.clear() if not self._open: return click.echo() @@ -996,6 +1002,14 @@ class _PrettyToolStream: self.close_line() self._whole_lines = False + def _show_prefix(self, prefix: str) -> None: + """Write *prefix* and leave the cursor on it for its own detail.""" + click.echo(f"\n{prefix}", nl=False) + with contextlib.suppress(OSError): + _ = sys.stdout.flush() + self._open = True + _mark_open_prefix(self) + # The stream with a prefix line still open (cursor mid-line), or None. A confirm # box or ``ask_user_*`` prompt is printed from a worker thread while the tool @@ -1062,7 +1076,8 @@ async def render_events( # noqa: C901, PLR0912, PLR0915 Pretty tool calls print their ``* Verb 'target' `` prefix as soon as the call starts, so a blocking tool is visible while it runs; the outcome is appended - when the result lands (same bytes as the whole-line fallback). + when the result lands (same bytes as the whole-line fallback). A round that + issues several calls renders them in call order, one finished row each. """ show_full = get_verbose_tool_results() if verbose is None else verbose use_markdown = get_markdown_enabled() if markdown is None else markdown diff --git a/tests/test_cli/test_display.py b/tests/test_cli/test_display.py index 7f0480e..bfd8e21 100644 --- a/tests/test_cli/test_display.py +++ b/tests/test_cli/test_display.py @@ -997,11 +997,11 @@ async def test_pretty_verbose_keeps_full_result_output( assert "* Read 'a.txt' " not in out -async def test_pretty_parallel_batch_closes_the_open_prefix( +async def test_pretty_parallel_batch_gives_every_call_its_own_row( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - """A second call in the batch ends the open prefix; results print whole lines.""" + """A second call in the batch holds its prefix until the first row is done.""" monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True) await render_events( _aiter( @@ -1014,16 +1014,35 @@ async def test_pretty_parallel_batch_closes_the_open_prefix( ) ) out = capsys.readouterr().out - # The prefix survives as its own line; whole lines take over from there. - assert out == ("\n* Read 'a.txt' \n\n* Read 'a.txt' L1-4 (done)\n\n* Read 'b.txt' L1-9 (done)\n\n") + assert out == ("\n* Read 'a.txt' L1-4 (done)\n\n* Read 'b.txt' L1-9 (done)\n\n") assert "\x1b[" not in out # the cursor is never moved +async def test_pretty_edit_batch_prints_one_row_per_edit( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Two edits in one round: one finished row each, no abandoned prefix row.""" + monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True) + await render_events( + _aiter( + [ + _pretty_call("edit_replace", '{"path": "a.py"}'), + _pretty_call("edit_replace", '{"path": "b.py"}'), + _result("replaced 1 occurrence in a.py ('x' → 'y')"), + _result("replaced 1 occurrence in b.py ('x' → 'y')"), + ] + ) + ) + out = capsys.readouterr().out + assert out == "\n* Edit 'a.py' (done)\n\n* Edit 'b.py' (done)\n\n" + + async def test_pretty_parallel_batch_keeps_the_line_above_the_prefix( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - """Ending the open prefix must not touch the line above it. + """Rendering a batch must not touch the line above the prefix. Reasoning streams without a trailing newline, so the prefix line sits right below the reasoning text; moving the cursor up used to wipe that reasoning @@ -1042,9 +1061,7 @@ async def test_pretty_parallel_batch_keeps_the_line_above_the_prefix( ) ) out = capsys.readouterr().out - assert out == ( - "\nreasoning:\nthink\n* Read 'a.txt' \n\n* Read 'a.txt' L1-4 (done)\n\n* Read 'b.txt' L1-9 (done)\n\n\n" - ) + assert out == ("\nreasoning:\nthink\n* Read 'a.txt' L1-4 (done)\n\n* Read 'b.txt' L1-9 (done)\n\n\n") assert "\x1b[" not in out