diff --git a/clients/terminal/tui/renderers/__init__.py b/clients/terminal/tui/renderers/__init__.py index cf33a5d..5ba71c3 100644 --- a/clients/terminal/tui/renderers/__init__.py +++ b/clients/terminal/tui/renderers/__init__.py @@ -18,9 +18,10 @@ reg.register(subagent.SpawnAgentResultRenderer()) reg.register(todo.TodoStartedRenderer()) reg.register(todo.TodoResultRenderer()) + # filesystem-specific renderers must be checked before the generic tool + # renderers (first accepting renderer wins). + reg.register(filesystem.FilesystemToolStartedRenderer()) reg.register(tool.ToolStartedRenderer()) - # filesystem-specific result renderer must be checked before the generic - # ToolResultRenderer (first accepting renderer wins). reg.register(filesystem.FilesystemToolResultRenderer()) reg.register(tool.ToolResultRenderer()) reg.register(error.ErrorRenderer()) diff --git a/clients/terminal/tui/renderers/filesystem.py b/clients/terminal/tui/renderers/filesystem.py index d7dbfc1..0ea5922 100644 --- a/clients/terminal/tui/renderers/filesystem.py +++ b/clients/terminal/tui/renderers/filesystem.py @@ -1,12 +1,17 @@ -"""Styled renderer for ``filesystem`` tool-call results. +"""Styled renderers for ``filesystem`` tool events. -The filesystem tool embeds structured output (unified diffs, directory listings, -grep matches) as plain text in ``ToolResult.output``. This renderer detects the -action via ``args.action`` and restyles the output — diff highlighting, listing -layout, grep match highlighting — without touching the tool or the WebSocket -protocol. Unknown / not-yet-styled actions fall back to the generic plain -rendering used by :class:`ToolResultRenderer`. -""" +Two renderers: + +* :class:`FilesystemToolStartedRenderer` — restyles the ``tool_started`` card: + the action and primary path go into the title (``→ filesystem read + src/main.py``), and the body shows the key arguments compactly instead of a + full JSON dump, so large ``content`` / ``old`` / ``new`` values no longer + flood the card. +* :class:`FilesystemToolResultRenderer` — restyles the ``tool_call`` result + (diff highlighting, listing layout, grep match highlighting, read/info/find + formatting). + +Neither touches the filesystem tool, the WebSocket protocol, or events. """ from __future__ import annotations @@ -326,4 +331,105 @@ out.append(m.group(0), style=theme.accent.hex) last = m.end() if last < len(text): - out.append(text[last:], style=theme.text.hex) \ No newline at end of file + out.append(text[last:], style=theme.text.hex) + +# Actions whose title carries "path → destination". +_DEST_ACTIONS = {"move", "copy", "diff"} +_PREVIEW_MAX = 60 + + +def _preview(s: str | None, max_len: int = _PREVIEW_MAX) -> str: + """Shorten a string to its first line plus a ``(+N lines)`` hint.""" + if not s: + return '""' + lines = s.splitlines() + first = lines[0] + more = len(lines) - 1 + if len(first) > max_len: + first = first[: max_len - 1] + "…" + if more > 0: + return f'"{first}" … (+{more} lines)' + return f'"{first}"' + + +class FilesystemToolStartedRenderer(ContentRenderer): + """Styled ``tool_started`` card for ``filesystem``. + + Title: ``→ filesystem `` (plus ``→ `` for + move/copy/diff). Body: a compact, human-readable summary of the key + arguments per action — large ``content`` / ``old`` / ``new`` / + ``instruction`` / ``question`` values are previewed, not dumped in full. + """ + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "tool_started" and msg.get("tool") == "filesystem" + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + args = msg.get("args") or {} + action = args.get("action", "?") + path = args.get("path") + + title = f"→ filesystem {action}" + if path: + title += f" {path}" + if action in _DEST_ACTIONS and args.get("destination"): + title += f" → {args['destination']}" + + body = self._summarize(action, args, theme) + panel = Panel( + body if body.plain else Text(""), + title=title, + title_align="left", + border_style=theme.tool_border.hex, + box=ROUNDED, + ) + if bool(msg.get("is_subagent", False)): + return Padding(panel, (0, 0, 0, 2)) + return panel + + def _summarize(self, action: str, args: dict, theme: Theme) -> Text: + out = Text() + + def kv(key: str, value: str, value_style: str = theme.text.hex) -> None: + if out.plain: + out.append("\n") + out.append(key, style=theme.text_dim.hex) + out.append(": ", style=theme.text_dim.hex) + out.append(str(value), style=value_style) + + if action == "read": + offset = args.get("offset") + limit = args.get("limit") + if offset is not None or limit is not None: + kv("range", f"{offset if offset is not None else 1}–{limit if limit is not None else 'end'}") + if args.get("numbered"): + kv("numbered", "true") + elif action in {"write", "append"}: + kv("content", _preview(args.get("content", ""))) + elif action == "edit": + kv("old", _preview(args.get("old", ""))) + kv("new", _preview(args.get("new", ""))) + elif action == "edit_lines": + ops = args.get("operations") or [] + kinds = [str(o.get("op", "?")) for o in ops[:5]] + tail = " …" if len(ops) > 5 else "" + kv("operations", f"{len(ops)}: {', '.join(kinds)}{tail}" if ops else "0") + elif action == "smart_edit": + kv("instruction", _preview(args.get("instruction", ""))) + elif action == "list": + if args.get("recursive"): + kv("recursive", "true") + elif action in {"find", "find_up"}: + kv("pattern", str(args.get("pattern", ""))) + elif action == "grep": + kv("pattern", str(args.get("pattern", ""))) + if args.get("glob"): + kv("glob", str(args.get("glob"))) + if args.get("regex"): + kv("regex", "true") + elif action == "query": + kv("question", _preview(args.get("question", ""))) + # move/copy/diff: destination is in the title. + # info/delete/mkdir/exists: only the path, which is in the title. + return out diff --git a/tests/clients/test_filesystem_renderer.py b/tests/clients/test_filesystem_renderer.py index 6de92cc..dfe4ff2 100644 --- a/tests/clients/test_filesystem_renderer.py +++ b/tests/clients/test_filesystem_renderer.py @@ -1,11 +1,14 @@ -"""Tests for the filesystem tool-call result renderer (diff / list / grep).""" +"""Tests for the filesystem tool renderers (tool_started + tool_call result).""" from __future__ import annotations from rich.console import Console from rich.text import Text -from clients.terminal.tui.renderers.filesystem import FilesystemToolResultRenderer +from clients.terminal.tui.renderers.filesystem import ( + FilesystemToolResultRenderer, + FilesystemToolStartedRenderer, +) from clients.terminal.tui.themes import get_active_theme, set_active_theme @@ -565,4 +568,163 @@ {"type": "tool_call", "tool": "filesystem", "args": {"action": "read"}, "result": "x", "success": True, "is_subagent": False} ) - assert any(line.startswith("╭") for line in _render_text(renderable).splitlines()) \ No newline at end of file + assert any(line.startswith("╭") for line in _render_text(renderable).splitlines()) + +# ── tool_started ────────────────────────────────────────────────────────────── + + +def _started_msg(args: dict, **extra) -> dict: + msg = {"type": "tool_started", "tool": "filesystem", "args": args} + msg.update(extra) + return msg + + +def test_started_accepts_filesystem_only() -> None: + renderer = FilesystemToolStartedRenderer() + assert renderer.accepts(_started_msg({"action": "read", "path": "a.py"})) + assert not renderer.accepts({"type": "tool_started", "tool": "code_exec", "args": {}}) + assert not renderer.accepts({"type": "tool_call", "tool": "filesystem", "args": {}}) + + +def test_started_title_carries_action_and_path() -> None: + set_active_theme("gnexus-dark") + renderer = FilesystemToolStartedRenderer() + panel = renderer.render(_started_msg({"action": "read", "path": "src/main.py"})) + title = str(panel.title) + assert "filesystem" in title + assert "read" in title + assert "src/main.py" in title + + +def test_started_title_carries_destination_for_move_copy_diff() -> None: + set_active_theme("gnexus-dark") + renderer = FilesystemToolStartedRenderer() + move = renderer.render(_started_msg({"action": "move", "path": "a.py", "destination": "b.py"})) + assert "→" in str(move.title) + assert "b.py" in str(move.title) + diff = renderer.render(_started_msg({"action": "diff", "path": "a.py", "destination": "b.py"})) + assert "b.py" in str(diff.title) + # Non-destination actions do not add the arrow. + read = renderer.render(_started_msg({"action": "read", "path": "a.py", "destination": "x"})) + assert "→ x" not in str(read.title) + + +def test_started_info_has_empty_body() -> None: + set_active_theme("gnexus-dark") + renderer = FilesystemToolStartedRenderer() + panel = renderer.render(_started_msg({"action": "info", "path": "a.py"})) + body = _body(panel) + assert isinstance(body, Text) + assert body.plain == "" + + +def test_started_read_shows_range_and_numbered() -> None: + set_active_theme("gnexus-dark") + renderer = FilesystemToolStartedRenderer() + panel = renderer.render( + _started_msg({"action": "read", "path": "a.py", "offset": 5, "limit": 10, "numbered": True}) + ) + body = _body(panel) + assert "range" in body.plain + assert "5–10" in body.plain + assert "numbered" in body.plain + + +def test_started_write_previews_multiline_content() -> None: + set_active_theme("gnexus-dark") + renderer = FilesystemToolStartedRenderer() + panel = renderer.render( + _started_msg({"action": "write", "path": "a.py", "content": "line1\nline2\nline3\n"}) + ) + body = _body(panel) + assert '"line1"' in body.plain + assert "(+2 lines)" in body.plain + # Full content is not dumped. + assert "line3" not in body.plain + + +def test_started_edit_previews_old_and_new() -> None: + set_active_theme("gnexus-dark") + renderer = FilesystemToolStartedRenderer() + panel = renderer.render( + _started_msg({"action": "edit", "path": "a.py", "old": "foo", "new": "bar\nbaz"}) + ) + body = _body(panel) + assert "old" in body.plain + assert "new" in body.plain + assert '"foo"' in body.plain + assert '"bar"' in body.plain + assert "(+1 lines)" in body.plain + + +def test_started_edit_lines_summarizes_operations() -> None: + set_active_theme("gnexus-dark") + renderer = FilesystemToolStartedRenderer() + ops = [ + {"op": "replace", "start": 1, "end": 1, "content": "x"}, + {"op": "delete", "start": 5, "end": 5}, + {"op": "insert", "after": 7, "content": "y"}, + ] + panel = renderer.render(_started_msg({"action": "edit_lines", "path": "a.py", "operations": ops})) + body = _body(panel) + assert "operations" in body.plain + assert "3:" in body.plain + assert "replace" in body.plain + assert "delete" in body.plain + + +def test_started_grep_shows_pattern_glob_regex() -> None: + set_active_theme("gnexus-dark") + renderer = FilesystemToolStartedRenderer() + panel = renderer.render( + _started_msg({"action": "grep", "path": "src", "pattern": "TODO", "glob": "*.py", "regex": True}) + ) + body = _body(panel) + assert "pattern" in body.plain + assert "TODO" in body.plain + assert "glob" in body.plain + assert "*.py" in body.plain + assert "regex" in body.plain + + +def test_started_smart_edit_previews_instruction() -> None: + set_active_theme("gnexus-dark") + renderer = FilesystemToolStartedRenderer() + panel = renderer.render( + _started_msg({"action": "smart_edit", "path": "a.py", "instruction": "rename foo to bar"}) + ) + body = _body(panel) + assert "instruction" in body.plain + assert "rename foo to bar" in body.plain + + +def test_started_query_previews_question() -> None: + set_active_theme("gnexus-dark") + renderer = FilesystemToolStartedRenderer() + panel = renderer.render( + _started_msg({"action": "query", "path": "a.py", "question": "What does calculate() return?"}) + ) + body = _body(panel) + assert "question" in body.plain + assert "calculate()" in body.plain + + +def test_started_subagent_is_indented() -> None: + set_active_theme("gnexus-dark") + renderer = FilesystemToolStartedRenderer() + renderable = renderer.render( + _started_msg({"action": "read", "path": "a.py"}, is_subagent=True) + ) + assert any(line.startswith(" ") for line in _render_text(renderable).splitlines()) + + +def test_started_body_keys_are_dim() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + renderer = FilesystemToolStartedRenderer() + panel = renderer.render(_started_msg({"action": "grep", "path": "src", "pattern": "TODO"})) + body = _body(panel) + assert isinstance(body, Text) + assert _span_styles_at(body, "pattern") == {theme.text_dim.hex} + # Pattern value is in text. + assert theme.text.hex in _span_styles_at(body, "TODO")