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

The generic ``ToolStartedRenderer``/``ToolResultRenderer`` dump the call as raw
JSON: the whole Python script shows up as an escaped string value, and the
result is a dim wall of text with stdout/stderr fused by a ``[stderr]`` marker.
``code_exec`` is a first-class navi_code tool, so it gets a dedicated card:

* started — the script rendered with Python syntax highlighting (via the shared
  ``highlight_code`` so it follows ``Theme.code_theme``), long scripts folded,
  ``working_dir``/``timeout`` shown as compact key/values instead of JSON bulk.
* result — exit code anchored in the title (``exit N``), stdout and stderr split
  into separate blocks with stderr in the warning colour, and a dedicated
  ``⏱ timeout`` status instead of a plain ``✗``.

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
from .syntax import highlight_code

# Long scripts are folded in the started card — the full code is already in the
# tool args (and the session log); the card only needs to read at a glance.
_CODE_MAX_LINES = 60
# Cap rendered stdout/stderr so a chatty script cannot flood the bubble. Keep
# the tail, mirroring the generic tool renderer's 200-line cap.
_OUTPUT_MAX_LINES = 200


def _fold_code(code: str) -> tuple[str, int]:
    """Return ``(code_to_show, hidden_line_count)`` for the started card."""
    lines = code.splitlines()
    if len(lines) <= _CODE_MAX_LINES:
        return code, 0
    hidden = len(lines) - _CODE_MAX_LINES
    return "\n".join(lines[:_CODE_MAX_LINES]), hidden


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


# Server fuses stdout/stderr as ``<stdout>\n[stderr]\n<stderr>`` (code_exec.py).
# Split them back out so the result card can style stderr distinctly.
_STDERR_MARKER = "[stderr]"


def _split_streams(text: str) -> tuple[str, str]:
    """Split a code_exec result string back into ``(stdout, stderr)``."""
    if not text:
        return "", ""
    # The marker is always at the start of a line (joined with ``\n``). Walk to
    # the first line that is exactly ``[stderr]`` — everything before is stdout,
    # the rest is stderr.
    lines = text.split("\n")
    for i, line in enumerate(lines):
        if line == _STDERR_MARKER:
            stdout = "\n".join(lines[:i])
            stderr = "\n".join(lines[i + 1 :])
            return stdout, stderr
    return text, ""


class CodeExecStartedRenderer(ContentRenderer):
    """Styled ``tool_started`` card for ``code_exec`` — Python-highlighted code."""

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

    def render(self, msg: dict) -> RenderableType:
        theme = get_active_theme()
        args = msg.get("args") or {}
        code = args.get("code") or ""

        body = self._render_body(code, args, theme)
        panel = Panel(
            body,
            title="→ code_exec",
            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 _render_body(self, code: str, args: dict, theme: Theme) -> RenderableType:
        parts: list[RenderableType] = []

        # Optional meta lines (working_dir / timeout) above the code, compact.
        meta = self._meta_text(args, theme)
        if meta is not None and meta.plain:
            parts.append(meta)
            parts.append(Text(""))

        if code:
            shown, hidden = _fold_code(code)
            parts.append(highlight_code(shown, "python", theme=theme, line_numbers=True))
            if hidden:
                parts.append(Text(""))
                parts.append(Text(f"… ({hidden} more lines)", style=theme.text_dim.hex))
        else:
            parts.append(Text("(no code)", style=theme.text_dim.hex))

        return Group(*parts)

    def _meta_text(self, args: dict, theme: Theme) -> Text | None:
        out = Text()
        wd = args.get("working_dir")
        if wd:
            out.append("cwd: ", style=theme.text_dim.hex)
            out.append(str(wd), style=theme.accent.hex)
        timeout = args.get("timeout")
        if timeout is not None:
            if out.plain:
                out.append("\n")
            out.append("timeout: ", style=theme.text_dim.hex)
            out.append(f"{timeout}s", style=theme.text.hex)
        return out if out.plain else None


class CodeExecResultRenderer(ContentRenderer):
    """Structured ``tool_call`` card for ``code_exec`` — split streams + exit anchor."""

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

    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 ""
        metadata = msg.get("metadata") or {}
        timeout = _detect_timeout(text, success, metadata)

        title, border = self._title(success, timeout, metadata, theme)
        body = self._render_body(text, success, timeout, theme)
        panel = Panel(
            body,
            title=title,
            title_align="left",
            border_style=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

    # ── title / border ────────────────────────────────────────────────────────

    def _title(self, success: bool, timeout: bool, metadata: dict, theme: Theme) -> tuple[str, Theme]:
        if timeout:
            secs = metadata.get("timeout")
            tail = f"⏱ timeout {secs}s" if secs else "⏱ timeout"
            return f"← code_exec {tail}", theme.warning
        rc = metadata.get("returncode")
        if success:
            tail = "✓ exit 0" if rc == 0 else "✓"
        else:
            tail = f"✗ exit {rc}" if rc is not None else "✗"
        color = theme.tool_success if success else theme.tool_error
        return f"← code_exec {tail}", color

    # ── body ──────────────────────────────────────────────────────────────────

    def _render_body(self, text: str, success: bool, timeout: bool, theme: Theme) -> RenderableType:
        if timeout:
            return Text(_truncate(text), style=theme.warning.hex)

        stdout, stderr = _split_streams(text)
        parts: list[RenderableType] = []

        if stdout:
            parts.append(Text(_truncate(stdout), style=theme.text_dim.hex))
        if stderr:
            if parts:
                parts.append(Text(""))
            # stderr (tracebacks included) reads as a distinct warning block.
            parts.append(Text(_truncate(stderr), style=theme.warning.hex))
        if not stdout and not stderr and success:
            parts.append(Text("(no output)", style=theme.text_dim.hex))

        return Group(*parts) if len(parts) > 1 else (parts[0] if parts else Text(""))


def _detect_timeout(text: str, success: bool, metadata: dict) -> bool:
    """True when this result represents a timed-out execution."""
    if success:
        return False
    if "timeout" in metadata:  # set by code_exec on timeout (new sessions)
        return True
    return "timed out after" in (text or "")  # fallback for pre-metadata sessions