Tool Renderer Architecture
Date: 2026-01-07 Scope: UI / Renderers Status: Canonical
Overview
Tool renderers transform raw tool output into structured NeXTSTEP-style panels. All renderers follow a uniform 4-zone layout pattern and share common infrastructure via BaseToolRenderer.
Location: src/tunacode/ui/renderers/tools/
The 4-Zone Layout
Every tool panel follows this structure:
┌──────────────────────────────────────────────────┐
│ [bold]tool_name[/] status info │ Zone 1: Header
│ param1: value1 param2: value2 │ Zone 2: Params
│ ────────── │ Separator
│ main content │ Zone 3: Viewport
│ ────────── │ Separator
│ info1 info2 123ms │ Zone 4: Status
└──────────────────────────────────────────────────┘
09:41:23 Subtitle (timestamp)
Zone Responsibilities
| Zone | Purpose | Example |
|---|---|---|
| Header | Tool identity + result summary | mydir 45 files 12 dirs |
| Params | Input parameters used | hidden: off max: 100 |
| Viewport | Primary content (padded to min height) | Tree, diff, matches |
| Status | Truncation info, metrics, timing | [26/100 lines] 145ms |
Base Classes
BaseToolRenderer[T]
Abstract base class implementing the template method pattern. Subclasses provide tool-specific logic; the base handles composition.
from tunacode.ui.renderers.tools import BaseToolRenderer, RendererConfig
class MyToolRenderer(BaseToolRenderer[MyToolData]):
def parse_result(self, args, result) -> MyToolData | None:
"""Parse raw output into structured data."""
...
def build_header(self, data, duration_ms) -> Text:
"""Zone 1: tool name + summary."""
...
def build_params(self, data) -> Text | None:
"""Zone 2: input parameters."""
...
def build_viewport(self, data) -> RenderableType:
"""Zone 3: main content."""
...
def build_status(self, data, duration_ms) -> Text:
"""Zone 4: metrics and timing."""
...
Optional Overrides
def get_border_color(self, data) -> str:
"""Panel border color. Default: success green."""
return self.config.warning_color if data.has_error else self.config.success_color
def get_status_text(self, data) -> str:
"""Title status text. Default: 'done'."""
return f"exit {data.exit_code}" if data.exit_code != 0 else "done"
Helper Functions
Shared utilities eliminate duplication across renderers:
from tunacode.ui.renderers.tools import truncate_line, truncate_content, pad_lines
# Truncate single line with ellipsis
line = truncate_line("very long line...", max_width=60)
# Truncate multi-line content, returns (content, shown, total)
content, shown, total = truncate_content(raw_output, max_lines=10)
# Pad to minimum viewport height
lines = pad_lines(content.split("\n"), min_lines=4)
Preview Script
Use the preview script to render tool panels without running the full TUI:
uv run python scripts/preview_tool_panels.py --list
uv run python scripts/preview_tool_panels.py --all --width 92
uv run python scripts/preview_tool_panels.py --scenario grep-basic --scenario bash-error
The script uses tool_panel_smart() with canned outputs to exercise common scenarios
(success, truncation, diagnostics, and error fallback).
Registry Pattern
Renderers self-register using the @tool_renderer decorator:
from tunacode.ui.renderers.tools import tool_renderer
@tool_renderer("my_tool")
def render_my_tool(args, result, duration_ms=None):
"""Render my_tool output."""
return _renderer.render(args, result, duration_ms)
Lookup Functions
from tunacode.ui.renderers.tools import list_renderers
# List all registered renderers
names = list_renderers() # ["bash", "list_dir", ...]
Creating a New Renderer
- Create
src/tunacode/ui/renderers/tools/my_tool.py - Define a dataclass for parsed data
- Implement
BaseToolRenderer[MyData] - Create module-level instance and render function
- Register with
@tool_renderer - Export from
__init__.py - Add to
UNIFIED_RENDERERSintests/test_base_renderer.py
Minimal Example
"""Renderer for my_tool output."""
from dataclasses import dataclass
from typing import Any
from rich.console import RenderableType
from rich.text import Text
from tunacode.ui.renderers.tools.base import (
BaseToolRenderer,
RendererConfig,
pad_lines,
tool_renderer,
truncate_content,
)
@dataclass
class MyToolData:
"""Parsed my_tool result."""
name: str
content: str
count: int
class MyToolRenderer(BaseToolRenderer[MyToolData]):
def parse_result(self, args, result) -> MyToolData | None:
if not result:
return None
return MyToolData(name="example", content=result, count=len(result.splitlines()))
def build_header(self, data, duration_ms) -> Text:
header = Text()
header.append(data.name, style="bold")
header.append(f" {data.count} items", style="dim")
return header
def build_params(self, data) -> Text | None:
return None # No params to display
def build_viewport(self, data, max_line_width: int) -> RenderableType:
content, _, _ = truncate_content(data.content, max_width=max_line_width)
lines = pad_lines(content.split("\n"))
return Text("\n".join(lines))
def build_status(self, data, duration_ms, max_line_width: int) -> Text:
items = []
if duration_ms:
items.append(f"{duration_ms:.0f}ms")
return Text(" ".join(items), style="dim")
_renderer = MyToolRenderer(RendererConfig(tool_name="my_tool"))
@tool_renderer("my_tool")
def render_my_tool(args, result, duration_ms, max_line_width) -> RenderableType | None:
return _renderer.render(args, result, duration_ms, max_line_width)
Width Handling
Panel widths are calculated centrally in panel_widths.py (PR #244). This follows Gate 5: explicit widths instead of expand=True indirection.
Width Flow
TextualReplApp.on_tool_result_display()
→ computes max_line_width from viewport
→ calls tool_panel_smart(args, result, duration_ms, max_line_width)
→ renderer receives max_line_width as parameter
→ renderer uses max_line_width for content truncation
→ tool_panel_frame_width(max_line_width) computes Panel width
Key Function
from tunacode.ui.renderers.panel_widths import tool_panel_frame_width
# Compute explicit panel frame width
frame_width = tool_panel_frame_width(max_line_width)
Panel(content, width=frame_width) # explicit, verifiable
Why not expand=True? The expand parameter expands to scrollable_content_region, not terminal width. RichLog's internal padding subtracts 4-8 chars, making panels narrower than expected. Explicit widths are verifiable.
Constants
Defined in tunacode.constants:
| Constant | Value | Purpose |
|---|---|---|
MAX_PANEL_LINES |
20 | Max lines in generic panels |
TOOL_VIEWPORT_LINES |
8 | Max lines in tool viewport |
MIN_VIEWPORT_LINES |
3 | Minimum viewport height |
MIN_TOOL_PANEL_LINE_WIDTH |
4 | Minimum tool panel line width |
TOOL_PANEL_HORIZONTAL_INSET |
8 | Width reserved for borders/padding |
Defined in panel_widths.py:
| Function | Returns | Purpose |
|---|---|---|
tool_panel_frame_width(max_line_width) |
max_line_width + TOOL_PANEL_HORIZONTAL_INSET |
Panel frame width |
Tool panel line width is derived from the available viewport width and passed into renderers.
Defined in base.py:
| Constant | Value | Purpose |
|---|---|---|
BOX_HORIZONTAL |
─ |
Separator character |
SEPARATOR_WIDTH |
10 | Separator line width |
Current Renderers
| Tool | Status | Renderer Class |
|---|---|---|
list_dir |
Unified | ListDirRenderer |
bash |
Unified | BashRenderer |
read_file |
Legacy | (function) |
update_file |
Legacy | (function) |
glob |
Legacy | (function) |
grep |
Legacy | (function) |
web_fetch |
Legacy | (function) |
research_codebase |
Legacy | (function) |
Testing & Enforcement
Renderers using BaseToolRenderer are enforced via tests/test_base_renderer.py:
UNIFIED_RENDERERS = {
"list_dir": ListDirRenderer,
"bash": BashRenderer,
}
Tests verify:
- Each renderer inherits from
BaseToolRenderer - Each renderer is registered in the registry
When migrating a renderer, add it to UNIFIED_RENDERERS dict.
Verification Checklist
When creating or modifying a renderer:
- Inherits from
BaseToolRenderer - Avoid horizontal pre-truncation; rely on Rich wrapping for width changes
- Uses shared helpers (no local
_truncate_line) - Registered with
@tool_renderer - Added to
UNIFIED_RENDERERSin test file - Exported from
__init__.py