一键导入
tool-renderer
// Implement specialized rendering for Claude Code tools. Use when adding a new tool type (WebSearch, WebFetch, etc.) to the transcript viewer, or when asked to implement tool rendering.
// Implement specialized rendering for Claude Code tools. Use when adding a new tool type (WebSearch, WebFetch, etc.) to the transcript viewer, or when asked to implement tool rendering.
| name | tool-renderer |
| description | Implement specialized rendering for Claude Code tools. Use when adding a new tool type (WebSearch, WebFetch, etc.) to the transcript viewer, or when asked to implement tool rendering. |
This guide walks through adding rendering support for a new Claude Code tool, using WebSearch as an example.
Examine existing test data to understand the tool's actual JSON structure:
# Find test files containing the tool
rg -l "ToolName" test/test_data/
# Look at actual JSONL entries
rg '"name":\s*"ToolName"' test/test_data/ -A 2 -B 2
Key fields to identify:
tool_use.input?The toolUseResult field on transcript entries often contains richer structured data than tool_result.content. Always prefer parsing from toolUseResult when available.
Tool rendering involves several components working together:
models.py) - Type definitions for tool inputs and outputsfactories/tool_factory.py) - Parsing raw JSON into typed modelshtml/tool_formatters.py) - HTML rendering functionsAdd a Pydantic model for the tool's input parameters in models.py:
class WebSearchInput(BaseModel):
"""Input parameters for the WebSearch tool."""
query: str
Add a dataclass for the parsed output. Output models are dataclasses (not Pydantic) since they're created by our parsers, not from JSON:
@dataclass
class WebSearchLink:
"""Single search result link."""
title: str
url: str
@dataclass
class WebSearchOutput:
"""Parsed WebSearch tool output."""
query: str
links: list[WebSearchLink]
preamble: Optional[str] = None # Text before the Links
summary: Optional[str] = None # Markdown analysis after the Links
Note: Some tools have structured output with multiple sections. WebSearch is parsed as preamble/links/summary - text before Links, the Links JSON array, and markdown analysis after. This allows flexible rendering while preserving all content.
Add the new types to the ToolInput and ToolOutput unions:
ToolInput = Union[
# ... existing types ...
WebSearchInput,
ToolUseContent, # Generic fallback - keep last
]
ToolOutput = Union[
# ... existing types ...
WebSearchOutput,
ToolResultContent, # Generic fallback - keep last
]
In factories/tool_factory.py:
Add the input model to TOOL_INPUT_MODELS:
TOOL_INPUT_MODELS: dict[str, type[BaseModel]] = {
# ... existing entries ...
"WebSearch": WebSearchInput,
}
Important: Always check if the tool has structured toolUseResult data available. This is the preferred approach because:
Example toolUseResult structures in test data:
// WebSearch
{"query": "...", "results": [...], "durationSeconds": 15.7}
// WebFetch
{"url": "...", "result": "...", "code": 200, "codeText": "OK", "bytes": 12345, "durationMs": 1500}
Create a parser function that extracts from toolUseResult:
def _parse_websearch_from_structured(
tool_use_result: ToolUseResult,
) -> Optional[WebSearchOutput]:
"""Parse WebSearch from structured toolUseResult data.
The toolUseResult for WebSearch has the format:
{
"query": "search query",
"results": [
{"tool_use_id": "...", "content": [{"title": "...", "url": "..."}]},
"Analysis text..."
]
}
"""
if not isinstance(tool_use_result, dict):
return None
query = tool_use_result.get("query")
results = tool_use_result.get("results")
# ... extract links from results[0].content, summary from results[1] ...
return WebSearchOutput(query=query, links=links, preamble=None, summary=summary)
def parse_websearch_output(
tool_result: ToolResultContent,
file_path: Optional[str],
tool_use_result: Optional[ToolUseResult] = None, # Extended signature
) -> Optional[WebSearchOutput]:
"""Parse WebSearch tool result from structured toolUseResult."""
del tool_result, file_path # Unused
if tool_use_result is None:
return None
return _parse_websearch_from_structured(tool_use_result)
Add to TOOL_OUTPUT_PARSERS and register in PARSERS_WITH_TOOL_USE_RESULT if using the extended signature:
TOOL_OUTPUT_PARSERS: dict[str, ToolOutputParser] = {
# ... existing entries ...
"WebSearch": parse_websearch_output,
}
# REQUIRED for parsers that use toolUseResult - without this, the structured
# data won't be passed to your parser!
PARSERS_WITH_TOOL_USE_RESULT: set[str] = {"WebSearch", "WebFetch"}
Note: If your parser has the 3-argument signature (tool_result, file_path, tool_use_result), you MUST add it to PARSERS_WITH_TOOL_USE_RESULT. Otherwise create_tool_output() won't pass the structured data.
In html/tool_formatters.py:
Design consideration: The title already shows key info (tool name + primary parameter). Only show content in the body if it adds value or is too long for the title.
def format_websearch_input(search_input: WebSearchInput) -> str:
"""Format WebSearch tool use content."""
# If query is short enough to fit in title, return empty
if len(search_input.query) <= 100:
return "" # Full query shown in title
escaped_query = escape_html(search_input.query)
return f'<div class="websearch-query">{escaped_query}</div>'
This avoids redundancy when the title already shows everything important.
For tools with structured content like WebSearch, combine all parts into markdown then render:
def _websearch_as_markdown(output: WebSearchOutput) -> str:
"""Convert WebSearch output to markdown: preamble + links list + summary."""
parts = []
if output.preamble:
parts.extend([output.preamble, ""])
for link in output.links:
parts.append(f"- [{link.title}]({link.url})")
if output.summary:
parts.extend(["", output.summary])
return "\n".join(parts)
def format_websearch_output(output: WebSearchOutput) -> str:
"""Format WebSearch as single collapsible markdown block."""
markdown_content = _websearch_as_markdown(output)
return render_markdown_collapsible(markdown_content, "websearch-results")
Add functions to __all__:
__all__ = [
# ... existing exports ...
"format_websearch_input",
"format_websearch_output",
]
In html/renderer.py:
from .tool_formatters import (
# ... existing imports ...
format_websearch_input,
format_websearch_output,
)
def format_WebSearchInput(self, input: WebSearchInput, _: TemplateMessage) -> str:
return format_websearch_input(input)
def format_WebSearchOutput(self, output: WebSearchOutput, _: TemplateMessage) -> str:
return format_websearch_output(output)
For a custom title in the message header:
def title_WebSearchInput(self, input: WebSearchInput, message: TemplateMessage) -> str:
return self._tool_title(message, "🔎", f'"{input.query}"')
Tool-use messages get a default 🛠️ prefix prepended by
templates/transcript.html unless the title already starts with
an emoji that html/utils.py::starts_with_emoji recognises. That
function whitelists specific Unicode ranges:
0x2300-0x23FF Misc Technical (⏰ ⏳ ⏱️ ⏲️ ⏸ ⏹ ⏺ ⏏ …)0x2600-0x26FF Misc Symbols0x2700-0x27BF Dingbats0x1F300-0x1F5FF Misc Symbols and Pictographs0x1F600-0x1F64F Emoticons0x1F680-0x1F6FF Transport and Map Symbols0x1F900-0x1F9FF Supplemental SymbolsIf the icon you pass to _tool_title falls outside these
ranges, the template will helpfully add a 🛠️ in front of it,
producing a redundant double-icon title like
🛠️ <your-icon> <ToolName>. Verify by rendering a fixture and
grepping for 🛠️ co-occurring with your icon, or by checking
ord(your_icon) against the ranges above.
If your icon is a real emoji that lives in a Unicode range not
listed there, add the range to starts_with_emoji rather than
picking a different icon.
In markdown/renderer.py:
from ..models import (
# ... existing imports ...
WebSearchInput,
WebSearchOutput,
)
def format_WebSearchInput(self, input: WebSearchInput, _: TemplateMessage) -> str:
"""Format -> empty (query shown in title)."""
return ""
def format_WebSearchOutput(self, output: WebSearchOutput, _: TemplateMessage) -> str:
"""Format -> markdown list of links."""
parts = [f"Query: *{output.query}*", ""]
for link in output.links:
parts.append(f"- [{link.title}]({link.url})")
return "\n".join(parts)
def title_WebSearchInput(self, input: WebSearchInput, _: TemplateMessage) -> str:
"""Title -> '🔎 WebSearch `query`'."""
return f'🔎 WebSearch `{input.query}`'
Create a dedicated test file test/test_{toolname}_rendering.py. Tests are required - they catch regressions and document expected behavior.
"""Test cases for {ToolName} tool rendering."""
from claude_code_log.factories.tool_factory import parse_{toolname}_output
from claude_code_log.html.tool_formatters import (
format_{toolname}_input,
format_{toolname}_output,
)
from claude_code_log.models import (
ToolResultContent,
{ToolName}Input,
{ToolName}Output,
)
class Test{ToolName}Input:
"""Test input model and formatting."""
def test_input_basic(self):
"""Test input model creation."""
...
def test_format_input_short(self):
"""Test formatting when content fits in title."""
...
def test_format_input_long(self):
"""Test formatting when content is too long for title."""
...
class Test{ToolName}Parser:
"""Test output parsing."""
def test_parse_structured_output(self):
"""Test parsing from structured toolUseResult."""
...
def test_parse_minimal_output(self):
"""Test parsing with only required fields."""
...
def test_parse_missing_field(self):
"""Test graceful failure with missing required field."""
...
def test_parse_no_tool_use_result(self):
"""Test returns None when no toolUseResult."""
...
class Test{ToolName}OutputFormatting:
"""Test output HTML formatting."""
def test_format_output_full(self):
"""Test formatting with all metadata."""
...
def test_format_output_minimal(self):
"""Test formatting with minimal data."""
...
# Run just your new tests
uv run pytest test/test_{toolname}_rendering.py -v
# Run full test suite to check for regressions
uv run pytest -m "not (tui or browser)" -v
models.py)BaseModel)toolUseResult)ToolInput unionToolOutput unionfactories/tool_factory.py)TOOL_INPUT_MODELStoolUseResultTOOL_OUTPUT_PARSERSPARSERS_WITH_TOOL_USE_RESULT (required if parser uses toolUseResult)html/tool_formatters.py, html/renderer.py)__all__ exportsformat_{Input} method in rendererformat_{Output} method in renderertitle_{Input} method in renderermarkdown/renderer.py)format_{Input} methodformat_{Output} methodtitle_{Input} methodtest/test_{toolname}_rendering.py)toolUseResult