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