Newer
Older
navi-1 / clients / terminal / tui / renderers / terminal.py
"""Styled renderer for ``terminal`` tool-call results.

The generic ``ToolResultRenderer`` dumps the result as a flat ``Text`` with no
awareness of the action. ``terminal`` has six actions with different shapes
(``run`` carries an exit code, ``open`` a PID, ``list`` a table of sessions),
so this renderer gives each a compact, structured card like the filesystem
renderer. Registered before the generic tool renderer (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


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 _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