cli/display: hold a batch's later prefixes until the open row is done

A round that asked for several calls printed the first one as an abandoned
half-line: the next prefix closed the still-open row (a bare newline), the
result then printed whole and repeated that prefix, and the rest of the batch
was suppressed. The model edits files in batches, so the duplicate row showed
up there most. Queue the later prefixes instead: each one opens its own row
once the row before it has taken its detail, which keeps one finished row per
call in call order and leaves the bytes identical to the non-interactive path.
Only output that genuinely takes the line — a prompt box, streamed text, an
error — still ends the open prefix as its own line. The cursor is still never
moved.
This commit is contained in:
2026-10-06 21:38:03 +08:00
parent 66b771f87d
commit 768b310a0b
2 changed files with 58 additions and 26 deletions
+33 -18
View File
@@ -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
+25 -8
View File
@@ -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