diff --git a/src/plyngent/cli/display.py b/src/plyngent/cli/display.py index abc24f0..18810fe 100644 --- a/src/plyngent/cli/display.py +++ b/src/plyngent/cli/display.py @@ -31,7 +31,7 @@ _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"}) +_PRETTY_TOOLS = frozenset({"read_file", "todo_push", "todo_update", "tree"}) # Process/session display flags (set from ReplState / slash). _verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False) @@ -108,10 +108,67 @@ def _todo_pretty(name: str, result: str) -> str: return f"* {label}:\n{result}" +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). + + ``kind`` is ``"dir"`` or ``"file"``; depth is 0 for ``flat`` (no hierarchy). + """ + stripped = line.strip() + if not stripped: + return None + kind: str | None = None + depth = 0 + if fmt == "flat": + if not stripped.startswith("…"): + kind = "dir" if stripped.endswith("/") else "file" + elif fmt == "markdown": + if stripped.startswith("- ") and not stripped.startswith(("- …", "- error")): + kind = "dir" if stripped[2:].endswith("/") else "file" + depth = (len(line) - len(line.lstrip(" "))) // 2 + 1 + elif "… (" not in stripped: + branch = "├── " if "├── " in line else ("└── " if "└── " in line else None) + if branch is not None: + name = line[line.index(branch) + len(branch) :].rstrip() + kind = "dir" if name.endswith("/") else "file" + depth = line.index(branch) // 4 + 1 + if kind is None: + return None + return kind, depth + + +def _tree_pretty(args_json: str, result: str) -> str: + """One-line summary for a ``tree`` call: ``* Tree 'src' (3 dirs, 24 files, depth 3)``. + + Flat trees have no depth, so the depth part is omitted for ``format=flat``. + """ + path = _json_str_arg(args_json, "path") or "." + fmt = _json_str_arg(args_json, "format") or "markdown" + if result.startswith("error:"): + return f"* Tree '{path}' (error)" + dirs = 0 + files = 0 + depth = 0 + for line in result.splitlines(): + stats = _tree_line_stats(fmt, line) + if stats is None: + continue + kind, line_depth = stats + if kind == "dir": + dirs += 1 + else: + 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)" + + 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 diff --git a/tests/test_cli/test_display.py b/tests/test_cli/test_display.py index 0924c8e..d1822dd 100644 --- a/tests/test_cli/test_display.py +++ b/tests/test_cli/test_display.py @@ -116,6 +116,44 @@ async def test_pretty_read_file_statuses(capsys: pytest.CaptureFixture[str]) -> assert f"* Read 'a.txt' {status}" in out, f"status {status} not rendered for {content!r}" +def _tree_call(args_json: str) -> ToolCallEvent: + return ToolCallEvent( + tool_call=AssistantFunctionToolCall( + id="1", + function=AssistantFunctionTool(name="tree", arguments=args_json), + ) + ) + + +async def test_pretty_tree_markdown(capsys: pytest.CaptureFixture[str]) -> None: + result = "- src/\n - main.py\n - nested/\n - deep.txt\n- a.txt\n" + await render_events(_aiter([_tree_call('{"path": "."}'), _result(result)])) + out = capsys.readouterr().out + assert "* Tree '.' (2 dirs, 3 files, depth 3)" in out + assert "[tool]" not in out + + +async def test_pretty_tree_flat_omits_depth(capsys: pytest.CaptureFixture[str]) -> None: + result = "src/\nsrc/main.py\na.txt\n… (5 more paths not shown)" + await render_events(_aiter([_tree_call('{"path": ".", "format": "flat"}'), _result(result)])) + out = capsys.readouterr().out + assert "* Tree '.' (1 dirs, 2 files)" in out + assert "depth" not in out + + +async def test_pretty_tree_decorated(capsys: pytest.CaptureFixture[str]) -> None: + result = "src/\n├── main.py\n└── nested/\n └── deep.txt\n" + await render_events(_aiter([_tree_call('{"path": "src", "format": "decorated"}'), _result(result)])) + out = capsys.readouterr().out + assert "* Tree 'src' (1 dirs, 2 files, depth 2)" in out + + +async def test_pretty_tree_error(capsys: pytest.CaptureFixture[str]) -> None: + await render_events(_aiter([_tree_call('{"path": "nope"}'), _result("error: not a directory: nope")])) + out = capsys.readouterr().out + assert "* Tree 'nope' (error)" in out + + async def test_pretty_todo_push(capsys: pytest.CaptureFixture[str]) -> None: call = ToolCallEvent( tool_call=AssistantFunctionToolCall(