diff --git a/.claude/skills/tool-renderer/SKILL.md b/.claude/skills/tool-renderer/SKILL.md
new file mode 100644
index 00000000..d5b43153
--- /dev/null
+++ b/.claude/skills/tool-renderer/SKILL.md
@@ -0,0 +1,414 @@
+---
+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.
+---
+
+# Implementing a Tool Renderer
+
+This guide walks through adding rendering support for a new Claude Code tool, using WebSearch as an example.
+
+## Before You Start
+
+**Examine existing test data** to understand the tool's actual JSON structure:
+
+```bash
+# 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:
+- **Input parameters**: What's in `tool_use.input`?
+- **toolUseResult structure**: What metadata does the structured result contain?
+- **tool_result.content**: What does the raw text output look like?
+
+The `toolUseResult` field on transcript entries often contains richer structured data than `tool_result.content`. **Always prefer parsing from `toolUseResult` when available.**
+
+## Overview
+
+Tool rendering involves several components working together:
+
+1. **Models** (`models.py`) - Type definitions for tool inputs and outputs
+2. **Factory** (`factories/tool_factory.py`) - Parsing raw JSON into typed models
+3. **HTML Formatters** (`html/tool_formatters.py`) - HTML rendering functions
+4. **Renderers** - Integration with HTML and Markdown renderers
+
+## Step 1: Define Models
+
+### Tool Input Model
+
+Add a Pydantic model for the tool's input parameters in `models.py`:
+
+```python
+class WebSearchInput(BaseModel):
+ """Input parameters for the WebSearch tool."""
+ query: str
+```
+
+### Tool Output Model
+
+Add a dataclass for the parsed output. Output models are dataclasses (not Pydantic) since they're created by our parsers, not from JSON:
+
+```python
+@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.
+
+### Update Type Unions
+
+Add the new types to the `ToolInput` and `ToolOutput` unions:
+
+```python
+ToolInput = Union[
+ # ... existing types ...
+ WebSearchInput,
+ ToolUseContent, # Generic fallback - keep last
+]
+
+ToolOutput = Union[
+ # ... existing types ...
+ WebSearchOutput,
+ ToolResultContent, # Generic fallback - keep last
+]
+```
+
+## Step 2: Implement Factory Functions
+
+In `factories/tool_factory.py`:
+
+### Register Input Model
+
+Add the input model to `TOOL_INPUT_MODELS`:
+
+```python
+TOOL_INPUT_MODELS: dict[str, type[BaseModel]] = {
+ # ... existing entries ...
+ "WebSearch": WebSearchInput,
+}
+```
+
+### Implement Output Parser
+
+**Important**: Always check if the tool has structured `toolUseResult` data available. This is the preferred approach because:
+- It's more reliable than regex parsing of text content
+- It often contains metadata (timing, byte counts, status codes) not in the text
+- The structure is well-defined and type-safe
+
+Example `toolUseResult` structures in test data:
+```json
+// 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`:
+
+```python
+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)
+```
+
+### Register Output Parser
+
+Add to `TOOL_OUTPUT_PARSERS` and **register in `PARSERS_WITH_TOOL_USE_RESULT`** if using the extended signature:
+
+```python
+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.
+
+## Step 3: Implement HTML Formatters
+
+In `html/tool_formatters.py`:
+
+### Input Formatter
+
+**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.
+
+```python
+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'
{escaped_query}
'
+```
+
+This avoids redundancy when the title already shows everything important.
+
+### Output Formatter
+
+For tools with structured content like WebSearch, combine all parts into markdown then render:
+
+```python
+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")
+```
+
+### Update Exports
+
+Add functions to `__all__`:
+
+```python
+__all__ = [
+ # ... existing exports ...
+ "format_websearch_input",
+ "format_websearch_output",
+]
+```
+
+## Step 4: Wire Up HTML Renderer
+
+In `html/renderer.py`:
+
+### Import Formatters
+
+```python
+from .tool_formatters import (
+ # ... existing imports ...
+ format_websearch_input,
+ format_websearch_output,
+)
+```
+
+### Add Format Methods
+
+```python
+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)
+```
+
+### Add Title Method (Optional)
+
+For a custom title in the message header:
+
+```python
+def title_WebSearchInput(self, input: WebSearchInput, message: TemplateMessage) -> str:
+ return self._tool_title(message, "๐", f'"{input.query}"')
+```
+
+## Step 5: Implement Markdown Renderer
+
+In `markdown/renderer.py`:
+
+### Import Models
+
+```python
+from ..models import (
+ # ... existing imports ...
+ WebSearchInput,
+ WebSearchOutput,
+)
+```
+
+### Add Format Methods
+
+```python
+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}`'
+```
+
+## Step 6: Add Tests
+
+Create a dedicated test file `test/test_{toolname}_rendering.py`. Tests are **required** - they catch regressions and document expected behavior.
+
+### Test Structure
+
+```python
+"""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."""
+ ...
+```
+
+### Running Tests
+
+```bash
+# 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 -n auto -m "not (tui or browser)" -v
+```
+
+## Checklist
+
+### Models (`models.py`)
+- [ ] Add input model (Pydantic `BaseModel`)
+- [ ] Add output model (dataclass with all fields from `toolUseResult`)
+- [ ] Update `ToolInput` union
+- [ ] Update `ToolOutput` union
+
+### Factory (`factories/tool_factory.py`)
+- [ ] Add to `TOOL_INPUT_MODELS`
+- [ ] Import output model
+- [ ] Implement output parser with 3-arg signature if using `toolUseResult`
+- [ ] Add to `TOOL_OUTPUT_PARSERS`
+- [ ] Add to `PARSERS_WITH_TOOL_USE_RESULT` (required if parser uses `toolUseResult`)
+
+### HTML (`html/tool_formatters.py`, `html/renderer.py`)
+- [ ] Import models
+- [ ] Add input formatter function
+- [ ] Add output formatter function
+- [ ] Update `__all__` exports
+- [ ] Wire up `format_{Input}` method in renderer
+- [ ] Wire up `format_{Output}` method in renderer
+- [ ] Add `title_{Input}` method in renderer
+
+### Markdown (`markdown/renderer.py`)
+- [ ] Import models
+- [ ] Add `format_{Input}` method
+- [ ] Add `format_{Output}` method
+- [ ] Add `title_{Input}` method
+
+### Tests (`test/test_{toolname}_rendering.py`)
+- [ ] Create test file
+- [ ] Test input model creation
+- [ ] Test input formatting (short/long content)
+- [ ] Test parser with full structured data
+- [ ] Test parser with minimal data
+- [ ] Test parser with missing fields (graceful failure)
+- [ ] Test parser with no `toolUseResult`
+- [ ] Test output formatting
+- [ ] Run full test suite to verify no regressions
diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py
index c3010f0a..257416e0 100644
--- a/claude_code_log/factories/tool_factory.py
+++ b/claude_code_log/factories/tool_factory.py
@@ -38,6 +38,7 @@
ToolUseMessage,
ToolUseResult,
WebSearchInput,
+ WebFetchInput,
WriteInput,
# Tool output models
AskUserQuestionAnswer,
@@ -50,6 +51,7 @@
ToolOutput,
WebSearchLink,
WebSearchOutput,
+ WebFetchOutput,
WriteOutput,
)
@@ -72,6 +74,7 @@
"ask_user_question": AskUserQuestionInput, # Legacy tool name
"ExitPlanMode": ExitPlanModeInput,
"WebSearch": WebSearchInput,
+ "WebFetch": WebFetchInput,
}
@@ -556,6 +559,57 @@ def parse_websearch_output(
return _parse_websearch_from_structured(tool_use_result)
+def parse_webfetch_output(
+ tool_result: ToolResultContent,
+ file_path: Optional[str],
+ tool_use_result: Optional[ToolUseResult] = None,
+) -> Optional[WebFetchOutput]:
+ """Parse WebFetch tool result from structured toolUseResult.
+
+ WebFetch results include metadata from toolUseResult:
+ - bytes: Size of fetched content
+ - code: HTTP status code
+ - codeText: HTTP status text
+ - result: The processed markdown result
+ - durationMs: Time taken in milliseconds
+ - url: The URL that was fetched
+
+ Args:
+ tool_result: The tool result content (used as fallback)
+ file_path: Unused for WebFetch tool
+ tool_use_result: Structured result containing rich metadata
+
+ Returns:
+ WebFetchOutput if parsing succeeds, None otherwise
+ """
+ del file_path # Unused
+
+ # Prefer structured toolUseResult when available
+ if tool_use_result is not None and isinstance(tool_use_result, dict):
+ url = tool_use_result.get("url")
+ result = tool_use_result.get("result")
+
+ # Both url and result are required
+ if url and result:
+ return WebFetchOutput(
+ url=str(url),
+ result=str(result),
+ bytes=tool_use_result.get("bytes"),
+ code=tool_use_result.get("code"),
+ code_text=tool_use_result.get("codeText"),
+ duration_ms=tool_use_result.get("durationMs"),
+ )
+
+ # Fallback: try to extract from tool_result content
+ content = _extract_tool_result_text(tool_result)
+ if not content:
+ return None
+
+ # For fallback, we don't have the rich metadata, just the result text
+ # We also don't have the URL, so return None (will use generic formatter)
+ return None
+
+
# Type alias for tool output parsers
# Standard signature: (tool_result, file_path) -> Optional[ToolOutput]
# Extended signature: (tool_result, file_path, tool_use_result) -> Optional[ToolOutput]
@@ -563,7 +617,7 @@ def parse_websearch_output(
# Registry of tool output parsers: tool_name -> parser function
# Parsers receive the full ToolResultContent and can use _extract_tool_result_text() for text.
-# Some parsers (like WebSearch) also accept optional tool_use_result for structured data.
+# Some parsers (like WebSearch, WebFetch) also accept optional tool_use_result for structured data.
TOOL_OUTPUT_PARSERS: dict[str, ToolOutputParser] = {
"Read": parse_read_output,
"Edit": parse_edit_output,
@@ -573,10 +627,11 @@ def parse_websearch_output(
"AskUserQuestion": parse_askuserquestion_output,
"ExitPlanMode": parse_exitplanmode_output,
"WebSearch": parse_websearch_output,
+ "WebFetch": parse_webfetch_output,
}
# Parsers that accept the extended signature with tool_use_result
-PARSERS_WITH_TOOL_USE_RESULT: set[str] = {"WebSearch"}
+PARSERS_WITH_TOOL_USE_RESULT: set[str] = {"WebSearch", "WebFetch"}
def create_tool_output(
@@ -591,11 +646,14 @@ def create_tool_output(
using the TOOL_OUTPUT_PARSERS registry. Each parser receives the full
ToolResultContent and can use _extract_tool_result_text() if it needs text.
+ For tools in PARSERS_WITH_TOOL_USE_RESULT, the structured toolUseResult
+ from the transcript entry is also passed to the parser.
+
Args:
tool_name: The name of the tool (e.g., "Bash", "Read")
tool_result: The raw tool result content
file_path: Optional file path for file-based tools (Read, Edit, Write)
- tool_use_result: Optional structured toolUseResult from entry (for WebSearch, etc.)
+ tool_use_result: Optional structured toolUseResult from entry (for WebSearch, WebFetch)
Returns:
A typed output model if parsing succeeds, ToolResultContent as fallback.
@@ -682,7 +740,7 @@ def create_tool_result_message(
meta: Message metadata
tool_result: The tool result content item
tool_use_context: Dict with tool_use_id -> ToolUseContent mapping
- tool_use_result: Optional structured toolUseResult from the entry
+ tool_use_result: Optional structured toolUseResult from transcript entry
Returns:
ToolItemResult with tool_result content model
diff --git a/claude_code_log/html/__init__.py b/claude_code_log/html/__init__.py
index bd5f97b3..2bfd2c62 100644
--- a/claude_code_log/html/__init__.py
+++ b/claude_code_log/html/__init__.py
@@ -25,6 +25,7 @@
format_read_input,
format_task_input,
format_todowrite_input,
+ format_webfetch_input,
format_write_input,
# Tool output formatters (called by HtmlRenderer.format_{OutputClass})
format_askuserquestion_output,
@@ -33,6 +34,7 @@
format_exitplanmode_output,
format_read_output,
format_task_output,
+ format_webfetch_output,
format_write_output,
# Fallback formatter
format_tool_result_content_raw,
@@ -108,6 +110,7 @@
"format_read_input",
"format_task_input",
"format_todowrite_input",
+ "format_webfetch_input",
"format_write_input",
# tool_formatters (output) - called by HtmlRenderer.format_{OutputClass}
"format_askuserquestion_output",
@@ -116,6 +119,7 @@
"format_exitplanmode_output",
"format_read_output",
"format_task_output",
+ "format_webfetch_output",
"format_write_output",
# Fallback formatter
"format_tool_result_content_raw",
diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py
index 8bc1ec68..8d22a374 100644
--- a/claude_code_log/html/renderer.py
+++ b/claude_code_log/html/renderer.py
@@ -36,6 +36,7 @@
TodoWriteInput,
ToolUseContent,
WebSearchInput,
+ WebFetchInput,
WriteInput,
# Tool output types
AskUserQuestionOutput,
@@ -46,6 +47,7 @@
TaskOutput,
ToolResultContent,
WebSearchOutput,
+ WebFetchOutput,
WriteOutput,
)
from ..renderer import (
@@ -100,6 +102,8 @@
format_tool_result_content_raw,
format_websearch_input,
format_websearch_output,
+ format_webfetch_input,
+ format_webfetch_output,
format_write_input,
format_write_output,
render_params_table,
@@ -362,6 +366,14 @@ def format_ToolResultContent(
"""Format โ raw content
(fallback for unknown tools)."""
return format_tool_result_content_raw(output)
+ def format_WebFetchInput(self, input: WebFetchInput, _: TemplateMessage) -> str:
+ """Format โ prompt text if long, empty if shown in title."""
+ return format_webfetch_input(input)
+
+ def format_WebFetchOutput(self, output: WebFetchOutput, _: TemplateMessage) -> str:
+ """Format โ collapsible markdown with metadata badge."""
+ return format_webfetch_output(output)
+
# -------------------------------------------------------------------------
# Tool Input Title Methods (for Renderer.title_ToolUseMessage dispatch)
# -------------------------------------------------------------------------
@@ -443,6 +455,12 @@ def title_WebSearchInput(
"""Title โ '๐ WebSearch '."""
return self._tool_title(message, "๐", input.query)
+ def title_WebFetchInput(
+ self, input: WebFetchInput, message: TemplateMessage
+ ) -> str:
+ """Title โ '๐ WebFetch '."""
+ return self._tool_title(message, "๐", input.url)
+
def _flatten_preorder(
self, roots: list[TemplateMessage]
) -> list[Tuple[TemplateMessage, str, str, str]]:
diff --git a/claude_code_log/html/templates/components/message_styles.css b/claude_code_log/html/templates/components/message_styles.css
index 918c4507..63ddfc05 100644
--- a/claude_code_log/html/templates/components/message_styles.css
+++ b/claude_code_log/html/templates/components/message_styles.css
@@ -918,6 +918,51 @@ details summary {
margin: 10px 0;
}
+/* WebFetch tool styling */
+.webfetch-prompt {
+ color: var(--text-secondary);
+ font-style: italic;
+ margin-bottom: 8px;
+}
+
+.webfetch-meta {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 8px;
+ font-size: 0.85em;
+}
+
+.webfetch-status {
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-weight: 500;
+}
+
+.webfetch-status-success {
+ background-color: #d4edda;
+ color: #155724;
+}
+
+.webfetch-status-error {
+ background-color: #f8d7da;
+ color: #721c24;
+}
+
+.webfetch-size,
+.webfetch-duration {
+ color: var(--text-muted);
+}
+
+/* Override negative margin for WebFetch collapsible - needs space for meta badges */
+.webfetch-result.collapsible-code {
+ margin-top: 0;
+}
+
+/* Override centered h1 from global styles for WebFetch content */
+.webfetch-result h1 {
+ text-align: left;
+}
+
@media (max-width: 1280px) {
.fold-bar {
height: auto;
diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py
index 99719007..8873eab8 100644
--- a/claude_code_log/html/tool_formatters.py
+++ b/claude_code_log/html/tool_formatters.py
@@ -45,6 +45,8 @@
ToolResultContent,
WebSearchInput,
WebSearchOutput,
+ WebFetchInput,
+ WebFetchOutput,
WriteInput,
WriteOutput,
)
@@ -579,6 +581,70 @@ def format_task_input(task_input: TaskInput) -> str:
return render_markdown_collapsible(task_input.prompt, "task-prompt")
+# -- WebFetch Tool ------------------------------------------------------------
+
+
+def format_webfetch_input(webfetch_input: WebFetchInput) -> str:
+ """Format WebFetch tool use content.
+
+ Args:
+ webfetch_input: Typed WebFetchInput with url and prompt.
+
+ The URL is shown in the title, so we only show the prompt here if it's
+ substantial enough to warrant display.
+ """
+ # If prompt is short, it can fit in the title - return empty
+ if len(webfetch_input.prompt) <= 100:
+ return ""
+
+ # Show the prompt for longer queries
+ escaped_prompt = escape_html(webfetch_input.prompt)
+ return f'{escaped_prompt}
'
+
+
+def format_webfetch_output(output: WebFetchOutput) -> str:
+ """Format WebFetch tool result as collapsible markdown.
+
+ Args:
+ output: Parsed WebFetchOutput with result and metadata
+
+ Returns:
+ HTML string with markdown rendered in collapsible section,
+ plus metadata badge showing HTTP status and timing.
+ """
+ # Build metadata badge
+ badge_parts: list[str] = []
+ if output.code is not None:
+ status_class = "success" if output.code == 200 else "error"
+ badge_parts.append(
+ f'{output.code}'
+ )
+ if output.bytes is not None:
+ # Format bytes nicely
+ if output.bytes >= 1024 * 1024:
+ size_str = f"{output.bytes / (1024 * 1024):.1f} MB"
+ elif output.bytes >= 1024:
+ size_str = f"{output.bytes / 1024:.1f} KB"
+ else:
+ size_str = f"{output.bytes} bytes"
+ badge_parts.append(f'{size_str}')
+ if output.duration_ms is not None:
+ if output.duration_ms >= 1000:
+ time_str = f"{output.duration_ms / 1000:.1f}s"
+ else:
+ time_str = f"{output.duration_ms}ms"
+ badge_parts.append(f'{time_str}')
+
+ badge_html = ""
+ if badge_parts:
+ badge_html = f'{" ".join(badge_parts)}
'
+
+ # Render the result as markdown in a collapsible section
+ content_html = render_markdown_collapsible(output.result, "webfetch-result")
+
+ return f"{badge_html}{content_html}"
+
+
# -- Generic Parameter Table --------------------------------------------------
@@ -768,6 +834,7 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str:
"format_bash_input",
"format_task_input",
"format_websearch_input",
+ "format_webfetch_input",
# Tool output formatters (called by HtmlRenderer.format_{OutputClass})
"format_read_output",
"format_write_output",
@@ -777,6 +844,7 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str:
"format_askuserquestion_output",
"format_exitplanmode_output",
"format_websearch_output",
+ "format_webfetch_output",
# Fallback for ToolResultContent
"format_tool_result_content_raw",
# Legacy formatters (still used)
diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py
index 829e7232..23c7e411 100644
--- a/claude_code_log/markdown/renderer.py
+++ b/claude_code_log/markdown/renderer.py
@@ -42,6 +42,7 @@
TodoWriteInput,
ToolUseContent,
WebSearchInput,
+ WebFetchInput,
WriteInput,
# Tool output types
AskUserQuestionOutput,
@@ -53,6 +54,7 @@
TaskOutput,
ToolResultContent,
WebSearchOutput,
+ WebFetchOutput,
WriteOutput,
)
from ..renderer import (
@@ -505,6 +507,12 @@ def format_WebSearchInput(self, _input: WebSearchInput, _: TemplateMessage) -> s
# Query is shown in the title, body is empty
return ""
+ def format_WebFetchInput(self, input: WebFetchInput, _: TemplateMessage) -> str:
+ """Format โ '' (url in title, prompt if long)."""
+ if len(input.prompt) > 100:
+ return self._code_fence(input.prompt)
+ return ""
+
def format_ToolUseContent(self, content: ToolUseContent, _: TemplateMessage) -> str:
"""Fallback for unknown tool inputs - render as key/value list."""
return self._render_params(content.input)
@@ -640,6 +648,31 @@ def format_WebSearchOutput(
return "\n".join(parts)
+ def format_WebFetchOutput(self, output: WebFetchOutput, _: TemplateMessage) -> str:
+ """Format โ metadata line + blockquoted result.
+
+ WebFetch results are AI-generated summaries, not raw content,
+ so a collapsible section isn't needed - use blockquote directly.
+ """
+ meta_parts: list[str] = []
+ if output.code is not None:
+ status = f"{output.code} {output.code_text or ''}".strip()
+ meta_parts.append(status)
+ if output.bytes is not None:
+ if output.bytes >= 1024 * 1024:
+ meta_parts.append(f"{output.bytes / (1024 * 1024):.1f} MB")
+ elif output.bytes >= 1024:
+ meta_parts.append(f"{output.bytes / 1024:.1f} KB")
+ else:
+ meta_parts.append(f"{output.bytes} bytes")
+ if output.duration_ms is not None:
+ if output.duration_ms >= 1000:
+ meta_parts.append(f"{output.duration_ms / 1000:.1f}s")
+ else:
+ meta_parts.append(f"{output.duration_ms}ms")
+ meta_line = f"*{' ยท '.join(meta_parts)}*\n\n" if meta_parts else ""
+ return meta_line + self._quote(output.result)
+
def format_ToolResultContent(
self, output: ToolResultContent, message: TemplateMessage
) -> str:
@@ -719,6 +752,11 @@ def title_WebSearchInput(self, input: WebSearchInput, _: TemplateMessage) -> str
"""Title โ '๐ WebSearch `query`'."""
return f"๐ WebSearch `{input.query}`"
+ def title_WebFetchInput(self, input: WebFetchInput, _: TemplateMessage) -> str:
+ """Title โ '๐ WebFetch `url`' (truncated if > 60 chars)."""
+ url = input.url[:60] + "โฆ" if len(input.url) > 60 else input.url
+ return f"๐ WebFetch `{url}`"
+
def title_ThinkingMessage(
self, _content: ThinkingMessage, _message: TemplateMessage
) -> str:
diff --git a/claude_code_log/models.py b/claude_code_log/models.py
index 011873b8..47278945 100644
--- a/claude_code_log/models.py
+++ b/claude_code_log/models.py
@@ -877,6 +877,13 @@ class WebSearchInput(BaseModel):
query: str
+class WebFetchInput(BaseModel):
+ """Input parameters for the WebFetch tool."""
+
+ url: str
+ prompt: str
+
+
# Union of all typed tool inputs
ToolInput = Union[
BashInput,
@@ -891,6 +898,7 @@ class WebSearchInput(BaseModel):
AskUserQuestionInput,
ExitPlanModeInput,
WebSearchInput,
+ WebFetchInput,
ToolUseContent, # Generic fallback when no specialized parser
]
@@ -1061,6 +1069,22 @@ class WebSearchOutput:
summary: Optional[str] = None # Markdown analysis after the links
+@dataclass
+class WebFetchOutput:
+ """Parsed WebFetch tool output.
+
+ Symmetric with WebFetchInput for tool_use โ tool_result pairing.
+ Contains the fetched URL's processed content as markdown.
+ """
+
+ url: str # The URL that was fetched
+ result: str # The processed markdown result
+ bytes: Optional[int] = None # Size of fetched content
+ code: Optional[int] = None # HTTP status code
+ code_text: Optional[str] = None # HTTP status text (e.g., "OK")
+ duration_ms: Optional[int] = None # Time taken in milliseconds
+
+
# Union of all specialized output types + ToolResultContent as generic fallback
ToolOutput = Union[
ReadOutput,
@@ -1071,6 +1095,7 @@ class WebSearchOutput:
AskUserQuestionOutput,
ExitPlanModeOutput,
WebSearchOutput,
+ WebFetchOutput,
# TODO: Add as parsers are implemented:
# GlobOutput, GrepOutput
ToolResultContent, # Generic fallback for unparsed results
diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr
index 1582b732..ee18bbd1 100644
--- a/test/__snapshots__/test_snapshot_html.ambr
+++ b/test/__snapshots__/test_snapshot_html.ambr
@@ -3052,6 +3052,51 @@
margin: 10px 0;
}
+ /* WebFetch tool styling */
+ .webfetch-prompt {
+ color: var(--text-secondary);
+ font-style: italic;
+ margin-bottom: 8px;
+ }
+
+ .webfetch-meta {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 8px;
+ font-size: 0.85em;
+ }
+
+ .webfetch-status {
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-weight: 500;
+ }
+
+ .webfetch-status-success {
+ background-color: #d4edda;
+ color: #155724;
+ }
+
+ .webfetch-status-error {
+ background-color: #f8d7da;
+ color: #721c24;
+ }
+
+ .webfetch-size,
+ .webfetch-duration {
+ color: var(--text-muted);
+ }
+
+ /* Override negative margin for WebFetch collapsible - needs space for meta badges */
+ .webfetch-result.collapsible-code {
+ margin-top: 0;
+ }
+
+ /* Override centered h1 from global styles for WebFetch content */
+ .webfetch-result h1 {
+ text-align: left;
+ }
+
@media (max-width: 1280px) {
.fold-bar {
height: auto;
@@ -7979,6 +8024,51 @@
margin: 10px 0;
}
+ /* WebFetch tool styling */
+ .webfetch-prompt {
+ color: var(--text-secondary);
+ font-style: italic;
+ margin-bottom: 8px;
+ }
+
+ .webfetch-meta {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 8px;
+ font-size: 0.85em;
+ }
+
+ .webfetch-status {
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-weight: 500;
+ }
+
+ .webfetch-status-success {
+ background-color: #d4edda;
+ color: #155724;
+ }
+
+ .webfetch-status-error {
+ background-color: #f8d7da;
+ color: #721c24;
+ }
+
+ .webfetch-size,
+ .webfetch-duration {
+ color: var(--text-muted);
+ }
+
+ /* Override negative margin for WebFetch collapsible - needs space for meta badges */
+ .webfetch-result.collapsible-code {
+ margin-top: 0;
+ }
+
+ /* Override centered h1 from global styles for WebFetch content */
+ .webfetch-result h1 {
+ text-align: left;
+ }
+
@media (max-width: 1280px) {
.fold-bar {
height: auto;
@@ -13002,6 +13092,51 @@
margin: 10px 0;
}
+ /* WebFetch tool styling */
+ .webfetch-prompt {
+ color: var(--text-secondary);
+ font-style: italic;
+ margin-bottom: 8px;
+ }
+
+ .webfetch-meta {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 8px;
+ font-size: 0.85em;
+ }
+
+ .webfetch-status {
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-weight: 500;
+ }
+
+ .webfetch-status-success {
+ background-color: #d4edda;
+ color: #155724;
+ }
+
+ .webfetch-status-error {
+ background-color: #f8d7da;
+ color: #721c24;
+ }
+
+ .webfetch-size,
+ .webfetch-duration {
+ color: var(--text-muted);
+ }
+
+ /* Override negative margin for WebFetch collapsible - needs space for meta badges */
+ .webfetch-result.collapsible-code {
+ margin-top: 0;
+ }
+
+ /* Override centered h1 from global styles for WebFetch content */
+ .webfetch-result h1 {
+ text-align: left;
+ }
+
@media (max-width: 1280px) {
.fold-bar {
height: auto;
@@ -18066,6 +18201,51 @@
margin: 10px 0;
}
+ /* WebFetch tool styling */
+ .webfetch-prompt {
+ color: var(--text-secondary);
+ font-style: italic;
+ margin-bottom: 8px;
+ }
+
+ .webfetch-meta {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 8px;
+ font-size: 0.85em;
+ }
+
+ .webfetch-status {
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-weight: 500;
+ }
+
+ .webfetch-status-success {
+ background-color: #d4edda;
+ color: #155724;
+ }
+
+ .webfetch-status-error {
+ background-color: #f8d7da;
+ color: #721c24;
+ }
+
+ .webfetch-size,
+ .webfetch-duration {
+ color: var(--text-muted);
+ }
+
+ /* Override negative margin for WebFetch collapsible - needs space for meta badges */
+ .webfetch-result.collapsible-code {
+ margin-top: 0;
+ }
+
+ /* Override centered h1 from global styles for WebFetch content */
+ .webfetch-result h1 {
+ text-align: left;
+ }
+
@media (max-width: 1280px) {
.fold-bar {
height: auto;
diff --git a/test/test_webfetch_rendering.py b/test/test_webfetch_rendering.py
new file mode 100644
index 00000000..6cf180d9
--- /dev/null
+++ b/test/test_webfetch_rendering.py
@@ -0,0 +1,558 @@
+#!/usr/bin/env python3
+"""Test cases for WebFetch tool rendering functionality."""
+
+import json
+import tempfile
+from pathlib import Path
+
+import pytest
+from claude_code_log.converter import load_transcript
+from claude_code_log.factories.tool_factory import parse_webfetch_output
+from claude_code_log.html import format_webfetch_input, format_webfetch_output
+from claude_code_log.html.renderer import HtmlRenderer, generate_html
+from claude_code_log.markdown.renderer import MarkdownRenderer
+from claude_code_log.models import (
+ MessageMeta,
+ ToolResultContent,
+ ToolUseMessage,
+ WebFetchInput,
+ WebFetchOutput,
+)
+from claude_code_log.renderer import TemplateMessage
+
+
+class TestWebFetchInput:
+ """Test WebFetch input model and formatting."""
+
+ def test_webfetch_input_creation(self):
+ """Test basic WebFetchInput model creation."""
+ webfetch_input = WebFetchInput(
+ url="https://example.com/api",
+ prompt="Extract the main content",
+ )
+ assert webfetch_input.url == "https://example.com/api"
+ assert webfetch_input.prompt == "Extract the main content"
+
+ def test_format_webfetch_input_short_prompt(self):
+ """Test formatting with short prompt (under 100 chars)."""
+ webfetch_input = WebFetchInput(
+ url="https://example.com",
+ prompt="Get the title",
+ )
+ html = format_webfetch_input(webfetch_input)
+ # Short prompt should not show prompt content
+ assert html == ""
+
+ def test_format_webfetch_input_long_prompt(self):
+ """Test formatting with long prompt (over 100 chars)."""
+ long_prompt = "Extract all the information about the API endpoints, including parameters, return types, and examples. Also include any authentication requirements."
+ webfetch_input = WebFetchInput(
+ url="https://api.example.com/docs",
+ prompt=long_prompt,
+ )
+ html = format_webfetch_input(webfetch_input)
+ # Long prompt should show prompt content
+ assert "webfetch-prompt" in html
+ assert long_prompt in html
+
+ def test_format_webfetch_input_html_escaping(self):
+ """Test that prompt content is properly HTML escaped."""
+ webfetch_input = WebFetchInput(
+ url="https://example.com",
+ prompt="A very long prompt " * 10 + "",
+ )
+ html = format_webfetch_input(webfetch_input)
+ assert "<script>" in html
+ assert "