diff --git a/clients/terminal/tui/renderers/__init__.py b/clients/terminal/tui/renderers/__init__.py index be43074..7fde227 100644 --- a/clients/terminal/tui/renderers/__init__.py +++ b/clients/terminal/tui/renderers/__init__.py @@ -4,7 +4,7 @@ from .base import ContentRenderer from .registry import RendererRegistry -from . import message, tool, thinking, error, markdown_content, plain, diff, status, planning, subagent, todo, turn_meta, summary, filesystem, recall +from . import message, tool, thinking, error, markdown_content, plain, diff, status, planning, subagent, todo, turn_meta, summary, filesystem, terminal, recall def default_registry() -> RendererRegistry: @@ -18,11 +18,12 @@ reg.register(subagent.SpawnAgentResultRenderer()) reg.register(todo.TodoStartedRenderer()) reg.register(todo.TodoResultRenderer()) - # filesystem-specific renderers must be checked before the generic tool - # renderers (first accepting renderer wins). + # filesystem and terminal are action-aware and must be checked before the + # generic tool renderers (first accepting renderer wins). reg.register(filesystem.FilesystemToolStartedRenderer()) reg.register(tool.ToolStartedRenderer()) reg.register(filesystem.FilesystemToolResultRenderer()) + reg.register(terminal.TerminalToolResultRenderer()) reg.register(tool.ToolResultRenderer()) reg.register(error.ErrorRenderer()) reg.register(status.StatusRenderer()) diff --git a/clients/terminal/tui/renderers/terminal.py b/clients/terminal/tui/renderers/terminal.py new file mode 100644 index 0000000..38f5e15 --- /dev/null +++ b/clients/terminal/tui/renderers/terminal.py @@ -0,0 +1,158 @@ +"""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 \ No newline at end of file diff --git a/tests/clients/test_render_plain.py b/tests/clients/test_render_plain.py index 2cac89d..2c86a33 100644 --- a/tests/clients/test_render_plain.py +++ b/tests/clients/test_render_plain.py @@ -38,6 +38,7 @@ {"type": "tool_call", "tool": "filesystem", "result": "ok", "success": True, "args": {"action": "read"}}, {"type": "tool_started", "tool": "spawn_agent", "args": {"task": "do thing"}}, {"type": "tool_call", "tool": "spawn_agent", "result": "done", "success": True}, + {"type": "tool_call", "tool": "terminal", "result": "hi", "success": True, "args": {"action": "run", "command": "echo hi"}, "metadata": {"returncode": 0}}, {"type": "tool_started", "tool": "todo", "args": {}}, {"type": "tool_call", "tool": "todo", "result": "plan set", "success": True}, {"type": "error", "message": "boom"}, diff --git a/tests/clients/test_terminal_renderer.py b/tests/clients/test_terminal_renderer.py new file mode 100644 index 0000000..9004fae --- /dev/null +++ b/tests/clients/test_terminal_renderer.py @@ -0,0 +1,127 @@ +"""Tests for the terminal tool-call renderer (action-aware cards).""" + +from __future__ import annotations + +from rich.console import Console +from rich.text import Text + +from clients.terminal.tui.renderers.terminal import TerminalToolResultRenderer +from clients.terminal.tui.themes import get_active_theme, set_active_theme + + +def _render_text(renderable) -> str: + console = Console(record=True, width=80, force_terminal=True, color_system=None) + console.print(renderable) + return console.export_text() + + +def _body(panel): + r = panel.renderable + if isinstance(r, Text): + return r + return r + + +def _msg(action, **kw): + msg = {"type": "tool_call", "tool": "terminal", "args": {"action": action}, "success": True} + msg.update(kw) + return msg + + +def test_accepts_only_terminal_tool_call() -> None: + r = TerminalToolResultRenderer() + assert r.accepts(_msg("run")) + assert not r.accepts({"type": "tool_call", "tool": "filesystem", "args": {"action": "read"}}) + assert not r.accepts({"type": "tool_started", "tool": "terminal"}) + + +def test_run_shows_command_and_exit_code() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + panel = TerminalToolResultRenderer().render( + _msg("run", args={"action": "run", "command": "echo hi"}, result="hi", success=True, metadata={"returncode": 0}) + ) + text = _render_text(panel) + assert "$ echo hi" in text + assert "hi" in text + assert "exit 0" in text + assert "✓" in str(panel.title) + + +def test_run_failure_marks_exit_code_red() -> None: + set_active_theme("gnexus-dark") + panel = TerminalToolResultRenderer().render( + _msg("run", args={"action": "run", "command": "false"}, result="", success=False, metadata={"returncode": 1}) + ) + text = _render_text(panel) + assert "$ false" in text + assert "exit 1" in text + assert "✗" in str(panel.title) + + +def test_open_shows_name_description_pid() -> None: + set_active_theme("gnexus-dark") + panel = TerminalToolResultRenderer().render( + _msg( + "open", + args={"action": "open", "terminal_name": "dev", "description": "dev server", "background": True}, + result="Terminal 'dev' opened in background. PID: 123.", + success=True, + metadata={"name": "dev", "description": "dev server", "pid": 123}, + ) + ) + text = _render_text(panel) + assert "dev" in text + assert "dev server" in text + assert "background" in text + assert "123" in text + + +def test_list_renders_output_dim() -> None: + set_active_theme("gnexus-dark") + panel = TerminalToolResultRenderer().render( + _msg("list", result="Active terminals (1):\n 🟢 dev: server", success=True) + ) + text = _render_text(panel) + assert "Active terminals" in text + assert "dev" in text + + +def test_status_renders_output() -> None: + set_active_theme("gnexus-dark") + panel = TerminalToolResultRenderer().render( + _msg("status", result="Terminal: dev\nStatus: busy\nPID: 7", success=True) + ) + text = _render_text(panel) + assert "Terminal: dev" in text + assert "busy" in text + + +def test_send_input_echo() -> None: + set_active_theme("gnexus-dark") + panel = TerminalToolResultRenderer().render( + _msg("send_input", result="Sent input to 'dev'.", success=True) + ) + text = _render_text(panel) + assert "Sent input" in text + + +def test_close_message() -> None: + set_active_theme("gnexus-dark") + panel = TerminalToolResultRenderer().render( + _msg("close", result="Terminal 'dev' closed.", success=True) + ) + text = _render_text(panel) + assert "closed" in text + + +def test_run_truncates_long_output() -> None: + set_active_theme("gnexus-dark") + big = "\n".join(f"line {i}" for i in range(300)) + panel = TerminalToolResultRenderer().render( + _msg("run", args={"action": "run", "command": "heavy"}, result=big, success=True, metadata={"returncode": 0}) + ) + text = _render_text(panel) + assert "lines truncated]" in text + assert "line 299" in text + assert "line 0" not in text \ No newline at end of file