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 "