diff --git a/src/plyngent/cli/display.py b/src/plyngent/cli/display.py index 903da0f..59375a2 100644 --- a/src/plyngent/cli/display.py +++ b/src/plyngent/cli/display.py @@ -224,6 +224,8 @@ _TODO_LABELS: dict[str, str] = { "todo_clear": "Todo Clear", } +_TODO_TOOLS: frozenset[str] = frozenset(_TODO_LABELS) + def _todo_line(name: str) -> PrettyLine: """Todo tools: header first, the rendered stack as the detail.""" @@ -238,6 +240,18 @@ def _todo_line(name: str) -> PrettyLine: return PrettyLine(prefix=prefix, detail=detail) +def _echo_todo_batch(labels: list[str], detail: str) -> None: + """One line for a batch of todo calls, then the last result (its stack). + + A model that updates its list in one round used to print one header and a + full stack render per call; the last result already carries the final stack, + so one summary line plus that result says the same thing in fewer rows. + """ + ops = ", ".join(label.removeprefix("Todo ") for label in labels) + click.secho(f"\n* Todo x{len(labels)}: {ops}", fg="yellow") + click.echo(detail) + + def _tree_line_stats(fmt: str, line: str) -> tuple[str, int] | None: """Classify one ``tree`` result line as ``(kind, depth)`` or None (not an entry). @@ -1062,6 +1076,12 @@ async def render_events( # noqa: C901, PLR0912, PLR0915 # Tool calls buffer so prettified tools can render once their result lands # (the summary line needs the status/range). FIFO matches loop event order. pending_tools: list[tuple[str, str, PrettyLine | None]] = [] + # A batch (one round's tool calls) that only touches todos prints one summary + # line plus its last result, whose text carries the final stack. Collected as + # the labels seen so far and that result's detail, flushed when the batch ends. + todo_labels: list[str] = [] + merged_todo = False + merged_detail: str | None = None def flush_assistant() -> None: nonlocal source, assistant_buf, printed_assistant @@ -1115,14 +1135,25 @@ async def render_events( # noqa: C901, PLR0912, PLR0915 name = call.function.name args = call.function.arguments line = _pretty_line_for(name) + if not pending_tools: + # First call of a batch: a batch that only touches todos is + # printed as one merged block once its last result lands. + todo_labels.clear() + merged_todo = name in _TODO_TOOLS pending_tools.append((name, args, line)) - if line is not None: + if name in _TODO_TOOLS: + if merged_todo: + todo_labels.append(_TODO_LABELS[name]) + elif line is not None: + merged_todo = False tool_lines.start_call(line.prefix(args)) else: + merged_todo = False tool_lines.close_line() preview = _preview(args, _TOOL_ARGS_PREVIEW) click.secho(f"\n[tool] {name}({preview})", fg="yellow") else: + merged_todo = False tool_lines.close_line() pending_tools.append(("custom", call.id, None)) click.secho(f"\n[tool] custom id={call.id}", fg="yellow") @@ -1133,7 +1164,14 @@ async def render_events( # noqa: C901, PLR0912, PLR0915 parts = _pretty_parts(line, args, content) if line is not None else None if parts is not None and not show_full: prefix, detail = parts - if not tool_lines.finish_call(detail): + if name in _TODO_TOOLS: + if merged_todo and len(todo_labels) > 1: + # Merged batch: the batch end prints the last result. + merged_detail = detail + else: + tool_lines.close_line() + click.echo(f"\n{prefix}{detail}") + elif not tool_lines.finish_call(detail): click.echo(f"\n{prefix}{detail}") elif show_full: tool_lines.close_line() @@ -1143,6 +1181,9 @@ async def render_events( # noqa: C901, PLR0912, PLR0915 preview = _preview_result(content, _TOOL_RESULT_PREVIEW) click.secho(f"[tool ok] {preview}", fg="magenta") if not pending_tools: + if merged_detail is not None: + _echo_todo_batch(todo_labels, merged_detail) + merged_detail = None tool_lines.end_batch() elif isinstance(event, ErrorEvent): flush_assistant() diff --git a/tests/test_cli/test_display.py b/tests/test_cli/test_display.py index 0da8f01..7f0480e 100644 --- a/tests/test_cli/test_display.py +++ b/tests/test_cli/test_display.py @@ -186,6 +186,51 @@ async def test_pretty_todo_push(capsys: pytest.CaptureFixture[str]) -> None: assert "[tool]" not in out +async def test_pretty_todo_batch_merges_to_one_stack(capsys: pytest.CaptureFixture[str]) -> None: + """A batch of todo calls: one summary line, then the last result's stack.""" + stack = "(LIFO of groups: depth=1; TOP group = current breakdown level)\ngroup d=0 TOP:\n [ ] a1: T1" + await render_events( + _aiter( + [ + _pretty_call("todo_push", '{"titles": ["T1"]}'), + _pretty_call("todo_update", '{"item_id": "a1", "status": "done"}'), + _result(f"pushed group (depth=1) items=[a1]\n{stack}"), + _result(f"updated a1 → done: T1\n{stack}"), + ] + ) + ) + out = capsys.readouterr().out + assert "* Todo x2: Push, Update" in out + assert "* Todo Push:" not in out + assert "* Todo Update:" not in out + # Only the last result is printed, so the stack renders once. + assert out.count("group d=0 TOP:") == 1 + assert "pushed group" not in out + assert "updated a1 → done: T1" in out + + +async def test_pretty_todo_batch_mixed_with_other_tool_stays_per_call( + capsys: pytest.CaptureFixture[str], +) -> None: + """A batch holding a non-todo call keeps one line (and stack) per todo call.""" + stack = "group d=0 TOP:\n [ ] a1: T1" + await render_events( + _aiter( + [ + _pretty_call("todo_push", '{"titles": ["T1"]}'), + _pretty_call("listdir", '{"path": "src"}'), + _result(f"pushed group (depth=1) items=[a1]\n{stack}"), + _result("dir\tsrc\nfile\tREADME.md\n"), + ] + ) + ) + out = capsys.readouterr().out + assert "* Todo Push:" in out + assert "group d=0 TOP:" in out + assert "src" in out + assert "* Todo x" not in out + + async def test_non_pretty_tool_keeps_old_style(capsys: pytest.CaptureFixture[str]) -> None: """Tools with no pretty line (plugins, unknown names) keep the ``[tool]`` style.""" call = ToolCallEvent(