"""Styled renderer for ``filesystem`` tool-call results.
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`.
"""
from __future__ import annotations
import re
from rich.box import ROUNDED
from rich.console import RenderableType, Group
from rich.padding import Padding
from rich.panel import Panel
from rich.text import Text
from clients.terminal.tui.themes import Theme, get_active_theme
from .base import ContentRenderer
from .diff import highlight_unified_diff
# Actions whose output embeds a unified diff (edit_lines / smart_edit prepend an
# "Applied …" summary line; diff is the raw unified diff).
_DIFF_ACTIONS = {"diff", "edit_lines", "smart_edit"}
# List entry prefixes emitted by filesystem._list.
_LIST_DIR_RE = re.compile(r"^d\s+(?P<name>.+?)/\s*(?:\((?P<n>\d+) items\))?\s*$")
_LIST_FILE_RE = re.compile(r"^\s{3}(?P<name>.+?)\s{2,}(?P<size>\S+)\s{2,}(?P<time>.+)$")
_LIST_UNKNOWN_RE = re.compile(r"^\?\s+(?P<name>.+)$")
_TRUNCATED_MARKER = "⚠"
# Grep match line: ``rel:line: text``.
_GREP_MATCH_RE = re.compile(r"^(?P<loc>.+?:\d+): (?P<text>.*)$")
class FilesystemToolResultRenderer(ContentRenderer):
"""Styled output for ``filesystem`` tool-call results (diff / list / grep).
Sub-agent results are indented to read as nested inside the parent
``spawn_agent`` card, matching :class:`ToolResultRenderer`.
"""
def accepts(self, msg: dict) -> bool:
return msg.get("type") == "tool_call" and msg.get("tool") == "filesystem"
def render(self, msg: dict) -> RenderableType:
theme = get_active_theme()
success = msg.get("success", True)
result = msg.get("result")
text = str(result) if result is not None else ""
args = msg.get("args") or {}
action = args.get("action")
body = self._render_body(text, action, args, success, theme)
color = theme.tool_success if success else theme.tool_error
panel = Panel(
body,
title=f"← filesystem {'✓' if success else '✗'}",
title_align="left",
border_style=color.hex,
box=ROUNDED,
)
if bool(msg.get("is_subagent", False)):
return Padding(panel, (0, 0, 0, 2))
return panel
# ── dispatch ───────────────────────────────────────────────────────────
def _render_body(
self, text: str, action: str | None, args: dict, success: bool, theme: Theme
) -> RenderableType:
if not success:
# Errors carry a human-readable message; do not attempt to parse.
return Text(text, style=theme.tool_error.hex)
if action in _DIFF_ACTIONS:
return self._render_diff(text, action, theme)
if action == "list":
return self._render_list(text, theme)
if action == "grep":
return self._render_grep(text, args, theme)
if action == "read":
return self._render_read(text, args, theme)
if action == "info":
return self._render_info(text, theme)
# write / edit / append / move / copy / delete / mkdir / exists / find /
# find_up / query / unknown → plain, unchanged.
return Text(text, style=theme.text_dim.hex)
# ── diff ───────────────────────────────────────────────────────────────
def _render_diff(self, text: str, action: str | None, theme: Theme) -> RenderableType:
if action == "diff":
if text == "Files are identical.":
return Text(text, style=theme.text_dim.hex)
return highlight_unified_diff(text, theme)
# edit_lines / smart_edit: "Applied …" summary, blank line, then diff.
summary, _, diff = text.partition("\n\n")
if not diff:
# No diff body (e.g. edit_lines with no net line change) — just summary.
return Text(summary, style=theme.text_dim.hex)
return Group(
Text(summary, style=theme.text_dim.hex),
Text(""),
highlight_unified_diff(diff, theme),
)
# ── read ───────────────────────────────────────────────────────────────
def _render_read(self, text: str, args: dict, theme: Theme) -> RenderableType:
lines = text.splitlines()
if not lines:
return Text(text, style=theme.text_dim.hex)
out = Text()
# Header plaque: "[path | N lines | size]" (or "… lines A–B of N …").
# Highlight the path between "[" and the first " | " in accent; the
# rest of the plaque is dim.
header = lines[0]
inner = header[1:] if header.startswith("[") else header
path_part, sep, rest = inner.partition(" | ")
out.append("[" if header.startswith("[") else "", style=theme.text_dim.hex)
if sep:
out.append(path_part, style=theme.accent.hex)
out.append(" | " + rest, style=theme.text_dim.hex)
else:
out.append(header, style=theme.text_dim.hex)
# Optional "⚠ Large file …" warning line.
idx = 1
if len(lines) > 1 and lines[1].startswith(_TRUNCATED_MARKER):
out.append("\n")
out.append(lines[1], style=theme.warning.hex)
idx = 2
# Body: file contents (no syntax highlighting — rejected by design).
body = lines[idx:]
if body:
out.append("\n")
if args.get("numbered", False):
for j, ln in enumerate(body):
if j:
out.append("\n")
num, sep2, content = ln.partition(": ")
if sep2:
out.append(num + ": ", style=theme.text_dim.hex)
out.append(content, style=theme.text.hex)
else:
out.append(ln, style=theme.text.hex)
else:
out.append("\n".join(body), style=theme.text.hex)
return out
# ── info ───────────────────────────────────────────────────────────────
def _render_info(self, text: str, theme: Theme) -> RenderableType:
lines = text.splitlines()
if not lines:
return Text(text, style=theme.text_dim.hex)
out = Text()
for idx, line in enumerate(lines):
if idx:
out.append("\n")
key, sep, rest = line.partition(":")
if sep:
out.append(key, style=theme.accent.hex)
out.append(":", style=theme.accent.hex)
stripped = rest.lstrip(" ")
leading = rest[: len(rest) - len(stripped)]
if leading:
out.append(leading, style=theme.text_dim.hex)
out.append(stripped, style=theme.text.hex)
else:
out.append(line, style=theme.text.hex)
return out
# ── list ───────────────────────────────────────────────────────────────
def _render_list(self, text: str, theme: Theme) -> RenderableType:
lines = text.splitlines()
if not lines:
return Text(text, style=theme.text_dim.hex)
header_line = lines[0]
rest = lines[1:]
out = Text()
# Header plaque: "[path | N entries ⚠ truncated]" → dim, warning if truncated.
header_style = (
theme.warning.hex if _TRUNCATED_MARKER in header_line else theme.text_dim.hex
)
out.append(header_line, style=header_style)
for line in rest:
out.append("\n")
if line == "(empty directory)":
out.append(line, style=theme.text_dim.hex)
continue
m = _LIST_DIR_RE.match(line)
if m:
name = m.group("name")
n = m.group("n")
out.append("▸ ", style=theme.info.hex)
out.append(f"{name}/", style=theme.info.hex)
if n:
out.append(f" ({n} items)", style=theme.text_dim.hex)
continue
m = _LIST_FILE_RE.match(line)
if m:
out.append(m.group("name").rstrip(), style=theme.text.hex)
out.append(" " + m.group("size"), style=theme.accent.hex)
out.append(" " + m.group("time"), style=theme.text_dim.hex)
continue
m = _LIST_UNKNOWN_RE.match(line)
if m:
out.append("? " + m.group("name"), style=theme.warning.hex)
continue
# Unrecognized line — render verbatim.
out.append(line, style=theme.text_dim.hex)
return out
# ── grep ───────────────────────────────────────────────────────────────
def _render_grep(self, text: str, args: dict, theme: Theme) -> RenderableType:
lines = text.splitlines()
if not lines:
return Text(text, style=theme.text_dim.hex)
pattern = str(args.get("pattern", "") or "")
# Pre-compile a case-insensitive literal match for highlighting.
highlight = None
if pattern:
try:
highlight = re.compile(re.escape(pattern), re.IGNORECASE)
except re.error:
highlight = None
out = Text()
for idx, line in enumerate(lines):
if idx:
out.append("\n")
if idx == 0 and line.startswith("["):
# Header plaque "[N matches for '…' in … (M files searched) ⚠ …]".
style = (
theme.warning.hex
if _TRUNCATED_MARKER in line
else theme.text_dim.hex
)
out.append(line, style=style)
continue
if line.startswith("No matches for "):
out.append(line, style=theme.text_dim.hex)
continue
m = _GREP_MATCH_RE.match(line)
if m:
out.append(m.group("loc") + ":", style=theme.text_dim.hex)
out.append(" ")
self._append_with_highlight(out, m.group("text"), highlight, theme)
continue
out.append(line, style=theme.text_dim.hex)
return out
@staticmethod
def _append_with_highlight(
out: Text, text: str, highlight: re.Pattern | None, theme: Theme
) -> None:
if highlight is None:
out.append(text, style=theme.text.hex)
return
last = 0
for m in highlight.finditer(text):
if m.start() > last:
out.append(text[last:m.start()], style=theme.text.hex)
out.append(m.group(0), style=theme.accent.hex)
last = m.end()
if last < len(text):
out.append(text[last:], style=theme.text.hex)