Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
414 changes: 414 additions & 0 deletions .claude/skills/tool-renderer/SKILL.md

Large diffs are not rendered by default.

66 changes: 62 additions & 4 deletions claude_code_log/factories/tool_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
ToolUseMessage,
ToolUseResult,
WebSearchInput,
WebFetchInput,
WriteInput,
# Tool output models
AskUserQuestionAnswer,
Expand All @@ -50,6 +51,7 @@
ToolOutput,
WebSearchLink,
WebSearchOutput,
WebFetchOutput,
WriteOutput,
)

Expand All @@ -72,6 +74,7 @@
"ask_user_question": AskUserQuestionInput, # Legacy tool name
"ExitPlanMode": ExitPlanModeInput,
"WebSearch": WebSearchInput,
"WebFetch": WebFetchInput,
}


Expand Down Expand Up @@ -556,14 +559,65 @@ def parse_websearch_output(
return _parse_websearch_from_structured(tool_use_result)


def parse_webfetch_output(
tool_result: ToolResultContent,
file_path: Optional[str],
tool_use_result: Optional[ToolUseResult] = None,
) -> Optional[WebFetchOutput]:
"""Parse WebFetch tool result from structured toolUseResult.

WebFetch results include metadata from toolUseResult:
- bytes: Size of fetched content
- code: HTTP status code
- codeText: HTTP status text
- result: The processed markdown result
- durationMs: Time taken in milliseconds
- url: The URL that was fetched

Args:
tool_result: The tool result content (used as fallback)
file_path: Unused for WebFetch tool
tool_use_result: Structured result containing rich metadata

Returns:
WebFetchOutput if parsing succeeds, None otherwise
"""
del file_path # Unused

# Prefer structured toolUseResult when available
if tool_use_result is not None and isinstance(tool_use_result, dict):
url = tool_use_result.get("url")
result = tool_use_result.get("result")

# Both url and result are required
if url and result:
return WebFetchOutput(
url=str(url),
result=str(result),
bytes=tool_use_result.get("bytes"),
code=tool_use_result.get("code"),
code_text=tool_use_result.get("codeText"),
duration_ms=tool_use_result.get("durationMs"),
)

# Fallback: try to extract from tool_result content
content = _extract_tool_result_text(tool_result)
if not content:
return None

# For fallback, we don't have the rich metadata, just the result text
# We also don't have the URL, so return None (will use generic formatter)
return None


# Type alias for tool output parsers
# Standard signature: (tool_result, file_path) -> Optional[ToolOutput]
# Extended signature: (tool_result, file_path, tool_use_result) -> Optional[ToolOutput]
ToolOutputParser = Callable[..., 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.
# Some parsers (like WebSearch, WebFetch) also accept optional tool_use_result for structured data.
TOOL_OUTPUT_PARSERS: dict[str, ToolOutputParser] = {
"Read": parse_read_output,
"Edit": parse_edit_output,
Expand All @@ -573,10 +627,11 @@ def parse_websearch_output(
"AskUserQuestion": parse_askuserquestion_output,
"ExitPlanMode": parse_exitplanmode_output,
"WebSearch": parse_websearch_output,
"WebFetch": parse_webfetch_output,
}

# Parsers that accept the extended signature with tool_use_result
PARSERS_WITH_TOOL_USE_RESULT: set[str] = {"WebSearch"}
PARSERS_WITH_TOOL_USE_RESULT: set[str] = {"WebSearch", "WebFetch"}


def create_tool_output(
Expand All @@ -591,11 +646,14 @@ def create_tool_output(
using the TOOL_OUTPUT_PARSERS registry. Each parser receives the full
ToolResultContent and can use _extract_tool_result_text() if it needs text.

For tools in PARSERS_WITH_TOOL_USE_RESULT, the structured toolUseResult
from the transcript entry is also passed to the parser.

Args:
tool_name: The name of the tool (e.g., "Bash", "Read")
tool_result: The raw tool result content
file_path: Optional file path for file-based tools (Read, Edit, Write)
tool_use_result: Optional structured toolUseResult from entry (for WebSearch, etc.)
tool_use_result: Optional structured toolUseResult from entry (for WebSearch, WebFetch)

Returns:
A typed output model if parsing succeeds, ToolResultContent as fallback.
Expand Down Expand Up @@ -682,7 +740,7 @@ def create_tool_result_message(
meta: Message metadata
tool_result: The tool result content item
tool_use_context: Dict with tool_use_id -> ToolUseContent mapping
tool_use_result: Optional structured toolUseResult from the entry
tool_use_result: Optional structured toolUseResult from transcript entry

Returns:
ToolItemResult with tool_result content model
Expand Down
4 changes: 4 additions & 0 deletions claude_code_log/html/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
format_read_input,
format_task_input,
format_todowrite_input,
format_webfetch_input,
format_write_input,
# Tool output formatters (called by HtmlRenderer.format_{OutputClass})
format_askuserquestion_output,
Expand All @@ -33,6 +34,7 @@
format_exitplanmode_output,
format_read_output,
format_task_output,
format_webfetch_output,
format_write_output,
# Fallback formatter
format_tool_result_content_raw,
Expand Down Expand Up @@ -108,6 +110,7 @@
"format_read_input",
"format_task_input",
"format_todowrite_input",
"format_webfetch_input",
"format_write_input",
# tool_formatters (output) - called by HtmlRenderer.format_{OutputClass}
"format_askuserquestion_output",
Expand All @@ -116,6 +119,7 @@
"format_exitplanmode_output",
"format_read_output",
"format_task_output",
"format_webfetch_output",
"format_write_output",
# Fallback formatter
"format_tool_result_content_raw",
Expand Down
18 changes: 18 additions & 0 deletions claude_code_log/html/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
TodoWriteInput,
ToolUseContent,
WebSearchInput,
WebFetchInput,
WriteInput,
# Tool output types
AskUserQuestionOutput,
Expand All @@ -46,6 +47,7 @@
TaskOutput,
ToolResultContent,
WebSearchOutput,
WebFetchOutput,
WriteOutput,
)
from ..renderer import (
Expand Down Expand Up @@ -100,6 +102,8 @@
format_tool_result_content_raw,
format_websearch_input,
format_websearch_output,
format_webfetch_input,
format_webfetch_output,
format_write_input,
format_write_output,
render_params_table,
Expand Down Expand Up @@ -362,6 +366,14 @@ def format_ToolResultContent(
"""Format → <pre>raw content</pre> (fallback for unknown tools)."""
return format_tool_result_content_raw(output)

def format_WebFetchInput(self, input: WebFetchInput, _: TemplateMessage) -> str:
"""Format → prompt text if long, empty if shown in title."""
return format_webfetch_input(input)

def format_WebFetchOutput(self, output: WebFetchOutput, _: TemplateMessage) -> str:
"""Format → collapsible markdown with metadata badge."""
return format_webfetch_output(output)

# -------------------------------------------------------------------------
# Tool Input Title Methods (for Renderer.title_ToolUseMessage dispatch)
# -------------------------------------------------------------------------
Expand Down Expand Up @@ -443,6 +455,12 @@ def title_WebSearchInput(
"""Title → '🔎 WebSearch <query>'."""
return self._tool_title(message, "🔎", input.query)

def title_WebFetchInput(
self, input: WebFetchInput, message: TemplateMessage
) -> str:
"""Title → '🌐 WebFetch <url>'."""
return self._tool_title(message, "🌐", input.url)

def _flatten_preorder(
self, roots: list[TemplateMessage]
) -> list[Tuple[TemplateMessage, str, str, str]]:
Expand Down
45 changes: 45 additions & 0 deletions claude_code_log/html/templates/components/message_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,51 @@ details summary {
margin: 10px 0;
}

/* WebFetch tool styling */
.webfetch-prompt {
color: var(--text-secondary);
font-style: italic;
margin-bottom: 8px;
}

.webfetch-meta {
display: flex;
gap: 8px;
margin-bottom: 8px;
font-size: 0.85em;
}

.webfetch-status {
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}

.webfetch-status-success {
background-color: #d4edda;
color: #155724;
}

.webfetch-status-error {
background-color: #f8d7da;
color: #721c24;
}

.webfetch-size,
.webfetch-duration {
color: var(--text-muted);
}

/* Override negative margin for WebFetch collapsible - needs space for meta badges */
.webfetch-result.collapsible-code {
margin-top: 0;
}

/* Override centered h1 from global styles for WebFetch content */
.webfetch-result h1 {
text-align: left;
}

@media (max-width: 1280px) {
.fold-bar {
height: auto;
Expand Down
68 changes: 68 additions & 0 deletions claude_code_log/html/tool_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
ToolResultContent,
WebSearchInput,
WebSearchOutput,
WebFetchInput,
WebFetchOutput,
WriteInput,
WriteOutput,
)
Expand Down Expand Up @@ -579,6 +581,70 @@ def format_task_input(task_input: TaskInput) -> str:
return render_markdown_collapsible(task_input.prompt, "task-prompt")


# -- WebFetch Tool ------------------------------------------------------------


def format_webfetch_input(webfetch_input: WebFetchInput) -> str:
"""Format WebFetch tool use content.

Args:
webfetch_input: Typed WebFetchInput with url and prompt.

The URL is shown in the title, so we only show the prompt here if it's
substantial enough to warrant display.
"""
# If prompt is short, it can fit in the title - return empty
if len(webfetch_input.prompt) <= 100:
return ""

# Show the prompt for longer queries
escaped_prompt = escape_html(webfetch_input.prompt)
return f'<div class="webfetch-prompt">{escaped_prompt}</div>'


def format_webfetch_output(output: WebFetchOutput) -> str:
"""Format WebFetch tool result as collapsible markdown.

Args:
output: Parsed WebFetchOutput with result and metadata

Returns:
HTML string with markdown rendered in collapsible section,
plus metadata badge showing HTTP status and timing.
"""
# Build metadata badge
badge_parts: list[str] = []
if output.code is not None:
status_class = "success" if output.code == 200 else "error"
badge_parts.append(
f'<span class="webfetch-status webfetch-status-{status_class}">{output.code}</span>'
)
if output.bytes is not None:
# Format bytes nicely
if output.bytes >= 1024 * 1024:
size_str = f"{output.bytes / (1024 * 1024):.1f} MB"
elif output.bytes >= 1024:
size_str = f"{output.bytes / 1024:.1f} KB"
else:
size_str = f"{output.bytes} bytes"
badge_parts.append(f'<span class="webfetch-size">{size_str}</span>')
if output.duration_ms is not None:
if output.duration_ms >= 1000:
time_str = f"{output.duration_ms / 1000:.1f}s"
else:
time_str = f"{output.duration_ms}ms"
badge_parts.append(f'<span class="webfetch-duration">{time_str}</span>')

badge_html = ""
if badge_parts:
badge_html = f'<div class="webfetch-meta">{" ".join(badge_parts)}</div>'

# Render the result as markdown in a collapsible section
content_html = render_markdown_collapsible(output.result, "webfetch-result")

return f"{badge_html}{content_html}"


# -- Generic Parameter Table --------------------------------------------------


Expand Down Expand Up @@ -768,6 +834,7 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str:
"format_bash_input",
"format_task_input",
"format_websearch_input",
"format_webfetch_input",
# Tool output formatters (called by HtmlRenderer.format_{OutputClass})
"format_read_output",
"format_write_output",
Expand All @@ -777,6 +844,7 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str:
"format_askuserquestion_output",
"format_exitplanmode_output",
"format_websearch_output",
"format_webfetch_output",
# Fallback for ToolResultContent
"format_tool_result_content_raw",
# Legacy formatters (still used)
Expand Down
Loading
Loading