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