diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py
index af55462a..c3010f0a 100644
--- a/claude_code_log/factories/tool_factory.py
+++ b/claude_code_log/factories/tool_factory.py
@@ -36,6 +36,8 @@
ToolResultMessage,
ToolUseContent,
ToolUseMessage,
+ ToolUseResult,
+ WebSearchInput,
WriteInput,
# Tool output models
AskUserQuestionAnswer,
@@ -46,6 +48,8 @@
ReadOutput,
TaskOutput,
ToolOutput,
+ WebSearchLink,
+ WebSearchOutput,
WriteOutput,
)
@@ -67,6 +71,7 @@
"AskUserQuestion": AskUserQuestionInput,
"ask_user_question": AskUserQuestionInput, # Legacy tool name
"ExitPlanMode": ExitPlanModeInput,
+ "WebSearch": WebSearchInput,
}
@@ -466,11 +471,99 @@ def parse_exitplanmode_output(
return ExitPlanModeOutput(message=message, approved=approved)
+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..."
+ ],
+ "durationSeconds": 15.7
+ }
+
+ Args:
+ tool_use_result: The structured toolUseResult from the entry
+
+ Returns:
+ WebSearchOutput if parsing succeeds, None otherwise
+ """
+ if not isinstance(tool_use_result, dict):
+ return None
+
+ query = tool_use_result.get("query")
+ if not isinstance(query, str):
+ return None
+
+ results_raw = tool_use_result.get("results")
+ if not isinstance(results_raw, list):
+ return None
+ results = cast(list[Any], results_raw)
+ if len(results) < 1:
+ return None
+
+ # Extract links from the first result element
+ links: list[WebSearchLink] = []
+ first_result: Any = results[0]
+ if isinstance(first_result, dict):
+ first_result_dict = cast(dict[str, Any], first_result)
+ content_raw = first_result_dict.get("content", [])
+ if isinstance(content_raw, list):
+ content = cast(list[Any], content_raw)
+ for item in content:
+ if isinstance(item, dict):
+ link = cast(dict[str, Any], item)
+ title = link.get("title")
+ url = link.get("url")
+ if isinstance(title, str) and isinstance(url, str):
+ links.append(WebSearchLink(title=title, url=url))
+
+ # Extract summary from the second result element (if present)
+ summary: Optional[str] = None
+ if len(results) > 1 and isinstance(results[1], str):
+ summary = results[1].strip() or None
+
+ 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,
+) -> Optional[WebSearchOutput]:
+ """Parse WebSearch tool result from structured toolUseResult data.
+
+ Note: A regex-based fallback parser for text content was removed.
+ See commit 0d1d2a9 if you need to restore it.
+
+ Args:
+ tool_result: The tool result content (unused, kept for signature compatibility)
+ file_path: Unused for WebSearch tool
+ tool_use_result: Structured toolUseResult from the entry
+
+ Returns:
+ WebSearchOutput with query, links, and summary, or None if not parseable
+ """
+ del tool_result, file_path # Unused
+
+ if tool_use_result is None:
+ return None
+
+ return _parse_websearch_from_structured(tool_use_result)
+
+
# Type alias for tool output parsers
-ToolOutputParser = Callable[[ToolResultContent, Optional[str]], Optional[ToolOutput]]
+# Standard signature: (tool_result, file_path) -> Optional[ToolOutput]
+# Extended signature: (tool_result, file_path, tool_use_result) -> Optional[ToolOutput]
+ToolOutputParser = Callable[..., Optional[ToolOutput]]
-# Registry of tool output parsers: tool_name -> parser(tool_result, file_path) -> Optional[ToolOutput]
+# 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.
TOOL_OUTPUT_PARSERS: dict[str, ToolOutputParser] = {
"Read": parse_read_output,
"Edit": parse_edit_output,
@@ -479,13 +572,18 @@ def parse_exitplanmode_output(
"Task": parse_task_output,
"AskUserQuestion": parse_askuserquestion_output,
"ExitPlanMode": parse_exitplanmode_output,
+ "WebSearch": parse_websearch_output,
}
+# Parsers that accept the extended signature with tool_use_result
+PARSERS_WITH_TOOL_USE_RESULT: set[str] = {"WebSearch"}
+
def create_tool_output(
tool_name: str,
tool_result: ToolResultContent,
file_path: Optional[str] = None,
+ tool_use_result: Optional[ToolUseResult] = None,
) -> ToolOutput:
"""Create typed tool output from raw ToolResultContent.
@@ -497,15 +595,21 @@ def create_tool_output(
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.)
Returns:
A typed output model if parsing succeeds, ToolResultContent as fallback.
"""
- # Look up parser in registry and parse if available
- if (parser := TOOL_OUTPUT_PARSERS.get(tool_name)) and (
- parsed := parser(tool_result, file_path)
- ):
- return parsed
+ # Look up parser in registry
+ parser = TOOL_OUTPUT_PARSERS.get(tool_name)
+ if parser:
+ # Use extended signature for parsers that support tool_use_result
+ if tool_name in PARSERS_WITH_TOOL_USE_RESULT:
+ parsed = parser(tool_result, file_path, tool_use_result)
+ else:
+ parsed = parser(tool_result, file_path)
+ if parsed:
+ return parsed
# Fallback to raw ToolResultContent
return tool_result
@@ -570,6 +674,7 @@ def create_tool_result_message(
meta: MessageMeta,
tool_result: ToolResultContent,
tool_use_context: dict[str, ToolUseContent],
+ tool_use_result: Optional[ToolUseResult] = None,
) -> ToolItemResult:
"""Create ToolItemResult from a tool_result content item.
@@ -577,6 +682,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
Returns:
ToolItemResult with tool_result content model
@@ -598,6 +704,7 @@ def create_tool_result_message(
result_tool_name or "",
tool_result,
result_file_path,
+ tool_use_result,
)
# Create content model with rendering context
diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py
index 0a47375f..8bc1ec68 100644
--- a/claude_code_log/html/renderer.py
+++ b/claude_code_log/html/renderer.py
@@ -35,6 +35,7 @@
TaskInput,
TodoWriteInput,
ToolUseContent,
+ WebSearchInput,
WriteInput,
# Tool output types
AskUserQuestionOutput,
@@ -44,6 +45,7 @@
ReadOutput,
TaskOutput,
ToolResultContent,
+ WebSearchOutput,
WriteOutput,
)
from ..renderer import (
@@ -96,6 +98,8 @@
format_task_output,
format_todowrite_input,
format_tool_result_content_raw,
+ format_websearch_input,
+ format_websearch_output,
format_write_input,
format_write_output,
render_params_table,
@@ -302,6 +306,10 @@ def format_ExitPlanModeInput(
"""Format → empty string (no content)."""
return format_exitplanmode_input(input)
+ def format_WebSearchInput(self, input: WebSearchInput, _: TemplateMessage) -> str:
+ """Format → search query display."""
+ return format_websearch_input(input)
+
def format_ToolUseContent(self, content: ToolUseContent, _: TemplateMessage) -> str:
"""Format →
."""
return render_params_table(content.input)
@@ -342,6 +350,12 @@ def format_ExitPlanModeOutput(
"""Format → status message."""
return format_exitplanmode_output(output)
+ def format_WebSearchOutput(
+ self, output: WebSearchOutput, _: TemplateMessage
+ ) -> str:
+ """Format → list of clickable search result links."""
+ return format_websearch_output(output)
+
def format_ToolResultContent(
self, output: ToolResultContent, _: TemplateMessage
) -> str:
@@ -423,6 +437,12 @@ def title_BashInput(self, input: BashInput, message: TemplateMessage) -> str:
"""Title → '💻 Bash '."""
return self._tool_title(message, "💻", input.description)
+ def title_WebSearchInput(
+ self, input: WebSearchInput, message: TemplateMessage
+ ) -> str:
+ """Title → '🔎 WebSearch '."""
+ return self._tool_title(message, "🔎", input.query)
+
def _flatten_preorder(
self, roots: list[TemplateMessage]
) -> list[Tuple[TemplateMessage, str, str, str]]:
diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py
index 2b99d352..99719007 100644
--- a/claude_code_log/html/tool_formatters.py
+++ b/claude_code_log/html/tool_formatters.py
@@ -43,6 +43,8 @@
TaskOutput,
TodoWriteInput,
ToolResultContent,
+ WebSearchInput,
+ WebSearchOutput,
WriteInput,
WriteOutput,
)
@@ -210,6 +212,60 @@ def format_exitplanmode_result(content: str) -> str:
return content
+# -- WebSearch Tool -----------------------------------------------------------
+
+
+def format_websearch_input(search_input: WebSearchInput) -> str:
+ """Format WebSearch tool use content showing the search query.
+
+ Args:
+ search_input: Typed WebSearchInput with query parameter.
+
+ Only shows the query if it exceeds 100 chars (truncated in title).
+ Otherwise returns empty since the full query is already in the title.
+ """
+ if len(search_input.query) <= 100:
+ return "" # Full query shown in title
+ escaped_query = escape_html(search_input.query)
+ return f'{escaped_query}
'
+
+
+def _websearch_as_markdown(output: WebSearchOutput) -> str:
+ """Convert WebSearch output to markdown: summary, then links at bottom."""
+ parts: list[str] = []
+
+ # Summary first (the analysis text)
+ if output.summary:
+ parts.append(output.summary)
+
+ # Links at the bottom after a separator
+ if output.links:
+ if parts:
+ parts.append("") # Blank line before separator
+ parts.append("---")
+ parts.append("") # Blank line after separator
+ for link in output.links:
+ parts.append(f"- [{link.title}]({link.url})")
+ elif not output.summary:
+ # Only show "no results" if there's also no summary
+ parts.append("*No results found*")
+
+ return "\n".join(parts)
+
+
+def format_websearch_output(output: WebSearchOutput) -> str:
+ """Format WebSearch tool result as collapsible markdown.
+
+ Args:
+ output: Parsed WebSearchOutput with preamble, links, and summary.
+
+ Combines preamble + links as markdown list + summary into a single
+ markdown block, rendered as collapsible content.
+ """
+ markdown_content = _websearch_as_markdown(output)
+ return render_markdown_collapsible(markdown_content, "websearch-results")
+
+
# -- TodoWrite Tool -----------------------------------------------------------
@@ -711,6 +767,7 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str:
"format_multiedit_input",
"format_bash_input",
"format_task_input",
+ "format_websearch_input",
# Tool output formatters (called by HtmlRenderer.format_{OutputClass})
"format_read_output",
"format_write_output",
@@ -719,6 +776,7 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str:
"format_task_output",
"format_askuserquestion_output",
"format_exitplanmode_output",
+ "format_websearch_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 94a07cff..829e7232 100644
--- a/claude_code_log/markdown/renderer.py
+++ b/claude_code_log/markdown/renderer.py
@@ -41,6 +41,7 @@
TaskInput,
TodoWriteInput,
ToolUseContent,
+ WebSearchInput,
WriteInput,
# Tool output types
AskUserQuestionOutput,
@@ -51,6 +52,7 @@
ReadOutput,
TaskOutput,
ToolResultContent,
+ WebSearchOutput,
WriteOutput,
)
from ..renderer import (
@@ -498,6 +500,11 @@ def format_ExitPlanModeInput(
# Title contains "Exiting plan mode", body is empty
return ""
+ def format_WebSearchInput(self, _input: WebSearchInput, _: TemplateMessage) -> str:
+ """Format → '' (query shown in title)."""
+ # Query is shown in the title, body is empty
+ 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)
@@ -609,6 +616,30 @@ def format_ExitPlanModeOutput(
return f"{status}\n\n{output.message}"
return status
+ def format_WebSearchOutput(
+ self, output: WebSearchOutput, _: TemplateMessage
+ ) -> str:
+ """Format → summary, then links at bottom after separator."""
+ parts: list[str] = []
+
+ # Summary first (the analysis text)
+ if output.summary:
+ parts.append(self._quote(output.summary))
+
+ # Links at the bottom after a separator
+ if output.links:
+ if parts:
+ parts.append("")
+ parts.append("---")
+ parts.append("")
+ for link in output.links:
+ parts.append(f"- [{link.title}]({link.url})")
+ elif not output.summary:
+ # Only show "no results" if there's also no summary
+ parts.append("*No results found*")
+
+ return "\n".join(parts)
+
def format_ToolResultContent(
self, output: ToolResultContent, message: TemplateMessage
) -> str:
@@ -684,6 +715,10 @@ def title_ExitPlanModeInput(
"""Title → '📝 Exiting plan mode'."""
return "📝 Exiting plan mode"
+ def title_WebSearchInput(self, input: WebSearchInput, _: TemplateMessage) -> str:
+ """Title → '🔎 WebSearch `query`'."""
+ return f"🔎 WebSearch `{input.query}`"
+
def title_ThinkingMessage(
self, _content: ThinkingMessage, _message: TemplateMessage
) -> str:
diff --git a/claude_code_log/models.py b/claude_code_log/models.py
index 80c388e6..011873b8 100644
--- a/claude_code_log/models.py
+++ b/claude_code_log/models.py
@@ -871,6 +871,12 @@ class ExitPlanModeInput(BaseModel):
teammateCount: Optional[int] = None
+class WebSearchInput(BaseModel):
+ """Input parameters for the WebSearch tool."""
+
+ query: str
+
+
# Union of all typed tool inputs
ToolInput = Union[
BashInput,
@@ -884,6 +890,7 @@ class ExitPlanModeInput(BaseModel):
TodoWriteInput,
AskUserQuestionInput,
ExitPlanModeInput,
+ WebSearchInput,
ToolUseContent, # Generic fallback when no specialized parser
]
@@ -1032,6 +1039,28 @@ class ExitPlanModeOutput:
approved: bool # Whether the plan was approved
+@dataclass
+class WebSearchLink:
+ """Single search result link from WebSearch output."""
+
+ title: str
+ url: str
+
+
+@dataclass
+class WebSearchOutput:
+ """Parsed WebSearch tool output.
+
+ Symmetric with WebSearchInput for tool_use → tool_result pairing.
+ Parsed as preamble/links/summary for flexible rendering.
+ """
+
+ query: str
+ links: list[WebSearchLink]
+ preamble: Optional[str] = None # Text before the Links (usually query header)
+ summary: Optional[str] = None # Markdown analysis after the links
+
+
# Union of all specialized output types + ToolResultContent as generic fallback
ToolOutput = Union[
ReadOutput,
@@ -1041,6 +1070,7 @@ class ExitPlanModeOutput:
TaskOutput,
AskUserQuestionOutput,
ExitPlanModeOutput,
+ WebSearchOutput,
# TODO: Add as parsers are implemented:
# GlobOutput, GrepOutput
ToolResultContent, # Generic fallback for unparsed results
diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py
index e54594a2..1f3c3496 100644
--- a/claude_code_log/renderer.py
+++ b/claude_code_log/renderer.py
@@ -22,6 +22,7 @@
SystemTranscriptEntry,
SummaryTranscriptEntry,
QueueOperationTranscriptEntry,
+ UserTranscriptEntry,
ContentItem,
TextContent,
ToolResultContent,
@@ -1856,8 +1857,15 @@ def _render_messages(
chunk_meta, tool_item, ctx.tool_use_context
)
elif isinstance(tool_item, ToolResultContent):
+ # Extract toolUseResult from user entries for structured parsing
+ entry_tool_use_result = None
+ if isinstance(message, UserTranscriptEntry):
+ entry_tool_use_result = message.toolUseResult
tool_result = create_tool_result_message(
- chunk_meta, tool_item, ctx.tool_use_context
+ chunk_meta,
+ tool_item,
+ ctx.tool_use_context,
+ entry_tool_use_result,
)
elif isinstance(tool_item, ThinkingContent):
# Pass usage only if not yet used
diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py
index 1eb1e233..47c59592 100644
--- a/claude_code_log/tui.py
+++ b/claude_code_log/tui.py
@@ -641,6 +641,7 @@ def compose(self) -> ComposeResult:
self._pages[self._current_page],
id="md-viewer",
show_table_of_contents=True,
+ open_links=False, # We handle links in go() override
)
footer_text = "Press ESC or q to close | t: toggle ToC"
if self._is_paginated:
diff --git a/dev-docs/implementing-a-tool-renderer.md b/dev-docs/implementing-a-tool-renderer.md
new file mode 100644
index 00000000..45972fc8
--- /dev/null
+++ b/dev-docs/implementing-a-tool-renderer.md
@@ -0,0 +1,273 @@
+# Implementing a Tool Renderer
+
+This guide walks through adding rendering support for a new Claude Code tool, using WebSearch as an example.
+
+## 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
+
+Create a parser function that extracts structured data from the raw result. Some tools (like WebSearch) have structured `toolUseResult` data available on the transcript entry, which is cleaner than regex parsing:
+
+```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 `PARSERS_WITH_TOOL_USE_RESULT`:
+
+```python
+TOOL_OUTPUT_PARSERS: dict[str, ToolOutputParser] = {
+ # ... existing entries ...
+ "WebSearch": parse_websearch_output,
+}
+
+# Parsers that accept the extended signature with tool_use_result
+PARSERS_WITH_TOOL_USE_RESULT: set[str] = {"WebSearch"}
+```
+
+## Step 3: Implement HTML Formatters
+
+In `html/tool_formatters.py`:
+
+### Input Formatter
+
+```python
+def format_websearch_input(search_input: WebSearchInput) -> str:
+ """Format WebSearch tool use content."""
+ escaped_query = escape_html(search_input.query)
+ return f'🔍 {escaped_query}
'
+```
+
+### 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 test cases in the appropriate test files:
+
+1. **Parser tests** - Verify output parsing handles various formats
+2. **Formatter tests** - Verify HTML/Markdown output is correct
+3. **Integration tests** - Verify end-to-end rendering
+
+## Checklist
+
+- [ ] Add input model to `models.py`
+- [ ] Add output model to `models.py`
+- [ ] Update `ToolInput` union
+- [ ] Update `ToolOutput` union
+- [ ] Add to `TOOL_INPUT_MODELS` in factory
+- [ ] Implement output parser function
+- [ ] Add to `TOOL_OUTPUT_PARSERS` in factory
+- [ ] Add to `PARSERS_WITH_TOOL_USE_RESULT` if using structured data (optional)
+- [ ] Add HTML input formatter
+- [ ] Add HTML output formatter
+- [ ] Wire up HTML renderer format methods
+- [ ] Add HTML title method (if needed)
+- [ ] Add Markdown format methods
+- [ ] Add Markdown title method
+- [ ] Add tests
+- [ ] Update `__all__` exports