diff --git a/clients/terminal/tui/renderers/__init__.py b/clients/terminal/tui/renderers/__init__.py index ae8d12c..3656f3f 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, terminal, recall +from . import message, tool, thinking, error, markdown_content, plain, diff, status, planning, subagent, todo, turn_meta, summary, filesystem, terminal, code_exec, recall def default_registry() -> RendererRegistry: @@ -22,9 +22,11 @@ # generic tool renderers (first accepting renderer wins). reg.register(filesystem.FilesystemToolStartedRenderer()) reg.register(terminal.TerminalToolStartedRenderer()) + reg.register(code_exec.CodeExecStartedRenderer()) reg.register(tool.ToolStartedRenderer()) reg.register(filesystem.FilesystemToolResultRenderer()) reg.register(terminal.TerminalToolResultRenderer()) + reg.register(code_exec.CodeExecResultRenderer()) reg.register(tool.ToolResultRenderer()) reg.register(error.ErrorRenderer()) reg.register(status.StatusRenderer()) diff --git a/clients/terminal/tui/renderers/code_exec.py b/clients/terminal/tui/renderers/code_exec.py new file mode 100644 index 0000000..deb0aad --- /dev/null +++ b/clients/terminal/tui/renderers/code_exec.py @@ -0,0 +1,208 @@ +"""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 ``\n[stderr]\n`` (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 \ No newline at end of file diff --git a/tests/clients/test_code_exec_renderer.py b/tests/clients/test_code_exec_renderer.py new file mode 100644 index 0000000..6165a17 --- /dev/null +++ b/tests/clients/test_code_exec_renderer.py @@ -0,0 +1,177 @@ +"""Tests for the code_exec tool-call renderer (Python highlight + structured result).""" + +from __future__ import annotations + +from rich.console import Console + +from clients.terminal.tui.renderers.code_exec import ( + CodeExecResultRenderer, + CodeExecStartedRenderer, + _split_streams, +) +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 _started(code: str, **kw) -> dict: + msg = {"type": "tool_started", "tool": "code_exec", "args": {"code": code}} + msg.update(kw) + return msg + + +def _result(result: str, success: bool, metadata: dict | None = None, **kw) -> dict: + msg = { + "type": "tool_call", + "tool": "code_exec", + "args": {"code": "print('x')"}, + "result": result, + "success": success, + "metadata": metadata or {}, + } + msg.update(kw) + return msg + + +# ── accepts ──────────────────────────────────────────────────────────────────── + + +def test_started_accepts_only_code_exec_tool_started() -> None: + r = CodeExecStartedRenderer() + assert r.accepts(_started("print('x')")) + assert not r.accepts({"type": "tool_started", "tool": "terminal", "args": {}}) + assert not r.accepts({"type": "tool_call", "tool": "code_exec", "args": {}}) + + +def test_result_accepts_only_code_exec_tool_call() -> None: + r = CodeExecResultRenderer() + assert r.accepts(_result("x", True)) + assert not r.accepts({"type": "tool_call", "tool": "terminal", "args": {}}) + assert not r.accepts({"type": "tool_started", "tool": "code_exec", "args": {}}) + + +# ── started ─────────────────────────────────────────────────────────────────── + + +def test_started_shows_code_not_json() -> None: + set_active_theme("gnexus-dark") + panel = CodeExecStartedRenderer().render(_started("print('hello world')")) + text = _render_text(panel) + # The code is rendered as code, not as a JSON dump (no surrounding quotes / + # "code": key). The literal source must be visible. + assert "print('hello world')" in text + assert '"code"' not in text + + +def test_started_folds_long_scripts() -> None: + set_active_theme("gnexus-dark") + long_code = "\n".join(f"x{i} = {i}" for i in range(120)) + panel = CodeExecStartedRenderer().render(_started(long_code)) + text = _render_text(panel) + assert "x0 = 0" in text + assert "more lines" in text + # The tail of a 120-line script (>=60 lines) must be folded out. + assert "x119 = 119" not in text + + +def test_started_shows_working_dir_and_timeout() -> None: + set_active_theme("gnexus-dark") + panel = CodeExecStartedRenderer().render( + _started("print('x')", args={"code": "print('x')", "working_dir": "/proj", "timeout": 120}) + ) + text = _render_text(panel) + assert "/proj" in text + assert "120s" in text + + +# ── result ──────────────────────────────────────────────────────────────────── + + +def test_result_success_anchors_exit_zero() -> None: + set_active_theme("gnexus-dark") + panel = CodeExecResultRenderer().render( + _result("hello", True, metadata={"returncode": 0, "language": "python"}) + ) + assert "exit 0" in str(panel.title) + assert "✓" in str(panel.title) + + +def test_result_failure_anchors_exit_code() -> None: + set_active_theme("gnexus-dark") + panel = CodeExecResultRenderer().render( + _result("boom", False, metadata={"returncode": 1, "language": "python"}) + ) + assert "exit 1" in str(panel.title) + assert "✗" in str(panel.title) + + +def test_result_splits_stdout_and_stderr() -> None: + set_active_theme("gnexus-dark") + fused = "ok line\n[stderr]\ntraceback boom" + panel = CodeExecResultRenderer().render( + _result(fused, False, metadata={"returncode": 1, "language": "python"}) + ) + text = _render_text(panel) + assert "ok line" in text + assert "traceback boom" in text + # The fused marker must not leak into the rendered card. + assert "[stderr]" not in text + + +def test_result_timeout_status() -> None: + set_active_theme("gnexus-dark") + panel = CodeExecResultRenderer().render( + _result("Code execution timed out after 30s", False, metadata={"timeout": 30}) + ) + assert "timeout 30s" in str(panel.title) + assert "⏱" in str(panel.title) + + +def test_result_timeout_detected_from_output_for_legacy_sessions() -> None: + set_active_theme("gnexus-dark") + # Pre-metadata session: no "timeout" key, detect from the output text. + panel = CodeExecResultRenderer().render( + _result("Code execution timed out after 30s", False, metadata={}) + ) + assert "timeout" in str(panel.title) + + +def test_result_no_output_message() -> None: + set_active_theme("gnexus-dark") + panel = CodeExecResultRenderer().render( + _result("", True, metadata={"returncode": 0, "language": "python"}) + ) + text = _render_text(panel) + assert "(no output)" in text + + +# ── stream splitting helper ──────────────────────────────────────────────────── + + +def test_split_streams_stdout_only() -> None: + assert _split_streams("hello\nworld") == ("hello\nworld", "") + + +def test_split_streams_stderr_only() -> None: + assert _split_streams("[stderr]\nboom") == ("", "boom") + + +def test_split_streams_both() -> None: + assert _split_streams("out\n[stderr]\nerr") == ("out", "err") + + +def test_split_streams_empty() -> None: + assert _split_streams("") == ("", "") + + +def test_split_streams_marker_in_stdout_content_is_not_treated_as_boundary() -> None: + # A literal "[stderr]" line inside real stdout still splits (the server uses + # the same marker), but a substring within a longer line does not. + assert _split_streams("see [stderr] inline\n[stderr]\nreal err") == ( + "see [stderr] inline", + "real err", + ) \ No newline at end of file