Newer
Older
navi-1 / clients / terminal / tui / renderers / filesystem.py
"""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, msg.get("metadata") or {})
        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,
        metadata: dict | None = None,
    ) -> 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 == "edit":
            return self._render_edit(text, metadata or {}, theme)
        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),
        )

    def _render_edit(self, text: str, metadata: dict, theme: Theme) -> RenderableType:
        # ``edit`` keeps its model-facing output short (a one-line summary); the
        # unified diff is carried in ``metadata["diff"]`` purely for display, so
        # the model's context is unchanged. Render the summary dim, then the
        # highlighted diff; fall back to plain when no diff is present.
        diff = metadata.get("diff") if metadata else None
        if not diff:
            return Text(text, style=theme.text_dim.hex)
        return Group(
            Text(text, 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