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