"""Styled renderers for ``filesystem`` tool events.
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
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)
if action == "find":
return self._render_find(text, theme)
if action == "find_up":
return self._render_find_up(text, theme)
# write / edit / append / move / copy / delete / mkdir / exists / 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
# ── find ───────────────────────────────────────────────────────────────
def _render_find(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")
if idx == 0 and line.startswith("["):
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
# Match line: "{path} ({size})" or "<dir>" instead of size.
path_part, sep, tail = line.rpartition(" (")
if sep:
out.append(path_part, style=theme.text.hex)
out.append(" (", style=theme.text_dim.hex)
if tail == "<dir>)":
out.append("<dir>", style=theme.info.hex)
out.append(")", style=theme.text_dim.hex)
else:
out.append(tail, style=theme.text_dim.hex)
else:
out.append(line, style=theme.text.hex)
return out
# ── find_up ────────────────────────────────────────────────────────────
def _render_find_up(self, text: str, theme: Theme) -> RenderableType:
if text.startswith("not found (searched:"):
return Text(text, style=theme.text_dim.hex)
return Text(text, style=theme.accent.hex)
# ── 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)
# Actions that take a destination path (shown in the body alongside the path).
_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 <action>`` (compact — the action alone). Body: the
primary ``path`` (and ``destination`` for move/copy/diff) plus a compact,
human-readable summary of the key per-action arguments — large ``content``
/ ``old`` / ``new`` / ``instruction`` / ``question`` values are previewed,
not dumped in full, so the card is never empty.
"""
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", "?")
title = f"→ filesystem {action}"
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)
# Primary path and (for move/copy/diff) destination lead the body.
path = args.get("path")
if path:
kv("path", str(path), theme.accent.hex)
if action in _DEST_ACTIONS and args.get("destination"):
kv("destination", str(args["destination"]), theme.accent.hex)
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", "")))
return out