mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
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:
+33
-18
@@ -182,9 +182,10 @@ class PrettyLine:
|
|||||||
``prefix`` is written as soon as the call starts — a blocking tool call is
|
``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
|
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
|
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
|
whole-line path prints. A later call of the same round waits its turn (see
|
||||||
parallel call, a confirm/ask prompt) the prefix stays as its own line and
|
:class:`_PrettyToolStream`); only when *other* output takes the line — a
|
||||||
the result is printed as a complete line instead.
|
confirm/ask prompt — does the prefix stay as its own line and the result
|
||||||
|
print as a complete line instead.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
prefix: Callable[[str], str]
|
prefix: Callable[[str], str]
|
||||||
@@ -941,37 +942,38 @@ class _PrettyToolStream:
|
|||||||
|
|
||||||
Writing the prefix immediately keeps a blocking tool call visible; appending
|
Writing the prefix immediately keeps a blocking tool call visible; appending
|
||||||
the detail later reproduces the single whole-line write byte for byte, so
|
the detail later reproduces the single whole-line write byte for byte, so
|
||||||
non-interactive output is unchanged. Nothing ever moves the cursor: when
|
non-interactive output is unchanged. Nothing ever moves the cursor: a round
|
||||||
something else is printed in between — a second call of the batch (parallel
|
that issues several calls holds the prefixes of the later ones until the open
|
||||||
tools) or a confirm/ask prompt — the open prefix is *closed* as its own line
|
row takes its own detail (results arrive in call order), so every call ends
|
||||||
and the rest of the batch prints whole lines (``prefix + detail``). A prefix
|
up with exactly one finished row. Output that genuinely takes the line — a
|
||||||
wider than the terminal therefore degrades cleanly instead of leaving a
|
confirm/ask prompt, streamed text, an error — closes the open prefix as its
|
||||||
half-erased row behind.
|
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
|
_interactive: bool
|
||||||
_open: bool
|
_open: bool
|
||||||
_whole_lines: bool
|
_whole_lines: bool
|
||||||
|
_queued: list[str]
|
||||||
|
|
||||||
def __init__(self, *, interactive: bool) -> None:
|
def __init__(self, *, interactive: bool) -> None:
|
||||||
self._interactive = interactive
|
self._interactive = interactive
|
||||||
self._open = False
|
self._open = False
|
||||||
self._whole_lines = False
|
self._whole_lines = False
|
||||||
|
self._queued = []
|
||||||
|
|
||||||
def start_call(self, prefix: str) -> None:
|
def start_call(self, prefix: str) -> None:
|
||||||
"""Show *prefix* for a call that is about to run (cursor left mid-line)."""
|
"""Show *prefix* for a call that is about to run (cursor left mid-line)."""
|
||||||
if not (self._interactive and prefix) or self._whole_lines:
|
if not (self._interactive and prefix) or self._whole_lines:
|
||||||
return
|
return
|
||||||
if self._open:
|
if self._open:
|
||||||
# Parallel batch: the open prefix can never receive its detail, so
|
# Sibling call of the same round: hold its prefix back instead of
|
||||||
# end it as its own line and print whole lines from here on.
|
# closing the open row, which would leave the earlier call as an
|
||||||
self.close_line()
|
# abandoned half-line and repeat its prefix on the whole line.
|
||||||
|
self._queued.append(prefix)
|
||||||
return
|
return
|
||||||
click.echo(f"\n{prefix}", nl=False)
|
self._show_prefix(prefix)
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
_ = sys.stdout.flush()
|
|
||||||
self._open = True
|
|
||||||
_mark_open_prefix(self)
|
|
||||||
|
|
||||||
def finish_call(self, detail: str) -> bool:
|
def finish_call(self, detail: str) -> bool:
|
||||||
"""Append *detail* to the open prefix; False when a whole line is needed."""
|
"""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)
|
click.echo(f"{detail}\n", nl=False)
|
||||||
self._open = False
|
self._open = False
|
||||||
_mark_open_prefix(None)
|
_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
|
return True
|
||||||
|
|
||||||
def close_line(self) -> None:
|
def close_line(self) -> None:
|
||||||
"""End a dangling prefix line before other output takes the next line."""
|
"""End a dangling prefix line before other output takes the next line."""
|
||||||
|
self._queued.clear()
|
||||||
if not self._open:
|
if not self._open:
|
||||||
return
|
return
|
||||||
click.echo()
|
click.echo()
|
||||||
@@ -996,6 +1002,14 @@ class _PrettyToolStream:
|
|||||||
self.close_line()
|
self.close_line()
|
||||||
self._whole_lines = False
|
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
|
# 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
|
# 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
|
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
|
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
|
show_full = get_verbose_tool_results() if verbose is None else verbose
|
||||||
use_markdown = get_markdown_enabled() if markdown is None else markdown
|
use_markdown = get_markdown_enabled() if markdown is None else markdown
|
||||||
|
|||||||
@@ -997,11 +997,11 @@ async def test_pretty_verbose_keeps_full_result_output(
|
|||||||
assert "* Read 'a.txt' " not in out
|
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,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
) -> None:
|
) -> 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)
|
monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True)
|
||||||
await render_events(
|
await render_events(
|
||||||
_aiter(
|
_aiter(
|
||||||
@@ -1014,16 +1014,35 @@ async def test_pretty_parallel_batch_closes_the_open_prefix(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
# The prefix survives as its own line; whole lines take over from there.
|
assert out == ("\n* Read 'a.txt' L1-4 (done)\n\n* Read 'b.txt' L1-9 (done)\n\n")
|
||||||
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 "\x1b[" not in out # the cursor is never moved
|
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(
|
async def test_pretty_parallel_batch_keeps_the_line_above_the_prefix(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
) -> None:
|
) -> 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
|
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
|
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
|
out = capsys.readouterr().out
|
||||||
assert out == (
|
assert out == ("\nreasoning:\nthink\n* Read 'a.txt' L1-4 (done)\n\n* Read 'b.txt' L1-9 (done)\n\n\n")
|
||||||
"\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 "\x1b[" not in out
|
assert "\x1b[" not in out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user