Newer
Older
navi-1 / clients / terminal / tui / renderers / terminal.py
"""Styled renderers for ``terminal`` tool events (started + result).

The generic ``ToolStartedRenderer``/``ToolResultRenderer`` dump the call as raw
JSON with no awareness of the action. ``terminal`` has six actions with
different shapes (``run`` carries a command + exit code, ``open`` a PID, …),
so these renderers give each a compact, structured card like the filesystem
renderers. Registered before the generic tool renderers (first accepting wins).
"""

from __future__ import annotations

from rich.box import ROUNDED
from rich.console import RenderableType, Group
from rich.panel import Panel
from rich.text import Text

from clients.terminal.tui.themes import Theme, get_active_theme

from .base import ContentRenderer

# Server caps output at 5000 chars already; cap what we render too so a giant
# result never floods the bubble (mirrors the generic tool renderer's 200-line
# cap for non-filesystem tools).
_MAX_LINES = 200
_PREVIEW_MAX = 80


def _truncate(text: str) -> str:
    lines = text.splitlines()
    if len(lines) <= _MAX_LINES:
        return text
    dropped = len(lines) - _MAX_LINES
    return f"... [{dropped} lines truncated]\n" + "\n".join(lines[-_MAX_LINES:])


def _preview(s: str | None, max_len: int = _PREVIEW_MAX) -> str:
    """First line of a value plus a ``…(+N lines)`` hint, capped."""
    if not s:
        return ""
    lines = s.splitlines()
    first = lines[0]
    if len(first) > max_len:
        first = first[: max_len - 1] + "…"
    more = len(lines) - 1
    if more > 0:
        return f"{first} …(+{more} lines)"
    return first


def _output_block(text: str, theme: Theme) -> Text:
    """Dim, truncated result text."""
    return Text(_truncate(text), style=theme.text_dim.hex)


class TerminalToolResultRenderer(ContentRenderer):
    """Action-aware card for ``terminal`` tool-call results (run/open/list/…)."""

    def accepts(self, msg: dict) -> bool:
        return msg.get("type") == "tool_call" and msg.get("tool") == "terminal"

    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")
        metadata = msg.get("metadata") or {}

        body = self._render_body(text, action, args, success, theme, metadata)
        color = theme.tool_success if success else theme.tool_error
        panel = Panel(
            body,
            title=f"← terminal {'✓' if success else '✗'}",
            title_align="left",
            border_style=color.hex,
            box=ROUNDED,
        )
        if bool(msg.get("is_subagent", False)):
            from rich.padding import Padding

            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,
    ) -> RenderableType:
        if action == "run":
            # run always gets the structured card — command + output + exit code
            # anchor (red on failure), so a failed command still shows what ran.
            return self._render_run(text, args, success, theme, metadata)
        if action == "open":
            if not success:
                # "already exists" / "max reached" — surface the reason verbatim.
                return Text(text, style=theme.tool_error.hex)
            return self._render_open(text, args, theme, metadata)
        if not success:
            # list/status/send_input/close errors — show the message in red.
            return Text(text, style=theme.tool_error.hex)
        if action == "list":
            return _output_block(text, theme)
        if action == "status":
            return _output_block(text, theme)
        if action == "send_input":
            return Text(text, style=theme.text_dim.hex)
        if action == "close":
            return Text(text, style=theme.text_dim.hex)
        # Unknown action → plain dim.
        return _output_block(text, theme)

    # ── run ────────────────────────────────────────────────────────────────────

    def _render_run(self, text: str, args: dict, success: bool, theme: Theme, metadata: dict) -> RenderableType:
        command = (args.get("command") or "").strip()
        parts: list[RenderableType] = []

        if command:
            # Command echoed at the top in the accent colour, like a shell prompt.
            parts.append(Text(f"$ {command}", style=theme.accent.hex))
            parts.append(Text(""))

        parts.append(_output_block(text, theme))

        # Exit code anchor — green/red regardless of the panel border, so it reads
        # as a clear success/failure marker even at a glance.
        rc = metadata.get("returncode")
        if rc is not None:
            rc_color = theme.tool_success if rc == 0 else theme.tool_error
            parts.append(Text(""))
            parts.append(Text(f"exit {rc}", style=rc_color.hex))

        # Drop the trailing separator before the exit line in the Group.
        return Group(*parts)

    # ── open ───────────────────────────────────────────────────────────────────

    def _render_open(self, text: str, args: dict, theme: Theme, metadata: dict) -> RenderableType:
        name = args.get("terminal_name") or metadata.get("name") or ""
        description = args.get("description") or metadata.get("description") or ""
        background = bool(args.get("background"))
        pid = metadata.get("pid")

        out = Text()
        if name:
            out.append("terminal: ", style=theme.text_dim.hex)
            out.append(str(name), style=theme.accent.hex)
        if description:
            if out.plain:
                out.append("\n")
            out.append("desc: ", style=theme.text_dim.hex)
            out.append(str(description), style=theme.text.hex)
        if background:
            if out.plain:
                out.append("\n")
            out.append("background", style=theme.info.hex)
        if pid is not None:
            if out.plain:
                out.append("\n")
            out.append("pid: ", style=theme.text_dim.hex)
            out.append(str(pid), style=theme.text.hex)

        if not out.plain:
            # Nothing structured to show — fall back to the raw result text.
            return _output_block(text, theme)
        return out


class TerminalToolStartedRenderer(ContentRenderer):
    """Styled ``tool_started`` card for ``terminal``.

    Title: ``→ terminal <action>``. Body: the key argument for the action — the
    command for ``run`` (accent, shell-prompt style), the terminal name +
    description + background for ``open``, the terminal name for
    close/status/send_input (plus the input preview for send_input) — instead of
    a full JSON dump that floods the card with large ``command``/``input``.
    """

    def accepts(self, msg: dict) -> bool:
        return msg.get("type") == "tool_started" and msg.get("tool") == "terminal"

    def render(self, msg: dict) -> RenderableType:
        theme = get_active_theme()
        args = msg.get("args") or {}
        action = args.get("action", "?")
        body = self._summarize(action, args, theme)
        panel = Panel(
            body if body.plain else Text(""),
            title=f"→ terminal {action}",
            title_align="left",
            border_style=theme.tool_border.hex,
            box=ROUNDED,
        )
        if bool(msg.get("is_subagent", False)):
            from rich.padding import Padding

            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, value_style: str = theme.text.hex) -> None:
            if out.plain:
                out.append("\n")
            out.append(f"{key}: ", style=theme.text_dim.hex)
            out.append(str(value), style=value_style)

        if action == "run":
            command = args.get("command")
            if command:
                # Shell-prompt style: the command is the headline, in accent.
                out.append("$ ", style=theme.text_dim.hex)
                out.append(_preview(command), style=theme.accent.hex)
            if args.get("working_dir"):
                kv("cwd", args.get("working_dir"))
        elif action == "open":
            name = args.get("terminal_name")
            if name:
                kv("name", name, theme.accent.hex)
            description = args.get("description")
            if description:
                kv("desc", description)
            if args.get("background"):
                kv("background", "true")
            command = args.get("command")
            if command:
                kv("command", _preview(command))
        elif action in ("status", "close"):
            name = args.get("terminal_name")
            if name:
                kv("name", name, theme.accent.hex)
        elif action == "send_input":
            name = args.get("terminal_name")
            if name:
                kv("name", name, theme.accent.hex)
            inp = args.get("input")
            if inp:
                kv("input", _preview(inp))
        elif action == "list":
            # No per-call argument carries the headline; the result card shows
            # the table. Keep the body empty (title alone).
            pass
        return out