From 89db5ecf8fb2188b9c712bb780fef035f643d0ce Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 23 Jan 2026 20:34:53 +0100 Subject: [PATCH 1/7] Add WebSearch tool models and factory parser - Add WebSearchInput model for tool use (query parameter) - Add WebSearchLink and WebSearchOutput models for parsed results - Add parse_websearch_output() to extract query and links from result text - Register WebSearch in TOOL_INPUT_MODELS and TOOL_OUTPUT_PARSERS Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/tool_factory.py | 58 +++++++++++++++++++++++ claude_code_log/models.py | 28 +++++++++++ 2 files changed, 86 insertions(+) diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py index af55462a..f93e682c 100644 --- a/claude_code_log/factories/tool_factory.py +++ b/claude_code_log/factories/tool_factory.py @@ -36,6 +36,7 @@ ToolResultMessage, ToolUseContent, ToolUseMessage, + WebSearchInput, WriteInput, # Tool output models AskUserQuestionAnswer, @@ -46,6 +47,8 @@ ReadOutput, TaskOutput, ToolOutput, + WebSearchLink, + WebSearchOutput, WriteOutput, ) @@ -67,6 +70,7 @@ "AskUserQuestion": AskUserQuestionInput, "ask_user_question": AskUserQuestionInput, # Legacy tool name "ExitPlanMode": ExitPlanModeInput, + "WebSearch": WebSearchInput, } @@ -466,6 +470,59 @@ def parse_exitplanmode_output( return ExitPlanModeOutput(message=message, approved=approved) +def parse_websearch_output( + tool_result: ToolResultContent, file_path: Optional[str] +) -> Optional[WebSearchOutput]: + """Parse WebSearch tool result into structured content. + + Parses the result format: + 'Web search results for query: "..."\n\nLinks: [{...}, ...]' + + Args: + tool_result: The tool result content + file_path: Unused for WebSearch tool + + Returns: + WebSearchOutput with query and links + """ + import json + + del file_path # Unused + if not (content := _extract_tool_result_text(tool_result)): + return None + + # Extract query from the first line + # Format: 'Web search results for query: "..."' + query_match = re.match( + r'Web search results for query: "([^"]+)"', + content, + ) + if not query_match: + return None + query = query_match.group(1) + + # Extract Links JSON array + # Format: 'Links: [{...}, ...]' + links_match = re.search(r"Links: (\[.*?\])", content, re.DOTALL) + if not links_match: + return WebSearchOutput(query=query, links=[]) + + try: + links_json = json.loads(links_match.group(1)) + links = [ + WebSearchLink( + title=link.get("title", ""), + url=link.get("url", ""), + ) + for link in links_json + if isinstance(link, dict) + ] + except (json.JSONDecodeError, TypeError): + links = [] + + return WebSearchOutput(query=query, links=links) + + # Type alias for tool output parsers ToolOutputParser = Callable[[ToolResultContent, Optional[str]], Optional[ToolOutput]] @@ -479,6 +536,7 @@ def parse_exitplanmode_output( "Task": parse_task_output, "AskUserQuestion": parse_askuserquestion_output, "ExitPlanMode": parse_exitplanmode_output, + "WebSearch": parse_websearch_output, } diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 80c388e6..0c7af4bb 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,26 @@ 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. + Contains the query and list of result links. + """ + + query: str + links: list[WebSearchLink] + + # Union of all specialized output types + ToolResultContent as generic fallback ToolOutput = Union[ ReadOutput, @@ -1041,6 +1068,7 @@ class ExitPlanModeOutput: TaskOutput, AskUserQuestionOutput, ExitPlanModeOutput, + WebSearchOutput, # TODO: Add as parsers are implemented: # GlobOutput, GrepOutput ToolResultContent, # Generic fallback for unparsed results From 5e2e4521dd4a66270150342fb45459653f20a2ab Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 23 Jan 2026 20:45:49 +0100 Subject: [PATCH 2/7] Add WebSearch HTML and Markdown formatters HTML rendering: - Add format_websearch_input() - displays search query - Add format_websearch_output() - renders clickable link list - Add title_WebSearchInput() - shows query in message header Markdown rendering: - Add format_WebSearchInput() - empty (query in title) - Add format_WebSearchOutput() - renders links as markdown list - Add title_WebSearchInput() - shows query in heading Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/tool_factory.py | 20 ++++++----- claude_code_log/html/renderer.py | 20 +++++++++++ claude_code_log/html/tool_formatters.py | 42 +++++++++++++++++++++++ claude_code_log/markdown/renderer.py | 22 ++++++++++++ 4 files changed, 95 insertions(+), 9 deletions(-) diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py index f93e682c..3d77a0a7 100644 --- a/claude_code_log/factories/tool_factory.py +++ b/claude_code_log/factories/tool_factory.py @@ -508,15 +508,17 @@ def parse_websearch_output( return WebSearchOutput(query=query, links=[]) try: - links_json = json.loads(links_match.group(1)) - links = [ - WebSearchLink( - title=link.get("title", ""), - url=link.get("url", ""), - ) - for link in links_json - if isinstance(link, dict) - ] + links_json: list[Any] = json.loads(links_match.group(1)) + links: list[WebSearchLink] = [] + for item in links_json: + if isinstance(item, dict): + link = cast(dict[str, Any], item) + links.append( + WebSearchLink( + title=str(link.get("title", "")), + url=str(link.get("url", "")), + ) + ) except (json.JSONDecodeError, TypeError): links = [] diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 0a47375f..89bd4a29 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, "🔎", f'"{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..f58caa2b 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,44 @@ 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. + + The query is displayed prominently since it's the main input. + """ + escaped_query = escape_html(search_input.query) + return f'
{escaped_query}
' + + +def format_websearch_output(output: WebSearchOutput) -> str: + """Format WebSearch tool result with clickable links. + + Args: + output: Parsed WebSearchOutput with query and links. + + Renders the search results as a list of clickable links. + """ + if not output.links: + return '
No results found
' + + html_parts: list[str] = ['
    '] + for link in output.links: + escaped_title = escape_html(link.title) + escaped_url = escape_html(link.url) + html_parts.append( + f'
  • ' + f"{escaped_title}
  • " + ) + html_parts.append("
") + return "".join(html_parts) + + # -- TodoWrite Tool ----------------------------------------------------------- @@ -711,6 +751,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 +760,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..bc427aa1 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,17 @@ def format_ExitPlanModeOutput( return f"{status}\n\n{output.message}" return status + def format_WebSearchOutput( + self, output: WebSearchOutput, _: TemplateMessage + ) -> str: + """Format → markdown list of links.""" + if not output.links: + return "*No results found*" + parts: list[str] = [] + for link in output.links: + parts.append(f"- [{link.title}]({link.url})") + return "\n".join(parts) + def format_ToolResultContent( self, output: ToolResultContent, message: TemplateMessage ) -> str: @@ -684,6 +702,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: From bb00e22a98e47060418a4443e610952f6365273e Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 24 Jan 2026 12:03:08 +0100 Subject: [PATCH 3/7] Add documentation for implementing tool renderers Adds dev-docs/implementing-a-tool-renderer.md with step-by-step guide: - Models: Input/Output types and union updates - Factory: Parser registration and implementation - HTML formatters: Input/output formatting - Renderer integration: HTML and Markdown - Checklist for completeness Uses WebSearch as the running example throughout. Co-Authored-By: Claude Opus 4.5 --- dev-docs/implementing-a-tool-renderer.md | 237 +++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 dev-docs/implementing-a-tool-renderer.md diff --git a/dev-docs/implementing-a-tool-renderer.md b/dev-docs/implementing-a-tool-renderer.md new file mode 100644 index 00000000..affd38ce --- /dev/null +++ b/dev-docs/implementing-a-tool-renderer.md @@ -0,0 +1,237 @@ +# 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] +``` + +### 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: + +```python +def parse_websearch_output( + tool_result: ToolResultContent, file_path: Optional[str] +) -> Optional[WebSearchOutput]: + """Parse WebSearch tool result into structured content.""" + del file_path # Unused + if not (content := _extract_tool_result_text(tool_result)): + return None + + # Parse the format: 'Web search results for query: "..."\n\nLinks: [...]' + # Extract query and links... + return WebSearchOutput(query=query, links=links) +``` + +### Register Output Parser + +Add to `TOOL_OUTPUT_PARSERS`: + +```python +TOOL_OUTPUT_PARSERS: dict[str, ToolOutputParser] = { + # ... existing entries ... + "WebSearch": parse_websearch_output, +} +``` + +## 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 + +```python +def format_websearch_output(output: WebSearchOutput) -> str: + """Format WebSearch tool result with clickable links.""" + html_parts = ['
    '] + for link in output.links: + escaped_title = escape_html(link.title) + escaped_url = escape_html(link.url) + html_parts.append( + f'
  • {escaped_title}
  • ' + ) + html_parts.append("
") + return "".join(html_parts) +``` + +### 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 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 From 31003396fc849a8631564efe7832cf0693bfb685 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 24 Jan 2026 12:12:13 +0100 Subject: [PATCH 4/7] Add analysis content support to WebSearch output Refactored to parse WebSearch result as preamble/links/summary: - Preamble: text before "Links:" (stripped of redundant query header) - Links: JSON array parsed into WebSearchLink objects - Summary: markdown analysis after the links HTML rendering: - Combines all parts into single markdown: preamble + links_list + summary - Renders via render_markdown_collapsible() for unified formatting - Input body empty unless query >100 chars (already shown in title) Markdown rendering: - Preamble as blockquote, links as markdown list, summary as collapsible - Input body always empty (full query in title) Also fixed: - Removed extra quotes around query in title (was showing ""query"") - Fixed query extraction regex to handle queries containing quotes - Strip redundant "Web search results for query" from preamble Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/tool_factory.py | 63 ++++++++++++++++++----- claude_code_log/html/renderer.py | 2 +- claude_code_log/html/tool_formatters.py | 52 +++++++++++++------ claude_code_log/markdown/renderer.py | 24 +++++++-- claude_code_log/models.py | 4 +- dev-docs/implementing-a-tool-renderer.md | 52 ++++++++++++++----- 6 files changed, 145 insertions(+), 52 deletions(-) diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py index 3d77a0a7..02b4278f 100644 --- a/claude_code_log/factories/tool_factory.py +++ b/claude_code_log/factories/tool_factory.py @@ -473,17 +473,20 @@ def parse_exitplanmode_output( def parse_websearch_output( tool_result: ToolResultContent, file_path: Optional[str] ) -> Optional[WebSearchOutput]: - """Parse WebSearch tool result into structured content. + """Parse WebSearch tool result into preamble/links/summary. Parses the result format: - 'Web search results for query: "..."\n\nLinks: [{...}, ...]' + 'Links: [{...}, ...]' + + The preamble typically contains 'Web search results for query: "..."' + The summary contains markdown analysis after the Links JSON. Args: tool_result: The tool result content file_path: Unused for WebSearch tool Returns: - WebSearchOutput with query and links + WebSearchOutput with query, links, preamble, and summary """ import json @@ -491,24 +494,53 @@ def parse_websearch_output( if not (content := _extract_tool_result_text(tool_result)): return None - # Extract query from the first line + # Extract query from the content (anywhere in preamble) # Format: 'Web search results for query: "..."' - query_match = re.match( - r'Web search results for query: "([^"]+)"', + # Note: query itself may contain quotes, so match until quote + newline + query_match = re.search( + r'Web search results for query: "(.+?)"\n', content, ) if not query_match: return None query = query_match.group(1) - # Extract Links JSON array - # Format: 'Links: [{...}, ...]' - links_match = re.search(r"Links: (\[.*?\])", content, re.DOTALL) - if not links_match: - return WebSearchOutput(query=query, links=[]) - + # Split content into preamble/links/summary + # Find "Links: [" and then match the JSON array + links_start = content.find("Links: [") + if links_start == -1: + # No links found - return with just the query + return WebSearchOutput(query=query, links=[], preamble=None) + + # Preamble is everything before "Links:", minus the query header line + # (which is redundant since query is already extracted and shown in title) + raw_preamble = content[:links_start].strip() + # Strip the "Web search results for query: ..." line + preamble_lines = raw_preamble.split("\n") + filtered_lines = [ + line + for line in preamble_lines + if not line.startswith('Web search results for query: "') + ] + preamble = "\n".join(filtered_lines).strip() or None + + # Find the end of the JSON array (matching brackets) + json_start = links_start + 7 # Position of '[' + bracket_count = 0 + json_end = json_start + for i, char in enumerate(content[json_start:], json_start): + if char == "[": + bracket_count += 1 + elif char == "]": + bracket_count -= 1 + if bracket_count == 0: + json_end = i + 1 + break + + # Parse links JSON + links_json_str = content[json_start:json_end] try: - links_json: list[Any] = json.loads(links_match.group(1)) + links_json: list[Any] = json.loads(links_json_str) links: list[WebSearchLink] = [] for item in links_json: if isinstance(item, dict): @@ -522,7 +554,10 @@ def parse_websearch_output( except (json.JSONDecodeError, TypeError): links = [] - return WebSearchOutput(query=query, links=links) + # Summary is everything after the JSON array + summary = content[json_end:].strip() or None + + return WebSearchOutput(query=query, links=links, preamble=preamble, summary=summary) # Type alias for tool output parsers diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 89bd4a29..8bc1ec68 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -441,7 +441,7 @@ def title_WebSearchInput( self, input: WebSearchInput, message: TemplateMessage ) -> str: """Title → '🔎 WebSearch '.""" - return self._tool_title(message, "🔎", f'"{input.query}"') + return self._tool_title(message, "🔎", input.query) def _flatten_preorder( self, roots: list[TemplateMessage] diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index f58caa2b..4dd1ed95 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -221,33 +221,51 @@ def format_websearch_input(search_input: WebSearchInput) -> str: Args: search_input: Typed WebSearchInput with query parameter. - The query is displayed prominently since it's the main input. + 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: preamble + links list + summary.""" + parts: list[str] = [] + + # Preamble (text before Links) + if output.preamble: + parts.append(output.preamble) + parts.append("") # Blank line + + # Links as markdown list + if output.links: + for link in output.links: + parts.append(f"- [{link.title}]({link.url})") + parts.append("") # Blank line after links + else: + parts.append("*No results found*") + parts.append("") + + # Summary (text after Links) + if output.summary: + parts.append(output.summary) + + return "\n".join(parts) + + def format_websearch_output(output: WebSearchOutput) -> str: - """Format WebSearch tool result with clickable links. + """Format WebSearch tool result as collapsible markdown. Args: - output: Parsed WebSearchOutput with query and links. + output: Parsed WebSearchOutput with preamble, links, and summary. - Renders the search results as a list of clickable links. + Combines preamble + links as markdown list + summary into a single + markdown block, rendered as collapsible content. """ - if not output.links: - return '
No results found
' - - html_parts: list[str] = ['
    '] - for link in output.links: - escaped_title = escape_html(link.title) - escaped_url = escape_html(link.url) - html_parts.append( - f'
  • ' - f"{escaped_title}
  • " - ) - html_parts.append("
") - return "".join(html_parts) + markdown_content = _websearch_as_markdown(output) + return render_markdown_collapsible(markdown_content, "websearch-results") # -- TodoWrite Tool ----------------------------------------------------------- diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index bc427aa1..5f052c28 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -619,12 +619,26 @@ def format_ExitPlanModeOutput( def format_WebSearchOutput( self, output: WebSearchOutput, _: TemplateMessage ) -> str: - """Format → markdown list of links.""" - if not output.links: - return "*No results found*" + """Format → preamble + markdown links list + summary.""" parts: list[str] = [] - for link in output.links: - parts.append(f"- [{link.title}]({link.url})") + + # Preamble (text before Links) + if output.preamble: + parts.append(self._quote(output.preamble)) + parts.append("") + + # Links as markdown list + if output.links: + for link in output.links: + parts.append(f"- [{link.title}]({link.url})") + else: + parts.append("*No results found*") + + # Summary (text after Links) as collapsible + if output.summary: + parts.append("") + parts.append(self._collapsible("Analysis", self._quote(output.summary))) + return "\n".join(parts) def format_ToolResultContent( diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 0c7af4bb..011873b8 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -1052,11 +1052,13 @@ class WebSearchOutput: """Parsed WebSearch tool output. Symmetric with WebSearchInput for tool_use → tool_result pairing. - Contains the query and list of result links. + 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 diff --git a/dev-docs/implementing-a-tool-renderer.md b/dev-docs/implementing-a-tool-renderer.md index affd38ce..538ca7dd 100644 --- a/dev-docs/implementing-a-tool-renderer.md +++ b/dev-docs/implementing-a-tool-renderer.md @@ -39,8 +39,12 @@ 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: @@ -82,14 +86,27 @@ Create a parser function that extracts structured data from the raw result: def parse_websearch_output( tool_result: ToolResultContent, file_path: Optional[str] ) -> Optional[WebSearchOutput]: - """Parse WebSearch tool result into structured content.""" + """Parse WebSearch tool result as preamble/links/summary.""" del file_path # Unused if not (content := _extract_tool_result_text(tool_result)): return None - # Parse the format: 'Web search results for query: "..."\n\nLinks: [...]' - # Extract query and links... - return WebSearchOutput(query=query, links=links) + # Extract query from anywhere in content + query_match = re.search(r'Web search results for query: "([^"]+)"', content) + if not query_match: + return None + query = query_match.group(1) + + # Split into preamble/links/summary + links_start = content.find("Links: [") + preamble = content[:links_start].strip() if links_start > 0 else None + + # Parse Links JSON array, find end position + # ... bracket matching to find JSON array end ... + + summary = content[json_end:].strip() or None + + return WebSearchOutput(query=query, links=links, preamble=preamble, summary=summary) ``` ### Register Output Parser @@ -118,18 +135,25 @@ def format_websearch_input(search_input: WebSearchInput) -> str: ### Output Formatter +For tools with structured content like WebSearch, combine all parts into markdown then render: + ```python -def format_websearch_output(output: WebSearchOutput) -> str: - """Format WebSearch tool result with clickable links.""" - html_parts = ['
    '] +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: - escaped_title = escape_html(link.title) - escaped_url = escape_html(link.url) - html_parts.append( - f'
  • {escaped_title}
  • ' - ) - html_parts.append("
") - return "".join(html_parts) + 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 From 0775b30c285b52b6d986b86c687c3074cddb20c5 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 24 Jan 2026 13:29:01 +0100 Subject: [PATCH 5/7] Use structured toolUseResult for WebSearch parsing WebSearch results in JSONL have structured `toolUseResult` data containing the query, links array, and analysis text. This is cleaner and more reliable than regex parsing from text content. Changes: - Add `_parse_websearch_from_structured()` for parsing structured data - Refactor regex parsing to `_parse_websearch_from_text()` as fallback - Add optional `tool_use_result` parameter through the parsing pipeline - Add `PARSERS_WITH_TOOL_USE_RESULT` registry for extended parsers - Update renderer to pass `entry.toolUseResult` for user entries - Update documentation with structured parsing approach Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/tool_factory.py | 139 ++++++++++++++++++---- claude_code_log/renderer.py | 10 +- dev-docs/implementing-a-tool-renderer.md | 68 ++++++++--- 3 files changed, 177 insertions(+), 40 deletions(-) diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py index 02b4278f..067f3371 100644 --- a/claude_code_log/factories/tool_factory.py +++ b/claude_code_log/factories/tool_factory.py @@ -36,6 +36,7 @@ ToolResultMessage, ToolUseContent, ToolUseMessage, + ToolUseResult, WebSearchInput, WriteInput, # Tool output models @@ -470,30 +471,80 @@ def parse_exitplanmode_output( return ExitPlanModeOutput(message=message, approved=approved) -def parse_websearch_output( - tool_result: ToolResultContent, file_path: Optional[str] +def _parse_websearch_from_structured( + tool_use_result: ToolUseResult, ) -> Optional[WebSearchOutput]: - """Parse WebSearch tool result into preamble/links/summary. + """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_from_text(content: str) -> Optional[WebSearchOutput]: + """Parse WebSearch from text content using regex. + + Fallback parser for when structured toolUseResult is not available. Parses the result format: 'Links: [{...}, ...]' - The preamble typically contains 'Web search results for query: "..."' - The summary contains markdown analysis after the Links JSON. - Args: - tool_result: The tool result content - file_path: Unused for WebSearch tool + content: The text content to parse Returns: - WebSearchOutput with query, links, preamble, and summary + WebSearchOutput if parsing succeeds, None otherwise """ import json - del file_path # Unused - if not (content := _extract_tool_result_text(tool_result)): - return None - # Extract query from the content (anywhere in preamble) # Format: 'Web search results for query: "..."' # Note: query itself may contain quotes, so match until quote + newline @@ -560,11 +611,46 @@ def parse_websearch_output( return WebSearchOutput(query=query, links=links, preamble=preamble, 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 into preamble/links/summary. + + Uses structured toolUseResult data when available (preferred), with + fallback to regex parsing from text content. + + Args: + tool_result: The tool result content + file_path: Unused for WebSearch tool + tool_use_result: Optional structured toolUseResult from the entry + + Returns: + WebSearchOutput with query, links, preamble, and summary + """ + del file_path # Unused + + # Try structured data first (cleaner, more reliable) + if tool_use_result is not None: + if parsed := _parse_websearch_from_structured(tool_use_result): + return parsed + + # Fallback to regex parsing from text content + if content := _extract_tool_result_text(tool_result): + return _parse_websearch_from_text(content) + + return None + + # 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, @@ -576,11 +662,15 @@ def parse_websearch_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. @@ -592,15 +682,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 @@ -665,6 +761,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. @@ -672,6 +769,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 @@ -693,6 +791,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/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/dev-docs/implementing-a-tool-renderer.md b/dev-docs/implementing-a-tool-renderer.md index 538ca7dd..26d16c2b 100644 --- a/dev-docs/implementing-a-tool-renderer.md +++ b/dev-docs/implementing-a-tool-renderer.md @@ -80,44 +80,73 @@ TOOL_INPUT_MODELS: dict[str, type[BaseModel]] = { ### Implement Output Parser -Create a parser function that extracts structured data from the raw result: +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_output( - tool_result: ToolResultContent, file_path: Optional[str] +def _parse_websearch_from_structured( + tool_use_result: ToolUseResult, ) -> Optional[WebSearchOutput]: - """Parse WebSearch tool result as preamble/links/summary.""" - del file_path # Unused - if not (content := _extract_tool_result_text(tool_result)): + """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_from_text(content: str) -> Optional[WebSearchOutput]: + """Fallback: parse from text content using regex.""" + # Extract query, Links JSON array, and summary from text content + # ... regex parsing ... - # Extract query from anywhere in content - query_match = re.search(r'Web search results for query: "([^"]+)"', content) - if not query_match: - return None - query = query_match.group(1) - # Split into preamble/links/summary - links_start = content.find("Links: [") - preamble = content[:links_start].strip() if links_start > 0 else None +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. - # Parse Links JSON array, find end position - # ... bracket matching to find JSON array end ... + Uses structured toolUseResult when available (preferred), with + fallback to regex parsing from text content. + """ + del file_path # Unused - summary = content[json_end:].strip() or None + # Try structured data first (cleaner, more reliable) + if tool_use_result is not None: + if parsed := _parse_websearch_from_structured(tool_use_result): + return parsed - return WebSearchOutput(query=query, links=links, preamble=preamble, summary=summary) + # Fallback to regex parsing from text content + if content := _extract_tool_result_text(tool_result): + return _parse_websearch_from_text(content) + + return None ``` ### Register Output Parser -Add to `TOOL_OUTPUT_PARSERS`: +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 @@ -251,6 +280,7 @@ Create test cases in the appropriate test files: - [ ] 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 From 8b31bf0d8f8d3f5e5c81a9851b4c4f8abadffd54 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 24 Jan 2026 13:44:23 +0100 Subject: [PATCH 6/7] Simplify WebSearch parser and improve rendering - Remove fallback regex parsing (see commit 0d1d2a9 if needed) - Keep only structured toolUseResult parsing - Render summary first, links at bottom after separator - Update both HTML and Markdown renderers consistently - Simplify documentation to match Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/tool_factory.py | 107 ++-------------------- claude_code_log/html/tool_formatters.py | 24 +++-- claude_code_log/markdown/renderer.py | 23 +++-- dev-docs/implementing-a-tool-renderer.md | 28 +----- 4 files changed, 37 insertions(+), 145 deletions(-) diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py index 067f3371..c3010f0a 100644 --- a/claude_code_log/factories/tool_factory.py +++ b/claude_code_log/factories/tool_factory.py @@ -530,117 +530,30 @@ def _parse_websearch_from_structured( return WebSearchOutput(query=query, links=links, preamble=None, summary=summary) -def _parse_websearch_from_text(content: str) -> Optional[WebSearchOutput]: - """Parse WebSearch from text content using regex. - - Fallback parser for when structured toolUseResult is not available. - Parses the result format: - 'Links: [{...}, ...]' - - Args: - content: The text content to parse - - Returns: - WebSearchOutput if parsing succeeds, None otherwise - """ - import json - - # Extract query from the content (anywhere in preamble) - # Format: 'Web search results for query: "..."' - # Note: query itself may contain quotes, so match until quote + newline - query_match = re.search( - r'Web search results for query: "(.+?)"\n', - content, - ) - if not query_match: - return None - query = query_match.group(1) - - # Split content into preamble/links/summary - # Find "Links: [" and then match the JSON array - links_start = content.find("Links: [") - if links_start == -1: - # No links found - return with just the query - return WebSearchOutput(query=query, links=[], preamble=None) - - # Preamble is everything before "Links:", minus the query header line - # (which is redundant since query is already extracted and shown in title) - raw_preamble = content[:links_start].strip() - # Strip the "Web search results for query: ..." line - preamble_lines = raw_preamble.split("\n") - filtered_lines = [ - line - for line in preamble_lines - if not line.startswith('Web search results for query: "') - ] - preamble = "\n".join(filtered_lines).strip() or None - - # Find the end of the JSON array (matching brackets) - json_start = links_start + 7 # Position of '[' - bracket_count = 0 - json_end = json_start - for i, char in enumerate(content[json_start:], json_start): - if char == "[": - bracket_count += 1 - elif char == "]": - bracket_count -= 1 - if bracket_count == 0: - json_end = i + 1 - break - - # Parse links JSON - links_json_str = content[json_start:json_end] - try: - links_json: list[Any] = json.loads(links_json_str) - links: list[WebSearchLink] = [] - for item in links_json: - if isinstance(item, dict): - link = cast(dict[str, Any], item) - links.append( - WebSearchLink( - title=str(link.get("title", "")), - url=str(link.get("url", "")), - ) - ) - except (json.JSONDecodeError, TypeError): - links = [] - - # Summary is everything after the JSON array - summary = content[json_end:].strip() or None - - return WebSearchOutput(query=query, links=links, preamble=preamble, 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 into preamble/links/summary. + """Parse WebSearch tool result from structured toolUseResult data. - Uses structured toolUseResult data when available (preferred), with - fallback to regex parsing from text content. + 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 + tool_result: The tool result content (unused, kept for signature compatibility) file_path: Unused for WebSearch tool - tool_use_result: Optional structured toolUseResult from the entry + tool_use_result: Structured toolUseResult from the entry Returns: - WebSearchOutput with query, links, preamble, and summary + WebSearchOutput with query, links, and summary, or None if not parseable """ - del file_path # Unused - - # Try structured data first (cleaner, more reliable) - if tool_use_result is not None: - if parsed := _parse_websearch_from_structured(tool_use_result): - return parsed + del tool_result, file_path # Unused - # Fallback to regex parsing from text content - if content := _extract_tool_result_text(tool_result): - return _parse_websearch_from_text(content) + if tool_use_result is None: + return None - return None + return _parse_websearch_from_structured(tool_use_result) # Type alias for tool output parsers diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index 4dd1ed95..99719007 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -231,26 +231,24 @@ def format_websearch_input(search_input: WebSearchInput) -> str: def _websearch_as_markdown(output: WebSearchOutput) -> str: - """Convert WebSearch output to markdown: preamble + links list + summary.""" + """Convert WebSearch output to markdown: summary, then links at bottom.""" parts: list[str] = [] - # Preamble (text before Links) - if output.preamble: - parts.append(output.preamble) - parts.append("") # Blank line + # Summary first (the analysis text) + if output.summary: + parts.append(output.summary) - # Links as markdown list + # 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})") - parts.append("") # Blank line after links - else: + elif not output.summary: + # Only show "no results" if there's also no summary parts.append("*No results found*") - parts.append("") - - # Summary (text after Links) - if output.summary: - parts.append(output.summary) return "\n".join(parts) diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index 5f052c28..829e7232 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -619,26 +619,25 @@ def format_ExitPlanModeOutput( def format_WebSearchOutput( self, output: WebSearchOutput, _: TemplateMessage ) -> str: - """Format → preamble + markdown links list + summary.""" + """Format → summary, then links at bottom after separator.""" parts: list[str] = [] - # Preamble (text before Links) - if output.preamble: - parts.append(self._quote(output.preamble)) - parts.append("") + # Summary first (the analysis text) + if output.summary: + parts.append(self._quote(output.summary)) - # Links as markdown list + # 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})") - else: + elif not output.summary: + # Only show "no results" if there's also no summary parts.append("*No results found*") - # Summary (text after Links) as collapsible - if output.summary: - parts.append("") - parts.append(self._collapsible("Analysis", self._quote(output.summary))) - return "\n".join(parts) def format_ToolResultContent( diff --git a/dev-docs/implementing-a-tool-renderer.md b/dev-docs/implementing-a-tool-renderer.md index 26d16c2b..45972fc8 100644 --- a/dev-docs/implementing-a-tool-renderer.md +++ b/dev-docs/implementing-a-tool-renderer.md @@ -105,34 +105,16 @@ def _parse_websearch_from_structured( return WebSearchOutput(query=query, links=links, preamble=None, summary=summary) -def _parse_websearch_from_text(content: str) -> Optional[WebSearchOutput]: - """Fallback: parse from text content using regex.""" - # Extract query, Links JSON array, and summary from text content - # ... regex parsing ... - - 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. - - Uses structured toolUseResult when available (preferred), with - fallback to regex parsing from text content. - """ - del file_path # Unused - - # Try structured data first (cleaner, more reliable) - if tool_use_result is not None: - if parsed := _parse_websearch_from_structured(tool_use_result): - return parsed - - # Fallback to regex parsing from text content - if content := _extract_tool_result_text(tool_result): - return _parse_websearch_from_text(content) - - return None + """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 From c6bf0830d6b343779ce11e7529bc35c73084cd0b Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 24 Jan 2026 14:19:18 +0100 Subject: [PATCH 7/7] Fix double tab opening when clicking links in TUI MarkdownViewer The MarkdownViewer had open_links=True by default, which caused the Markdown widget's on_markdown_link_clicked handler to open links via app.open_url(). Our SafeMarkdownViewer.go() override also opened links via webbrowser.open(), resulting in two tabs opening. Fix: Set open_links=False so only our go() override handles link clicks. Co-Authored-By: Claude Opus 4.5 --- claude_code_log/tui.py | 1 + 1 file changed, 1 insertion(+) 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: