mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
cli: pretty printed summaries for listdir, glob_paths and grep_files
This commit is contained in:
@@ -22,7 +22,7 @@ from plyngent.agent import (
|
|||||||
from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall
|
from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator, Callable
|
||||||
|
|
||||||
from plyngent.agent import AgentEvent
|
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 calls that render as a single pretty summary line instead of
|
||||||
# ``[tool]`` / ``[tool result]``.
|
# ``[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).
|
# Process/session display flags (set from ReplState / slash).
|
||||||
_verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False)
|
_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))
|
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:
|
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."""
|
"""Pretty summary for a known tool call + result; None keeps the old style."""
|
||||||
if name == "read_file":
|
builder = _PRETTY_BUILDERS.get(name)
|
||||||
return _read_file_pretty(args_json, result)
|
if builder is None:
|
||||||
if name == "tree":
|
return None
|
||||||
return _tree_pretty(args_json, result)
|
return builder(args_json, result)
|
||||||
if name in {"todo_push", "todo_update"}:
|
|
||||||
return _todo_pretty(name, result)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _echo_stream(text: str) -> None:
|
def _echo_stream(text: str) -> None:
|
||||||
|
|||||||
@@ -286,3 +286,66 @@ async def test_tool_result_preview_first_line_and_count(capsys: pytest.CaptureFi
|
|||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "[tool ok] (no output)" in out
|
assert "[tool ok] (no output)" in out
|
||||||
set_markdown_enabled(True)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user