# Tool Renderer Architecture

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

- Skill: `tools-only/tool-renderer-architecture` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add tools-only/tool-renderer-architecture`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tools-only/tool-renderer-architecture/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: tools-only (https://skillmd.com/u/tools-only)
- Updated: 2026-09-29
- Page: https://skillmd.com/skills/tools-only/tool-renderer-architecture

---

# 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.

```python
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

```python
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:

```python
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:

```bash
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:

```python
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

```python
from tunacode.ui.renderers.tools import list_renderers

# List all registered renderers
names = list_renderers()  # ["bash", "list_dir", ...]
```

## Creating a New Renderer

1. Create `src/tunacode/ui/renderers/tools/my_tool.py`
2. Define a dataclass for parsed data
3. Implement `BaseToolRenderer[MyData]`
4. Create module-level instance and render function
5. Register with `@tool_renderer`
6. Export from `__init__.py`
7. Add to `UNIFIED_RENDERERS` in `tests/test_base_renderer.py`

### Minimal Example

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

```python
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`:

```python
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_RENDERERS` in test file
- [ ] Exported from `__init__.py`

