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 → key | value rows
.""" 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