diff --git a/clients/terminal/tui/renderers/__init__.py b/clients/terminal/tui/renderers/__init__.py index 7fde227..ae8d12c 100644 --- a/clients/terminal/tui/renderers/__init__.py +++ b/clients/terminal/tui/renderers/__init__.py @@ -21,6 +21,7 @@ # 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(terminal.TerminalToolStartedRenderer()) reg.register(tool.ToolStartedRenderer()) reg.register(filesystem.FilesystemToolResultRenderer()) reg.register(terminal.TerminalToolResultRenderer()) diff --git a/clients/terminal/tui/renderers/terminal.py b/clients/terminal/tui/renderers/terminal.py index 38f5e15..5e471ac 100644 --- a/clients/terminal/tui/renderers/terminal.py +++ b/clients/terminal/tui/renderers/terminal.py @@ -1,10 +1,10 @@ -"""Styled renderer for ``terminal`` tool-call results. +"""Styled renderers for ``terminal`` tool events (started + result). -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). +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 @@ -22,6 +22,7 @@ # 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: @@ -32,6 +33,20 @@ 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) @@ -155,4 +170,82 @@ 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 ``. 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 \ No newline at end of file diff --git a/tests/clients/test_render_plain.py b/tests/clients/test_render_plain.py index 2c86a33..932ec6f 100644 --- a/tests/clients/test_render_plain.py +++ b/tests/clients/test_render_plain.py @@ -35,6 +35,7 @@ {"type": "tool_started", "tool": "foo", "args": {"a": 1}}, {"type": "tool_call", "tool": "foo", "result": "bar", "success": True}, {"type": "tool_started", "tool": "filesystem", "args": {"action": "read", "path": "x.py"}}, + {"type": "tool_started", "tool": "terminal", "args": {"action": "run", "command": "echo hi"}}, {"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}, diff --git a/tests/clients/test_terminal_renderer.py b/tests/clients/test_terminal_renderer.py index 9004fae..7a65398 100644 --- a/tests/clients/test_terminal_renderer.py +++ b/tests/clients/test_terminal_renderer.py @@ -5,7 +5,10 @@ from rich.console import Console from rich.text import Text -from clients.terminal.tui.renderers.terminal import TerminalToolResultRenderer +from clients.terminal.tui.renderers.terminal import ( + TerminalToolResultRenderer, + TerminalToolStartedRenderer, +) from clients.terminal.tui.themes import get_active_theme, set_active_theme @@ -124,4 +127,75 @@ 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 + assert "line 0" not in text + + +# ── tool_started cards ───────────────────────────────────────────────────── + + +def _started(action, **kw): + msg = {"type": "tool_started", "tool": "terminal", "args": {"action": action}} + msg.update(kw) + return msg + + +def test_started_accepts_only_terminal_tool_started() -> None: + r = TerminalToolStartedRenderer() + assert r.accepts(_started("run")) + assert not r.accepts({"type": "tool_started", "tool": "filesystem", "args": {}}) + assert not r.accepts({"type": "tool_call", "tool": "terminal"}) + + +def test_started_run_shows_command() -> None: + set_active_theme("gnexus-dark") + panel = TerminalToolStartedRenderer().render( + _started("run", args={"action": "run", "command": "echo hi"}) + ) + assert "→ terminal run" in str(panel.title) + text = _render_text(panel) + assert "$ echo hi" in text + + +def test_started_open_shows_name_desc_background() -> None: + set_active_theme("gnexus-dark") + panel = TerminalToolStartedRenderer().render( + _started( + "open", + args={"action": "open", "terminal_name": "dev", "description": "dev server", + "background": True, "command": "npm start"}, + ) + ) + assert "→ terminal open" in str(panel.title) + text = _render_text(panel) + assert "dev" in text + assert "dev server" in text + assert "background: true" in text + assert "npm start" in text + + +def test_started_close_shows_name() -> None: + set_active_theme("gnexus-dark") + panel = TerminalToolStartedRenderer().render( + _started("close", args={"action": "close", "terminal_name": "dev"}) + ) + assert "→ terminal close" in str(panel.title) + text = _render_text(panel) + assert "dev" in text + + +def test_started_send_input_shows_name_and_input() -> None: + set_active_theme("gnexus-dark") + panel = TerminalToolStartedRenderer().render( + _started("send_input", args={"action": "send_input", "terminal_name": "dev", "input": "yes\n"}) + ) + text = _render_text(panel) + assert "dev" in text + assert "yes" in text + + +def test_started_list_empty_body() -> None: + set_active_theme("gnexus-dark") + panel = TerminalToolStartedRenderer().render(_started("list", args={"action": "list"})) + assert "→ terminal list" in str(panel.title) + # list has no per-call headline argument; body is empty (no crash, no JSON dump). + assert panel.renderable.plain == "" \ No newline at end of file