diff --git a/src/plyngent/cli/display.py b/src/plyngent/cli/display.py index 0e20f40..0bcd202 100644 --- a/src/plyngent/cli/display.py +++ b/src/plyngent/cli/display.py @@ -22,7 +22,7 @@ from plyngent.agent import ( from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall if TYPE_CHECKING: - from collections.abc import AsyncIterator + from collections.abc import AsyncIterator, Callable from plyngent.agent import AgentEvent @@ -31,7 +31,17 @@ _TOOL_ARGS_PREVIEW = 80 # Tool calls that render as a single pretty summary line instead of # ``[tool]`` / ``[tool result]``. -_PRETTY_TOOLS = frozenset({"read_file", "todo_push", "todo_update", "tree"}) +_PRETTY_TOOLS = frozenset( + { + "read_file", + "todo_push", + "todo_update", + "tree", + "listdir", + "glob_paths", + "grep_files", + } +) # Process/session display flags (set from ReplState / slash). _verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False) @@ -195,15 +205,71 @@ def _tree_pretty(args_json: str, result: str) -> str: return _pretty_line(f"* Tree '{path}' ", (f"({dirs} dirs, {files} files)", None)) +def _listdir_pretty(args_json: str, result: str) -> str: + """One-line summary for a ``listdir`` call: ``* List 'path' (3 dirs, 5 files)``.""" + path = _json_str_arg(args_json, "path") or "." + if result.startswith("error:"): + return _pretty_line(f"* List '{path}' ", (f"({result})", "red")) + if result == "(empty)": + return _pretty_line(f"* List '{path}' ", ("(empty)", "dim")) + dirs = 0 + files = 0 + for line in result.splitlines(): + if line.startswith("dir\t"): + dirs += 1 + elif line.startswith("file\t"): + files += 1 + return _pretty_line(f"* List '{path}' ", (f"({dirs} dirs, {files} files)", None)) + + +def _glob_pretty(args_json: str, result: str) -> str: + """One-line summary for a ``glob_paths`` call: ``* Glob '**/*.py' in '.' (12 paths)``.""" + pattern = _json_str_arg(args_json, "pattern") or "?" + path = _json_str_arg(args_json, "path") or "." + if result.startswith("error:"): + return _pretty_line(f"* Glob '{pattern}' in '{path}' ", (f"({result})", "red")) + if result == "(no matches)": + return _pretty_line(f"* Glob '{pattern}' in '{path}' ", ("(no matches)", "dim")) + count = sum(1 for line in result.splitlines() if line and not line.startswith("...[truncated")) + unit = "path" if count == 1 else "paths" + return _pretty_line(f"* Glob '{pattern}' in '{path}' ", (f"({count} {unit})", None)) + + +def _grep_pretty(args_json: str, result: str) -> str: + """One-line summary for a ``grep_files`` call: ``* Grep 'pat' in '.' (12 matches in 3 files)``.""" + pattern = _json_str_arg(args_json, "pattern") or "?" + path = _json_str_arg(args_json, "path") or "." + if result.startswith("error:"): + return _pretty_line(f"* Grep '{pattern}' in '{path}' ", (f"({result})", "red")) + if result == "(no matches)": + return _pretty_line(f"* Grep '{pattern}' in '{path}' ", ("(no matches)", "dim")) + matches = [line for line in result.splitlines() if line and not line.startswith("...[truncated")] + files = len({line.split(":", 1)[0] for line in matches}) + match_unit = "match" if len(matches) == 1 else "matches" + file_unit = "file" if files == 1 else "files" + return _pretty_line( + f"* Grep '{pattern}' in '{path}' ", + (f"({len(matches)} {match_unit} in {files} {file_unit})", None), + ) + + +_PRETTY_BUILDERS: dict[str, Callable[[str, str], str]] = { + "read_file": _read_file_pretty, + "tree": _tree_pretty, + "todo_push": lambda _args, result: _todo_pretty("todo_push", result), + "todo_update": lambda _args, result: _todo_pretty("todo_update", result), + "listdir": _listdir_pretty, + "glob_paths": _glob_pretty, + "grep_files": _grep_pretty, +} + + def _pretty_tool_result(name: str, args_json: str, result: str) -> str | None: """Pretty summary for a known tool call + result; None keeps the old style.""" - if name == "read_file": - return _read_file_pretty(args_json, result) - if name == "tree": - return _tree_pretty(args_json, result) - if name in {"todo_push", "todo_update"}: - return _todo_pretty(name, result) - return None + builder = _PRETTY_BUILDERS.get(name) + if builder is None: + return None + return builder(args_json, result) def _echo_stream(text: str) -> None: diff --git a/tests/test_cli/test_display.py b/tests/test_cli/test_display.py index f631426..804992e 100644 --- a/tests/test_cli/test_display.py +++ b/tests/test_cli/test_display.py @@ -286,3 +286,66 @@ async def test_tool_result_preview_first_line_and_count(capsys: pytest.CaptureFi out = capsys.readouterr().out assert "[tool ok] (no output)" in out set_markdown_enabled(True) + + +def _pretty_call(name: str, args_json: str) -> ToolCallEvent: + return ToolCallEvent( + tool_call=AssistantFunctionToolCall( + id="1", + function=AssistantFunctionTool(name=name, arguments=args_json), + ) + ) + + +async def test_pretty_listdir(capsys: pytest.CaptureFixture[str]) -> None: + result = "dir\tsrc\nfile\tREADME.md\nfile\tpyproject.toml\n" + await render_events(_aiter([_pretty_call("listdir", '{"path": "src"}'), _result(result)])) + out = capsys.readouterr().out + assert "* List 'src' (1 dirs, 2 files)" in out + assert "[tool]" not in out + assert "[tool ok]" not in out + + +async def test_pretty_listdir_empty(capsys: pytest.CaptureFixture[str]) -> None: + await render_events(_aiter([_pretty_call("listdir", '{"path": "src"}'), _result("(empty)")])) + out = capsys.readouterr().out + assert "* List 'src' (empty)" in out + + +async def test_pretty_listdir_error(capsys: pytest.CaptureFixture[str]) -> None: + await render_events(_aiter([_pretty_call("listdir", '{"path": "nope"}'), _result("error: not a directory: nope")])) + out = capsys.readouterr().out + assert "* List 'nope' (error: not a directory: nope)" in out + + +async def test_pretty_glob(capsys: pytest.CaptureFixture[str]) -> None: + result = "src/a.py\nsrc/b.py\n" + await render_events(_aiter([_pretty_call("glob_paths", '{"pattern": "**/*.py"}'), _result(result)])) + out = capsys.readouterr().out + assert "* Glob '**/*.py' in '.' (2 paths)" in out + + +async def test_pretty_glob_no_matches(capsys: pytest.CaptureFixture[str]) -> None: + await render_events(_aiter([_pretty_call("glob_paths", '{"pattern": "**/*.rs"}'), _result("(no matches)")])) + out = capsys.readouterr().out + assert "* Glob '**/*.rs' in '.' (no matches)" in out + + +async def test_pretty_glob_truncated(capsys: pytest.CaptureFixture[str]) -> None: + result = "a\nb\n...[truncated at 200 matches]" + await render_events(_aiter([_pretty_call("glob_paths", '{"pattern": "*"}'), _result(result)])) + out = capsys.readouterr().out + assert "* Glob '*' in '.' (2 paths)" in out + + +async def test_pretty_grep(capsys: pytest.CaptureFixture[str]) -> None: + result = "src/a.py:3: x = 1\nsrc/a.py:9: x = 2\nsrc/b.py:1: y = 3\n" + await render_events(_aiter([_pretty_call("grep_files", '{"pattern": "x"}'), _result(result)])) + out = capsys.readouterr().out + assert "* Grep 'x' in '.' (3 matches in 2 files)" in out + + +async def test_pretty_grep_no_matches(capsys: pytest.CaptureFixture[str]) -> None: + await render_events(_aiter([_pretty_call("grep_files", '{"pattern": "zzz"}'), _result("(no matches)")])) + out = capsys.readouterr().out + assert "* Grep 'zzz' in '.' (no matches)" in out