Newer
Older
navi-1 / clients / terminal / tui / renderers / filesystem.py
"""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)
        # read / info / write / edit / append / move / copy / delete / mkdir /
        # exists / find / find_up / query / unknown → plain, unchanged for now.
        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),
        )

    # ── 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)