From 41ce98bf4232262895415c1c13a46763cfc45939 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 15 Aug 2026 18:05:40 +0800 Subject: [PATCH] cli: style pretty tool summaries, verbose shows full results, preview shows first line --- src/plyngent/cli/display.py | 67 +++++++++++++++++++++++++--------- tests/test_cli/test_display.py | 38 +++++++++++++++++++ 2 files changed, 87 insertions(+), 18 deletions(-) diff --git a/src/plyngent/cli/display.py b/src/plyngent/cli/display.py index 18810fe..0e20f40 100644 --- a/src/plyngent/cli/display.py +++ b/src/plyngent/cli/display.py @@ -74,38 +74,70 @@ def _preview(text: str, limit: int) -> str: return text[:limit] + "…" -def _json_str_arg(args_json: str, key: str) -> str | None: - """Extract a string argument from a tool-call arguments JSON blob.""" +def _preview_result(content: str, limit: int) -> str: + """Non-verbose tool-result preview: first line + a ``(…N more lines)`` tail.""" + lines = content.splitlines() + if not lines: + return "(no output)" + first = _preview(lines[0], limit) + more = len(lines) - 1 + if more == 0: + return first + unit = "line" if more == 1 else "lines" + return f"{first} (…{more} more {unit})" + + +def _json_arg(args_json: str, key: str) -> object | None: + """Extract a single argument value (any type) from a tool-call args JSON blob.""" try: raw = json.loads(args_json) except ValueError: return None if isinstance(raw, dict): - value = cast("dict[str, object]", raw).get(key) - if isinstance(value, str): - return value + return cast("dict[str, object]", raw).get(key) return None +def _json_str_arg(args_json: str, key: str) -> str | None: + value = _json_arg(args_json, key) + return value if isinstance(value, str) else None + + +def _pretty_line(prefix: str, *segments: tuple[str, str | None]) -> str: + """Style a pretty tool summary: yellow ``* Verb`` prefix + colored detail segments. + + ``segments`` are ``(text, fg)`` pairs; ``fg=None`` leaves a segment uncolored + and ``"dim"`` renders it dim. click.style emits ANSI codes only on a TTY, + so non-TTY output stays plain. + """ + parts = [click.style(prefix, fg="yellow")] + for text, fg in segments: + if fg == "dim": + parts.append(click.style(text, dim=True)) + else: + parts.append(click.style(text, fg=fg)) + return "".join(parts) + + def _read_file_pretty(args_json: str, result: str) -> str: """One-line summary for a ``read_file`` call: ``* Read 'path' L1-4 (done)``.""" path = _json_str_arg(args_json, "path") or "?" if result.startswith("error: file not found"): - return f"* Read '{path}' (file not found)" + return _pretty_line(f"* Read '{path}' ", ("(file not found)", "red")) if result.startswith("error: not a file"): - return f"* Read '{path}' (not a file)" + return _pretty_line(f"* Read '{path}' ", ("(not a file)", "red")) if result.startswith("error:"): - return f"* Read '{path}' (error)" + return _pretty_line(f"* Read '{path}' ", ("(error)", "red")) first = result.splitlines()[0] if result.splitlines() else "" if first.startswith("L") and "-" in first: - return f"* Read '{path}' {first} (done)" - return f"* Read '{path}' (done)" + return _pretty_line(f"* Read '{path}' ", (f"{first} ", "dim"), ("(done)", "green")) + return _pretty_line(f"* Read '{path}' ", ("(done)", "green")) def _todo_pretty(name: str, result: str) -> str: """Summary for todo push/update: header + the rendered todo stack.""" label = "Todo Push" if name == "todo_push" else "Todo Update" - return f"* {label}:\n{result}" + return _pretty_line(f"* {label}:\n{result}") def _tree_line_stats(fmt: str, line: str) -> tuple[str, int] | None: @@ -159,8 +191,8 @@ def _tree_pretty(args_json: str, result: str) -> str: files += 1 depth = max(depth, line_depth) if depth: - return f"* Tree '{path}' ({dirs} dirs, {files} files, depth {depth})" - return f"* Tree '{path}' ({dirs} dirs, {files} files)" + return _pretty_line(f"* Tree '{path}' ", (f"({dirs} dirs, {files} files, depth {depth})", None)) + return _pretty_line(f"* Tree '{path}' ", (f"({dirs} dirs, {files} files)", None)) def _pretty_tool_result(name: str, args_json: str, result: str) -> str | None: @@ -315,14 +347,13 @@ async def render_events( # noqa: C901, PLR0912, PLR0915 content = event.message.content name, args, pretty_tool = pending_tools.pop(0) if pending_tools else ("", "", False) pretty_line = _pretty_tool_result(name, args, content) if pretty_tool else None - if pretty_line is not None: - click.secho(f"\n{pretty_line}", fg="yellow") + if pretty_line is not None and not show_full: + click.echo(f"\n{pretty_line}") elif show_full: click.secho(f"[tool ok]\n{content}", fg="magenta") else: - preview = _preview(content, _TOOL_RESULT_PREVIEW) - one_line = preview.replace("\n", " ") - click.secho(f"[tool ok] {one_line}", fg="magenta") + preview = _preview_result(content, _TOOL_RESULT_PREVIEW) + click.secho(f"[tool ok] {preview}", fg="magenta") elif isinstance(event, ErrorEvent): flush_assistant() suffix = "" diff --git a/tests/test_cli/test_display.py b/tests/test_cli/test_display.py index d1822dd..f631426 100644 --- a/tests/test_cli/test_display.py +++ b/tests/test_cli/test_display.py @@ -248,3 +248,41 @@ def test_markdown_render_available_respects_plain_env(monkeypatch: pytest.Monkey monkeypatch.setenv("PLYNGENT_PLAIN", "1") assert markdown_render_available() is False monkeypatch.delenv("PLYNGENT_PLAIN", raising=False) + + +async def test_pretty_verbose_shows_full_result(capsys: pytest.CaptureFixture[str]) -> None: + """Verbose mode prints the full tool result even for pretty tools.""" + await render_events( + _aiter([_read_call('{"path": "a.txt"}'), _result("L1-4\none\ntwo\n")]), + verbose=True, + ) + out = capsys.readouterr().out + assert "[tool ok]" in out + assert "L1-4\none\ntwo" in out + assert "* Read" not in out + + +async def test_pretty_plain_output_has_no_ansi(capsys: pytest.CaptureFixture[str]) -> None: + """Pretty summaries are plain text when stdout is not a TTY.""" + await render_events(_aiter([_read_call('{"path": "a.txt"}'), _result("L1-4\none\n")])) + out = capsys.readouterr().out + assert "\x1b[" not in out + + +async def test_tool_result_preview_first_line_and_count(capsys: pytest.CaptureFixture[str]) -> None: + """Non-verbose preview shows the first line plus a line-count tail.""" + set_markdown_enabled(False) + content = "first line\nsecond\nthird" + await render_events(_aiter([ToolResultEvent(message=ToolChatMessage(content=content, tool_call_id="1"))])) + out = capsys.readouterr().out + assert "[tool ok] first line (…2 more lines)" in out + + await render_events(_aiter([ToolResultEvent(message=ToolChatMessage(content="only line", tool_call_id="2"))])) + out = capsys.readouterr().out + assert "[tool ok] only line" in out + assert "more" not in out + + await render_events(_aiter([ToolResultEvent(message=ToolChatMessage(content="", tool_call_id="3"))])) + out = capsys.readouterr().out + assert "[tool ok] (no output)" in out + set_markdown_enabled(True)