From fb9350a233de865aca8eefc9ab4a201d360b012c Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 2 Dec 2025 04:28:12 +0100 Subject: [PATCH 001/102] Add message type documentation and sample extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dev-docs/messages.md: Comprehensive documentation of all message types found in Claude Code JSONL transcripts, with hierarchy and rendering notes - dev-docs/messages/: Abbreviated JSON samples for each message type - dev-docs/messages/tools/: Samples for each tool type (Bash, Read, Task, etc.) - dev-docs/TEMPLATE_MESSAGE_CHILDREN.md: Architecture notes for planned children-based TemplateMessage refactoring - scripts/extract_message_samples.py: Tool to extract and abbreviate sample messages from real session data 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- dev-docs/TEMPLATE_MESSAGE_CHILDREN.md | 106 ++++++++ dev-docs/messages.md | 182 +++++++++++++ dev-docs/messages/assistant_text.json | 20 ++ dev-docs/messages/assistant_thinking.json | 20 ++ dev-docs/messages/assistant_tool_use.json | 27 ++ dev-docs/messages/file_history_snapshot.json | 3 + dev-docs/messages/queue_operation.json | 5 + dev-docs/messages/summary.json | 3 + dev-docs/messages/system.json | 8 + dev-docs/messages/tools/askuserquestion.json | 44 +++ dev-docs/messages/tools/bash.json | 25 ++ dev-docs/messages/tools/bashoutput.json | 24 ++ dev-docs/messages/tools/edit.json | 26 ++ dev-docs/messages/tools/exit_plan_mode.json | 24 ++ dev-docs/messages/tools/exitplanmode.json | 24 ++ dev-docs/messages/tools/glob.json | 25 ++ dev-docs/messages/tools/grep.json | 27 ++ dev-docs/messages/tools/killshell.json | 24 ++ dev-docs/messages/tools/ls.json | 24 ++ dev-docs/messages/tools/multiedit.json | 38 +++ dev-docs/messages/tools/read.json | 24 ++ dev-docs/messages/tools/task.json | 26 ++ dev-docs/messages/tools/todowrite.json | 65 +++++ dev-docs/messages/tools/webfetch.json | 25 ++ dev-docs/messages/tools/websearch.json | 24 ++ dev-docs/messages/tools/write.json | 25 ++ dev-docs/messages/user_image.json | 25 ++ dev-docs/messages/user_text.json | 21 ++ dev-docs/messages/user_tool_result.json | 20 ++ scripts/extract_message_samples.py | 270 +++++++++++++++++++ 30 files changed, 1204 insertions(+) create mode 100644 dev-docs/TEMPLATE_MESSAGE_CHILDREN.md create mode 100644 dev-docs/messages.md create mode 100644 dev-docs/messages/assistant_text.json create mode 100644 dev-docs/messages/assistant_thinking.json create mode 100644 dev-docs/messages/assistant_tool_use.json create mode 100644 dev-docs/messages/file_history_snapshot.json create mode 100644 dev-docs/messages/queue_operation.json create mode 100644 dev-docs/messages/summary.json create mode 100644 dev-docs/messages/system.json create mode 100644 dev-docs/messages/tools/askuserquestion.json create mode 100644 dev-docs/messages/tools/bash.json create mode 100644 dev-docs/messages/tools/bashoutput.json create mode 100644 dev-docs/messages/tools/edit.json create mode 100644 dev-docs/messages/tools/exit_plan_mode.json create mode 100644 dev-docs/messages/tools/exitplanmode.json create mode 100644 dev-docs/messages/tools/glob.json create mode 100644 dev-docs/messages/tools/grep.json create mode 100644 dev-docs/messages/tools/killshell.json create mode 100644 dev-docs/messages/tools/ls.json create mode 100644 dev-docs/messages/tools/multiedit.json create mode 100644 dev-docs/messages/tools/read.json create mode 100644 dev-docs/messages/tools/task.json create mode 100644 dev-docs/messages/tools/todowrite.json create mode 100644 dev-docs/messages/tools/webfetch.json create mode 100644 dev-docs/messages/tools/websearch.json create mode 100644 dev-docs/messages/tools/write.json create mode 100644 dev-docs/messages/user_image.json create mode 100644 dev-docs/messages/user_text.json create mode 100644 dev-docs/messages/user_tool_result.json create mode 100644 scripts/extract_message_samples.py diff --git a/dev-docs/TEMPLATE_MESSAGE_CHILDREN.md b/dev-docs/TEMPLATE_MESSAGE_CHILDREN.md new file mode 100644 index 00000000..aac6d336 --- /dev/null +++ b/dev-docs/TEMPLATE_MESSAGE_CHILDREN.md @@ -0,0 +1,106 @@ +# Template Message Children Architecture + +This document tracks the exploration of a children-based architecture for `TemplateMessage`, where messages can have nested children to form an explicit tree structure. + +## Current Architecture + +### TemplateMessage (current) +- Flat list of messages with `message_id` and `ancestry` fields +- Ancestry is a list of parent message IDs (from root to immediate parent) +- Hierarchy is determined by levels based on message type/css_class +- Multiple reordering passes: session → pairs → sidechains → build_hierarchy + +### Hierarchy Levels (current) +``` +Level 0: Session headers +Level 1: User messages +Level 2: Assistant, System, Thinking +Level 3: Tool use/result +Level 4: Sidechain assistant/thinking +Level 5: Sidechain tools +``` + +### Template Rendering (current) +- Single `{% for message in messages %}` loop +- Ancestry rendered as CSS classes for JavaScript DOM queries +- Fold/unfold uses `document.querySelectorAll('.message.${targetId}')` + +## Proposed Architecture + +### TemplateMessage (proposed) +Add `children: List[TemplateMessage]` field to make hierarchy explicit. + +```python +class TemplateMessage: + # ... existing fields ... + children: List["TemplateMessage"] = [] +``` + +### Tree Building +Replace flat list processing with tree construction: +1. Session headers become root nodes +2. User messages are children of sessions +3. Assistant/System are children of users +4. Tools are children of assistants +5. Sidechains are children of Task tool_results + +### Template Rendering (proposed) +Recursive macro approach: +```jinja2 +{% macro render_message(message, depth=0) %} +
+
{{ message.content_html | safe }}
+ {% if message.children %} +
+ {% for child in message.children %} + {{ render_message(child, depth + 1) }} + {% endfor %} +
+ {% endif %} +
+{% endmacro %} +``` + +### JavaScript Simplification +With nested DOM structure, fold/unfold becomes trivial: +```javascript +// Hide all children +messageEl.querySelector('.children').style.display = 'none'; +// Show children +messageEl.querySelector('.children').style.display = ''; +``` + +## Exploration Log + +### Phase 1: Foundation (TODO) +- [ ] Add `children` field to TemplateMessage +- [ ] Keep existing flat-list behavior working +- [ ] Add `flatten()` method for backward compatibility + +### Phase 2: Tree Building (TODO) +- [ ] Create `_build_message_tree()` function +- [ ] Return root messages instead of flat list +- [ ] Update child counting to work recursively + +### Phase 3: Template Migration (TODO) +- [ ] Create recursive render macro +- [ ] Update DOM structure to use nested `.children` divs +- [ ] Migrate JavaScript fold/unfold + +### Challenges & Notes + +*To be filled as exploration progresses...* + +## Related Work + +### golergka's text-output-format PR +Created `content_extractor.py` for shared content parsing: +- Separates data extraction from presentation +- Dataclasses for extracted content: `ExtractedText`, `ExtractedToolUse`, etc. +- Could be extended for the tree-building approach + +### Visitor Pattern Consideration +For multi-format output (HTML, Markdown, JSON), consider: +- TemplateMessage as a tree data structure (no rendering logic) +- Visitor implementations for each output format +- Preparation in converter.py before any rendering diff --git a/dev-docs/messages.md b/dev-docs/messages.md new file mode 100644 index 00000000..71161a9c --- /dev/null +++ b/dev-docs/messages.md @@ -0,0 +1,182 @@ +# Message Types in Claude Code Transcripts + +This document describes all message types found in Claude Code JSONL transcript files. + +## JSONL Entry Types (Top Level) + +Each line in a `.jsonl` file is a JSON object with a `type` field: + +``` +Session +├── user # User input or tool results +│ ├── text content # User typed message +│ ├── tool_result # Result from tool execution +│ └── image # User attached image +│ +├── assistant # Claude's response +│ ├── text content # Assistant's text response +│ ├── thinking content # Extended thinking (when enabled) +│ └── tool_use content # Tool invocation +│ ├── Read, Edit, Write, Glob, Grep +│ ├── Bash, BashOutput, KillShell +│ ├── Task (spawns sidechain) +│ ├── TodoWrite, AskUserQuestion +│ ├── WebFetch, WebSearch +│ └── ExitPlanMode, etc. +│ +├── system # System messages (init command, notifications) +│ +├── summary # Session summary (generated after session ends) +│ +├── queue-operation # Steering messages (interrupt/continue) +│ +└── file-history-snapshot # File state snapshots +``` + +## Message Hierarchy (Rendering) + +When rendering, messages are organized hierarchically: + +``` +Level 0: Session header +└── Level 1: User message + ├── Level 2: System message (info/warning) + └── Level 2: Assistant response + └── Level 3: Tool use/result (paired) + └── Level 4: Sidechain assistant (from Task) + └── Level 5: Sidechain tools +``` + +## Detailed Type Descriptions + +### `user` Type + +User messages contain the human input or tool execution results. + +**Common Fields:** +- `type`: "user" +- `sessionId`: Session UUID +- `timestamp`: ISO 8601 timestamp +- `uuid`: Message UUID +- `parentUuid`: UUID of parent message (or null) +- `isSidechain`: Whether this is in a sub-agent context +- `message.role`: "user" +- `message.content`: Array of content items + +**Content Types:** +- `text`: User typed message, IDE selection, system reminders +- `tool_result`: Execution result of a tool_use +- `image`: User attached screenshot/image + +**See:** [messages/user_text.json](messages/user_text.json), [messages/user_tool_result.json](messages/user_tool_result.json) + +### `assistant` Type + +Assistant messages contain Claude's responses. + +**Common Fields:** +- `type`: "assistant" +- `sessionId`: Session UUID +- `timestamp`: ISO 8601 timestamp +- `uuid`: Message UUID +- `parentUuid`: UUID of parent message +- `message.role`: "assistant" +- `message.model`: Model identifier (e.g., "claude-opus-4-5-20251101") +- `message.content`: Array of content items + +**Content Types:** +- `text`: Claude's text response +- `thinking`: Extended thinking content (when enabled) +- `tool_use`: Tool invocation with name, id, and input + +**See:** [messages/assistant_text.json](messages/assistant_text.json), [messages/assistant_thinking.json](messages/assistant_thinking.json) + +### `system` Type + +System messages for commands and notifications. + +**Variants:** +- Init command: Shows CLI initialization +- IDE notifications: VS Code integration messages +- Warnings/errors: System-level issues + +**See:** [messages/system.json](messages/system.json) + +### `summary` Type + +Session summaries generated after a session ends. + +**Fields:** +- `type`: "summary" +- `summary`: The generated summary text +- `leafUuid`: UUID of the last message (used to link summary to session) + +**See:** [messages/summary.json](messages/summary.json) + +### `queue-operation` Type + +Steering messages for interrupts and user intervention. + +**Common Operations:** +- User interrupts assistant's response +- User provides steering input mid-response + +**See:** [messages/queue_operation.json](messages/queue_operation.json) + +### `file-history-snapshot` Type + +Snapshots of file state for undo/redo functionality. + +**See:** [messages/file_history_snapshot.json](messages/file_history_snapshot.json) + +## Tool Types + +Tools are invoked via `tool_use` content items in assistant messages, with results appearing as `tool_result` in subsequent user messages. + +### File Operations +- **Read**: Read file contents +- **Edit**: Edit file with old_string/new_string replacement +- **Write**: Write entire file +- **MultiEdit**: Multiple edits in one operation +- **Glob**: Find files by pattern +- **Grep**: Search file contents + +### Shell Operations +- **Bash**: Execute shell command +- **BashOutput**: Get output from background shell +- **KillShell**: Terminate background shell + +### Agent/Task Operations +- **Task**: Spawn sub-agent (creates sidechain) +- **TodoWrite**: Update task list +- **AskUserQuestion**: Prompt user for input +- **ExitPlanMode**: Complete planning phase + +### Web Operations +- **WebFetch**: Fetch URL content +- **WebSearch**: Search the web + +**See:** [messages/tools/](messages/tools/) for samples of each tool type. + +## Sidechains (Sub-agents) + +When Claude uses the `Task` tool, a sub-agent is spawned. Messages from this sub-agent: +- Have `isSidechain: true` +- Have an `agentId` field linking them to the Task +- Appear in the transcript interleaved with main messages +- Are reordered during rendering to appear after their Task result + +## Key Relationships + +1. **Parent/Child**: `parentUuid` links messages in conversation order +2. **Tool Pairing**: `tool_use.id` matches `tool_result.tool_use_id` +3. **Sidechain Linking**: `agentId` links sidechain messages to Task results +4. **Summary Linking**: `summary.leafUuid` links to the last message's `uuid` + +## Rendering Considerations + +- Messages with same `uuid` but different `sessionId` are duplicates (from session resume) +- Multiple assistant messages may share the same `requestId` (streaming responses) +- Tool pairs should be visually grouped and foldable together +- Sidechains should be nested under their Task result +- Extended thinking should be collapsible diff --git a/dev-docs/messages/assistant_text.json b/dev-docs/messages/assistant_text.json new file mode 100644 index 00000000..c720e160 --- /dev/null +++ b/dev-docs/messages/assistant_text.json @@ -0,0 +1,20 @@ +{ + "type": "assistant", + "sessionId": "15eaa3ee-d16e-4fdc-b5b5-ed52c42219bb", + "timestamp": "2025-11-30T14:07:53.639Z", + "uuid": "1d239a51-3213-452d-9f4f-93c907163264", + "parentUuid": null, + "isSidechain": true, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_018y2W5pHiahDbfz2wDMukhJ", + "content": [ + { + "type": "text", + "text": "I'll help you find and extract inline CSS styles from Python and HTML files. Let me start with Phase 1: Exploration." + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/assistant_thinking.json b/dev-docs/messages/assistant_thinking.json new file mode 100644 index 00000000..8eb662ef --- /dev/null +++ b/dev-docs/messages/assistant_thinking.json @@ -0,0 +1,20 @@ +{ + "type": "assistant", + "sessionId": "f852ad25-1024-47da-964e-5eaae5bd6e6a", + "timestamp": "2025-09-29T18:01:57.835Z", + "uuid": "96acdb48-646c-415f-9528-722902e9fb6e", + "parentUuid": "7002bd4a-4559-454c-bca3-b40729ce9246", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-opus-4-1-20250805", + "id": "msg_01CkR2ph1853oo3iZdeTXBvJ", + "content": [ + { + "type": "thinking", + "thinking": "The user is asking me to:\n1. Read three files related to a tokenizer application\n2. Do a thorough code review\n... [truncated]" + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/assistant_tool_use.json b/dev-docs/messages/assistant_tool_use.json new file mode 100644 index 00000000..5c0c6ee1 --- /dev/null +++ b/dev-docs/messages/assistant_tool_use.json @@ -0,0 +1,27 @@ +{ + "type": "assistant", + "sessionId": "15eaa3ee-d16e-4fdc-b5b5-ed52c42219bb", + "timestamp": "2025-11-30T14:07:55.195Z", + "uuid": "efe8f2d5-05e0-4a97-957a-5bed72e5f3ee", + "parentUuid": "1d239a51-3213-452d-9f4f-93c907163264", + "isSidechain": true, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_018y2W5pHiahDbfz2wDMukhJ", + "content": [ + { + "type": "tool_use", + "id": "toolu_01YWvwrXKVh2XxASYZscFZX5", + "name": "Grep", + "input": { + "pattern": "style=", + "glob": "*.html", + "path": "/home/cboos/Workspace/github/claude-code-log/claude_code_log/templates", + "_note": "... +2 more fields" + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/file_history_snapshot.json b/dev-docs/messages/file_history_snapshot.json new file mode 100644 index 00000000..7974be8a --- /dev/null +++ b/dev-docs/messages/file_history_snapshot.json @@ -0,0 +1,3 @@ +{ + "type": "file-history-snapshot" +} \ No newline at end of file diff --git a/dev-docs/messages/queue_operation.json b/dev-docs/messages/queue_operation.json new file mode 100644 index 00000000..bd6210da --- /dev/null +++ b/dev-docs/messages/queue_operation.json @@ -0,0 +1,5 @@ +{ + "type": "queue-operation", + "sessionId": "15eaa3ee-d16e-4fdc-b5b5-ed52c42219bb", + "timestamp": "2025-11-30T13:20:21.949Z" +} \ No newline at end of file diff --git a/dev-docs/messages/summary.json b/dev-docs/messages/summary.json new file mode 100644 index 00000000..e3df3f1b --- /dev/null +++ b/dev-docs/messages/summary.json @@ -0,0 +1,3 @@ +{ + "type": "summary" +} \ No newline at end of file diff --git a/dev-docs/messages/system.json b/dev-docs/messages/system.json new file mode 100644 index 00000000..795609e3 --- /dev/null +++ b/dev-docs/messages/system.json @@ -0,0 +1,8 @@ +{ + "type": "system", + "sessionId": "15eaa3ee-d16e-4fdc-b5b5-ed52c42219bb", + "timestamp": "2025-11-30T13:27:55.008Z", + "uuid": "07af8597-7a9b-4b17-b530-4af62df21d6b", + "parentUuid": null, + "isSidechain": false +} \ No newline at end of file diff --git a/dev-docs/messages/tools/askuserquestion.json b/dev-docs/messages/tools/askuserquestion.json new file mode 100644 index 00000000..7e36b802 --- /dev/null +++ b/dev-docs/messages/tools/askuserquestion.json @@ -0,0 +1,44 @@ +{ + "type": "assistant", + "sessionId": "f33a07ec-358b-4db4-833e-4a1c797ae6f2", + "timestamp": "2025-11-30T17:42:01.324Z", + "uuid": "2b02f618-ac68-429a-aa5d-dc6feecd36ee", + "parentUuid": "3e7dc0d9-a1ec-49f1-924d-7dae169d8a02", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-opus-4-5-20251101", + "id": "msg_01Q1vxX9DJU4Vzhrz2K2sJ7p", + "content": [ + { + "type": "tool_use", + "id": "toolu_01Cm6WC2hqbgRYC7PrztGyPh", + "name": "AskUserQuestion", + "input": { + "questions": [ + { + "question": "What would you like me to work on first?", + "header": "Next step", + "options": [ + { + "label": "Reorganize test data", + "description": "Move deep-manifest-tar into real_projects/ with appropriate naming, update test_performance.py" + }, + { + "label": "Performance benchmarking", + "description": "Add timing capture for all real projects with GitHub Job Summary output" + }, + { + "label": "Both in sequence", + "description": "Start with reorganization, then add benchmarking" + } + ], + "multiSelect": false + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/bash.json b/dev-docs/messages/tools/bash.json new file mode 100644 index 00000000..c2417914 --- /dev/null +++ b/dev-docs/messages/tools/bash.json @@ -0,0 +1,25 @@ +{ + "type": "assistant", + "sessionId": "15eaa3ee-d16e-4fdc-b5b5-ed52c42219bb", + "timestamp": "2025-11-30T14:09:45.724Z", + "uuid": "de01de02-163c-42b9-b96c-c68aef52312a", + "parentUuid": "ab3cf99a-ef5e-488d-a60e-a1763aa442a0", + "isSidechain": true, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_01CBMJuXYiksQ6x8TMs1VcEX", + "content": [ + { + "type": "tool_use", + "id": "toolu_01VXNwKT7dwgEyCss6KBkViV", + "name": "Bash", + "input": { + "command": "git diff", + "description": "Show all changes made to extract inline CSS" + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/bashoutput.json b/dev-docs/messages/tools/bashoutput.json new file mode 100644 index 00000000..b48acc4c --- /dev/null +++ b/dev-docs/messages/tools/bashoutput.json @@ -0,0 +1,24 @@ +{ + "type": "assistant", + "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e", + "timestamp": "2025-11-18T00:03:27.174Z", + "uuid": "be2b84d8-d747-48d3-adf0-143393870c88", + "parentUuid": "4682610f-dd5e-4c44-909b-22f1d86ff164", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_01EPx394bFyVKqqNVDKWqnct", + "content": [ + { + "type": "tool_use", + "id": "toolu_01GvxiBWatZMFVNvxyDms7Ey", + "name": "BashOutput", + "input": { + "bash_id": "dce0af" + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/edit.json b/dev-docs/messages/tools/edit.json new file mode 100644 index 00000000..a5db961b --- /dev/null +++ b/dev-docs/messages/tools/edit.json @@ -0,0 +1,26 @@ +{ + "type": "assistant", + "sessionId": "15eaa3ee-d16e-4fdc-b5b5-ed52c42219bb", + "timestamp": "2025-11-30T14:08:41.094Z", + "uuid": "d86f1ef4-c4fa-4a4f-adcf-b771b3e44692", + "parentUuid": "e1214aea-1ad8-4238-9181-655c67095088", + "isSidechain": true, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_019JuKZV46VCmHeDNsMpXqGv", + "content": [ + { + "type": "tool_use", + "id": "toolu_01A6YfhE9YXBHhGNthwwdsUx", + "name": "Edit", + "input": { + "file_path": "/home/cboos/Workspace/github/claude-code-log/claude_code_log/templates/transcript.html", + "old_string": " {% if message.session_subtitle %}\n
{{\n... [truncated]" + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/exit_plan_mode.json b/dev-docs/messages/tools/exit_plan_mode.json new file mode 100644 index 00000000..131492a2 --- /dev/null +++ b/dev-docs/messages/tools/exit_plan_mode.json @@ -0,0 +1,24 @@ +{ + "type": "assistant", + "sessionId": "07047a7d-ecbf-4e09-9f96-43949ae2e4f4", + "timestamp": "2025-06-27T00:13:52.054Z", + "uuid": "1dea3b4c-9292-424b-8f8e-1835078d6185", + "parentUuid": "6d61f53f-ad87-4ed5-a047-dc52251df302", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-20250514", + "id": "msg_01SY3bH7Ty4f7xye4u9jrnUD", + "content": [ + { + "type": "tool_use", + "id": "toolu_01XUruhhzr6TGcoFy832ESHU", + "name": "exit_plan_mode", + "input": { + "plan": "## Clean Up Message Filtering Logic\n\n... [truncated]" + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/exitplanmode.json b/dev-docs/messages/tools/exitplanmode.json new file mode 100644 index 00000000..04e306b6 --- /dev/null +++ b/dev-docs/messages/tools/exitplanmode.json @@ -0,0 +1,24 @@ +{ + "type": "assistant", + "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", + "timestamp": "2025-09-29T17:08:36.338Z", + "uuid": "67b1db15-73a4-4de3-8a6e-3c27eff6f5bb", + "parentUuid": "06afbb5c-a17a-4ca7-9603-12515ad803ee", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-opus-4-1-20250805", + "id": "msg_01MiaNQB5aEjJMhwxAo4ZawH", + "content": [ + { + "type": "tool_use", + "id": "toolu_0173799ePMBxKdX8hsuevgm7", + "name": "ExitPlanMode", + "input": { + "plan": "## Plan to Fix Ruby Element Support for Chrome\n\n... [truncated]" + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/glob.json b/dev-docs/messages/tools/glob.json new file mode 100644 index 00000000..64647801 --- /dev/null +++ b/dev-docs/messages/tools/glob.json @@ -0,0 +1,25 @@ +{ + "type": "assistant", + "sessionId": "15eaa3ee-d16e-4fdc-b5b5-ed52c42219bb", + "timestamp": "2025-11-30T14:08:10.833Z", + "uuid": "6df59d53-1764-4efe-aa54-d590d0f69389", + "parentUuid": "5ef32d83-6c04-4259-880e-7ce7429e50a3", + "isSidechain": true, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_011meSXQXsnpJdBeoS3CkUeG", + "content": [ + { + "type": "tool_use", + "id": "toolu_01QJDXkUGHEbwYvVBQmjAWLD", + "name": "Glob", + "input": { + "pattern": "**/*.css", + "path": "/home/cboos/Workspace/github/claude-code-log/claude_code_log/templates/components" + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/grep.json b/dev-docs/messages/tools/grep.json new file mode 100644 index 00000000..5c0c6ee1 --- /dev/null +++ b/dev-docs/messages/tools/grep.json @@ -0,0 +1,27 @@ +{ + "type": "assistant", + "sessionId": "15eaa3ee-d16e-4fdc-b5b5-ed52c42219bb", + "timestamp": "2025-11-30T14:07:55.195Z", + "uuid": "efe8f2d5-05e0-4a97-957a-5bed72e5f3ee", + "parentUuid": "1d239a51-3213-452d-9f4f-93c907163264", + "isSidechain": true, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_018y2W5pHiahDbfz2wDMukhJ", + "content": [ + { + "type": "tool_use", + "id": "toolu_01YWvwrXKVh2XxASYZscFZX5", + "name": "Grep", + "input": { + "pattern": "style=", + "glob": "*.html", + "path": "/home/cboos/Workspace/github/claude-code-log/claude_code_log/templates", + "_note": "... +2 more fields" + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/killshell.json b/dev-docs/messages/tools/killshell.json new file mode 100644 index 00000000..34e4dcae --- /dev/null +++ b/dev-docs/messages/tools/killshell.json @@ -0,0 +1,24 @@ +{ + "type": "assistant", + "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e", + "timestamp": "2025-11-18T00:03:32.341Z", + "uuid": "054c1d19-9bee-4151-95e1-63ec99cf013a", + "parentUuid": "46911bf4-c7c4-4bac-ab78-0a6b3e2ff028", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_01P5Rd3DEkoNowEpc41U8kzs", + "content": [ + { + "type": "tool_use", + "id": "toolu_01Cv6rrwQjDynhg6WkqYWhAn", + "name": "KillShell", + "input": { + "shell_id": "dce0af" + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/ls.json b/dev-docs/messages/tools/ls.json new file mode 100644 index 00000000..c3d285f1 --- /dev/null +++ b/dev-docs/messages/tools/ls.json @@ -0,0 +1,24 @@ +{ + "type": "assistant", + "sessionId": "858d9e0c-1f3f-4b19-ac5c-b0573d8f5ec3", + "timestamp": "2025-06-23T23:47:52.983Z", + "uuid": "0a7cf970-4266-4b9d-af3d-df49a89cf873", + "parentUuid": "070ed2e2-b131-4e3c-8238-551a533865b2", + "isSidechain": true, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-20250514", + "id": "msg_014LvG2y6axoynWL76riVvZD", + "content": [ + { + "type": "tool_use", + "id": "toolu_012fQhHuTkyHqwemmGoHJKhh", + "name": "LS", + "input": { + "path": "/Users/dain/workspace/claude-code-log/claude_code_log/templates" + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/multiedit.json b/dev-docs/messages/tools/multiedit.json new file mode 100644 index 00000000..8986dc8d --- /dev/null +++ b/dev-docs/messages/tools/multiedit.json @@ -0,0 +1,38 @@ +{ + "type": "assistant", + "sessionId": "f852ad25-1024-47da-964e-5eaae5bd6e6a", + "timestamp": "2025-09-29T18:05:43.613Z", + "uuid": "3d232644-45c5-4f13-9d04-c4754a375799", + "parentUuid": "3c7e16d0-74a6-4562-b763-ecdf98c7cf8a", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-20250514", + "id": "msg_011d8bZffmS6UrvjWsAvYU3f", + "content": [ + { + "type": "tool_use", + "id": "toolu_01Efoe8PuBto6GonPJ8Wh12S", + "name": "MultiEdit", + "input": { + "file_path": "/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js", + "edits": [ + { + "old_string": "// from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.3'\n// TODO: https://www.reddit.com/r/LocalLLaMA/comments/1g9kkbb/transformersjs_v3_is_finally_out_webgpu_support/\nimport { AutoTokenizer } from './transformers.js'\n\nconst KEY_MODELS = 'models'\nconst COLOURS = [\n 'E40303',\n 'FF8C00',\n 'FFED00',\n '008026',\n '061393',\n '732982',\n '5BCEFA',\n 'F5A9B8',\n '8F3F2B',\n 'FFFFFF',\n]", + "new_string": "// Transformers.js v3 - https://huggingface.co/docs/transformers.js/\nimport { AutoTokenizer } from './transformers.js'\n\n// Constants\nconst KEY_MODELS = 'models'\nconst COLOURS = [\n 'E40303',\n 'FF8C00',\n 'FFED00',\n '008026',\n '061393',\n '732982',\n '5BCEFA',\n 'F5A9B8',\n '8F3F2B',\n 'FFFFFF',\n]\nconst DEFAULT_MODELS = [\n 'Xenova/gpt-4',\n 'Xenova/gpt-3',\n 'Xenova/llama-3-tokenizer',\n 'hf-internal-testing/llama-tokenizer',\n 'Xenova/gemma-tokenizer',\n 'microsoft/Phi-3-mini-4k-instruct',\n 'mistral-community/Mixtral-8x22B-v0.1',\n]" + }, + { + "old_string": "// TODO: take model list from URL params?\nfunction loadModels() {\n const storedModels = localStorage.getItem(KEY_MODELS)\n try {\n if (storedModels === null) throw Error('No models found in LocalStorage, using default list.')\n models = JSON.parse(storedModels)\n } catch (error) {\n console.log(error)\n models = [\n 'Xenova/gpt-4',\n 'Xenova/gpt-3',\n 'Xenova/llama-3-tokenizer',\n 'hf-internal-testing/llama-tokenizer',\n 'Xenova/gemma-tokenizer',\n 'microsoft/Phi-3-mini-4k-instruct',\n 'mistral-community/Mixtral-8x22B-v0.1',\n // 'deepseek-ai/deepseek-coder-6.7b-instruct',\n // '01-ai/Yi-34B',\n // 'Xenova/bert-base-cased',\n // 'Xenova/t5-small',\n // 'obvious/error',\n ]\n saveModels()\n }\n}", + "new_string": "/**\n * Load models from URL parameters or localStorage\n */\nfunction loadModels() {\n const urlParams = new URLSearchParams(window.location.search)\n const urlModels = urlParams.get('models')\n\n if (urlModels) {\n models = urlModels.split(',').map(m => m.trim()).filter(m => m.length > 0)\n saveModels()\n return\n }\n\n const storedModels = localStorage.getItem(KEY_MODELS)\n try {\n if (storedModels === null) throw Error('No models found in LocalStorage, using default list.')\n models = JSON.parse(storedModels)\n } catch (error) {\n console.log(error)\n models = [...DEFAULT_MODELS]\n saveModels()\n }\n}" + }, + { + "old_string": "function addModel(name) {\n localStorage.setItem(KEY_MODELS, JSON.stringify([...models, name]))\n}", + "new_string": "/**\n * Validate HuggingFace model name format\n */\nfunction isValidModelName(name) {\n if (!name || typeof name !== 'string') return false\n\n const trimmedName = name.trim()\n if (trimmedName.length === 0) return false\n\n // Basic validation: should contain at least one slash and valid characters\n const validPattern = /^[a-zA-Z0-9._-]+\\/[a-zA-Z0-9._-]+$/\n return validPattern.test(trimmedName)\n}\n\n/**\n * Add a new model to the list\n */\nfunction addModel(name) {\n const trimmedName = name.trim()\n\n if (!isValidModelName(trimmedName)) {\n alert('Please enter a valid HuggingFace model name (e.g., \"Xenova/gpt-4\" or \"microsoft/DialoGPT-medium\")')\n return false\n }\n\n if (models.includes(trimmedName)) {\n alert('This model is already in the list')\n return false\n }\n\n models.push(trimmedName)\n saveModels()\n return true\n}" + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/read.json b/dev-docs/messages/tools/read.json new file mode 100644 index 00000000..d5a4272b --- /dev/null +++ b/dev-docs/messages/tools/read.json @@ -0,0 +1,24 @@ +{ + "type": "assistant", + "sessionId": "15eaa3ee-d16e-4fdc-b5b5-ed52c42219bb", + "timestamp": "2025-11-30T14:08:00.830Z", + "uuid": "587d45c6-7292-48b0-bace-a7a3438a2a1b", + "parentUuid": "ebf49540-2a77-4641-8e8b-704efb1d72ae", + "isSidechain": true, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_01Q3HbXQ3SxkokBErZ6JrN69", + "content": [ + { + "type": "tool_use", + "id": "toolu_01YCETrcbwYFpqFaW1BPPykZ", + "name": "Read", + "input": { + "file_path": "/home/cboos/Workspace/github/claude-code-log/claude_code_log/templates/transcript.html" + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/task.json b/dev-docs/messages/tools/task.json new file mode 100644 index 00000000..6660a186 --- /dev/null +++ b/dev-docs/messages/tools/task.json @@ -0,0 +1,26 @@ +{ + "type": "assistant", + "sessionId": "15eaa3ee-d16e-4fdc-b5b5-ed52c42219bb", + "timestamp": "2025-11-30T14:07:31.381Z", + "uuid": "95b407d9-4ea0-4f4b-b880-89f414c1df7e", + "parentUuid": "ee69aeb8-16d9-4fbb-bad9-5e6ccf53bb8a", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-opus-4-5-20251101", + "id": "msg_014HDMeHC1uZ3inik8W3Dq9a", + "content": [ + { + "type": "tool_use", + "id": "toolu_01UEXaG4BiqMCgfx8GWspGJN", + "name": "Task", + "input": { + "description": "Color restyler coordination", + "prompt": "You are the \"color-restyler\" agent. Your job is to replace hardcoded color values with existing CSS ... [truncated]", + "subagent_type": "general-purpose" + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/todowrite.json b/dev-docs/messages/tools/todowrite.json new file mode 100644 index 00000000..a957c6ac --- /dev/null +++ b/dev-docs/messages/tools/todowrite.json @@ -0,0 +1,65 @@ +{ + "type": "assistant", + "sessionId": "15eaa3ee-d16e-4fdc-b5b5-ed52c42219bb", + "timestamp": "2025-11-30T14:08:05.906Z", + "uuid": "de997a01-f4ac-41e9-b687-a118f4bb96de", + "parentUuid": "4a11243d-9272-4773-9308-0b6cbb91f879", + "isSidechain": true, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_01XePRrw1LUSjZCxKsyk1sYb", + "content": [ + { + "type": "tool_use", + "id": "toolu_01C4uRgcKqVkodRiz9zz8f26", + "name": "TodoWrite", + "input": { + "todos": [ + { + "content": "Replace hardcoded colors in global_styles.css", + "status": "pending", + "activeForm": "Replacing hardcoded colors in global_styles.css" + }, + { + "content": "Replace hardcoded colors in message_styles.css", + "status": "pending", + "activeForm": "Replacing hardcoded colors in message_styles.css" + }, + { + "content": "Replace hardcoded colors in session_nav_styles.css", + "status": "pending", + "activeForm": "Replacing hardcoded colors in session_nav_styles.css" + }, + { + "content": "Replace hardcoded colors in filter_styles.css", + "status": "pending", + "activeForm": "Replacing hardcoded colors in filter_styles.css" + }, + { + "content": "Replace hardcoded colors in search_styles.css", + "status": "pending", + "activeForm": "Replacing hardcoded colors in search_styles.css" + }, + { + "content": "Replace hardcoded colors in timeline_styles.css", + "status": "pending", + "activeForm": "Replacing hardcoded colors in timeline_styles.css" + }, + { + "content": "Review changes with git diff", + "status": "pending", + "activeForm": "Reviewing changes with git diff" + }, + { + "content": "Commit changes to git", + "status": "pending", + "activeForm": "Committing changes to git" + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/webfetch.json b/dev-docs/messages/tools/webfetch.json new file mode 100644 index 00000000..21144476 --- /dev/null +++ b/dev-docs/messages/tools/webfetch.json @@ -0,0 +1,25 @@ +{ + "type": "assistant", + "sessionId": "741790a4-4fe2-4644-9a51-fb4482074060", + "timestamp": "2025-11-13T13:09:37.381Z", + "uuid": "0202e25d-9d68-456e-a764-e085e06aad63", + "parentUuid": "2ddf06a0-6ee1-4695-86a5-2c9e1983f607", + "isSidechain": true, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_01QcaYHrVe7qKdjUKUjMJjSg", + "content": [ + { + "type": "tool_use", + "id": "toolu_01WB97t4LJ8M2hrZpQnQCJxG", + "name": "WebFetch", + "input": { + "url": "https://docs.github.com/en/rest/pulls/comments", + "prompt": "What fields are returned in the response from GET /repos/OWNER/REPO/pulls/PULL_NUMBER/comments? Spec... [truncated]" + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/websearch.json b/dev-docs/messages/tools/websearch.json new file mode 100644 index 00000000..d3945157 --- /dev/null +++ b/dev-docs/messages/tools/websearch.json @@ -0,0 +1,24 @@ +{ + "type": "assistant", + "sessionId": "741790a4-4fe2-4644-9a51-fb4482074060", + "timestamp": "2025-11-13T12:14:44.735Z", + "uuid": "4d6d4310-d5b2-4c4d-b2b7-d70ed9caf921", + "parentUuid": "89c3edde-8686-48c5-871d-3770fc5dc61d", + "isSidechain": true, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_018sPiYDNCm5ytiGsmMeBRDn", + "content": [ + { + "type": "tool_use", + "id": "toolu_01Fa61Wkr6FFgFGSpZ2BSXED", + "name": "WebSearch", + "input": { + "query": "GitHub API pulls comments endpoint response fields path line position 2025" + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/tools/write.json b/dev-docs/messages/tools/write.json new file mode 100644 index 00000000..f2e1dbd4 --- /dev/null +++ b/dev-docs/messages/tools/write.json @@ -0,0 +1,25 @@ +{ + "type": "assistant", + "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", + "timestamp": "2025-10-03T23:59:52.232Z", + "uuid": "3b742928-0e5b-4fa9-9174-89c58b692497", + "parentUuid": "78649cc5-e531-4ea9-b748-2a92d402ae89", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_01NHFT4LdUekGkqFuzi2dxRx", + "content": [ + { + "type": "tool_use", + "id": "toolu_01BM49RbbGYRjhjgHRECVjyo", + "name": "Write", + "input": { + "file_path": "/Users/dain/workspace/online-llm-tokenizer/README.md", + "content": "# Online LLM Tokenizer\n\n... [truncated]" + } + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/user_image.json b/dev-docs/messages/user_image.json new file mode 100644 index 00000000..d22eb756 --- /dev/null +++ b/dev-docs/messages/user_image.json @@ -0,0 +1,25 @@ +{ + "type": "user", + "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", + "timestamp": "2025-10-04T12:32:34.402Z", + "uuid": "924fbd38-7ef9-4907-91fd-ade65d44ff0b", + "parentUuid": "9c9252a8-1c2c-45d4-8065-0acda205cb91", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII [abbreviated]" + } + }, + { + "type": "text", + "text": "Do you think we could set up rewrites for the JS and CSS? This basePath method does the job, but we end up with two failed requests for so it impacts page load times" + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/user_text.json b/dev-docs/messages/user_text.json new file mode 100644 index 00000000..cc08f35c --- /dev/null +++ b/dev-docs/messages/user_text.json @@ -0,0 +1,21 @@ +{ + "type": "user", + "sessionId": "15eaa3ee-d16e-4fdc-b5b5-ed52c42219bb", + "timestamp": "2025-11-30T13:20:21.976Z", + "uuid": "a184ca8d-cc3a-4852-8ab3-6b4feea1ed67", + "parentUuid": "ba1464c5-a8b5-420f-b5ed-08055dce75f4", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "text", + "text": "The user selected the lines 1 to 72 from /home/cboos/Workspace/github/claude-code-log/dev-docs/deep-manifest-tar-REVIEW/SUMMARY.md:\n# Summary of Changes (dev/review-PRs-42-48-49-50)\n\n..... [truncated]" + }, + { + "type": "text", + "text": "I got the following review:\n\nIn test/test_performance.py around lines 36-37, the test sets\n... [truncated]" + } + ] + } +} \ No newline at end of file diff --git a/dev-docs/messages/user_tool_result.json b/dev-docs/messages/user_tool_result.json new file mode 100644 index 00000000..a1ca7e20 --- /dev/null +++ b/dev-docs/messages/user_tool_result.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "15eaa3ee-d16e-4fdc-b5b5-ed52c42219bb", + "timestamp": "2025-11-30T14:07:57.551Z", + "uuid": "c4881a86-d968-4225-9e95-c962ffd10185", + "parentUuid": "dfcc0d60-72b7-4d69-9e80-3b67e036b2fe", + "isSidechain": true, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01YWvwrXKVh2XxASYZscFZX5", + "is_error": false, + "content": "claude_code_log/templates/transcript.html:78:
{{\nclaude_code_log/templates/index.html:31:
str: + """Truncate text to a few lines.""" + if not text: + return text + lines = text.split("\n") + if len(lines) > max_lines: + text = "\n".join(lines[:max_lines]) + "\n... [truncated]" + if len(text) > max_len: + text = text[:max_len] + "... [truncated]" + return text + + +def abbreviate_message(msg: dict[str, Any]) -> dict[str, Any]: + """Abbreviate a message for documentation.""" + result = {} + + # Keep essential fields + for key in ["type", "sessionId", "timestamp", "uuid", "parentUuid", "isSidechain"]: + if key in msg: + result[key] = msg[key] + + # Abbreviate message content + if "message" in msg: + message = msg["message"] + result["message"] = {} + + for key in ["role", "type", "model", "id"]: + if key in message: + result["message"][key] = message[key] + + if "content" in message: + content = message["content"] + if isinstance(content, str): + result["message"]["content"] = truncate_text(content) + elif isinstance(content, list): + result["message"]["content"] = [] + for item in content[:3]: # Max 3 content items + abbrev_item = abbreviate_content_item(item) + result["message"]["content"].append(abbrev_item) + if len(content) > 3: + result["message"]["content"].append( + {"_note": f"... +{len(content) - 3} more items"} + ) + + # Abbreviate tool use result + if "toolUseResult" in msg: + tur = msg["toolUseResult"] + result["toolUseResult"] = {} + if "type" in tur: + result["toolUseResult"]["type"] = tur["type"] + if "stdout" in tur: + result["toolUseResult"]["stdout"] = truncate_text(tur["stdout"]) + if "stderr" in tur: + result["toolUseResult"]["stderr"] = truncate_text(tur["stderr"]) + if "file" in tur: + result["toolUseResult"]["file"] = { + "filePath": tur["file"].get("filePath", ""), + "content": truncate_text(tur["file"].get("content", "")), + } + + return result + + +def abbreviate_content_item(item: dict[str, Any]) -> dict[str, Any]: + """Abbreviate a content item.""" + result = {"type": item.get("type", "unknown")} + + if item.get("type") == "text": + result["text"] = truncate_text(item.get("text", "")) + + elif item.get("type") == "thinking": + result["thinking"] = truncate_text(item.get("thinking", "")) + + elif item.get("type") == "tool_use": + result["id"] = item.get("id", "") + result["name"] = item.get("name", "") + inp = item.get("input", {}) + # Abbreviate input + if isinstance(inp, dict): + result["input"] = {} + for k, v in list(inp.items())[:3]: + if isinstance(v, str): + result["input"][k] = truncate_text(v, max_lines=2, max_len=100) + else: + result["input"][k] = v + if len(inp) > 3: + result["input"]["_note"] = f"... +{len(inp) - 3} more fields" + + elif item.get("type") == "tool_result": + result["tool_use_id"] = item.get("tool_use_id", "") + result["is_error"] = item.get("is_error", False) + content = item.get("content", "") + if isinstance(content, str): + result["content"] = truncate_text(content) + elif isinstance(content, list): + result["content"] = [{"_note": f"{len(content)} items"}] + + elif item.get("type") == "image": + source = item.get("source", {}) + result["source"] = { + "type": source.get("type", "base64"), + "media_type": source.get("media_type", "image/png"), + "data": TINY_BASE64_IMAGE + " [abbreviated]", + } + + return result + + +def find_samples(data_dirs: list[Path]) -> dict[str, list[dict]]: + """Find sample messages of each type.""" + samples: dict[str, list[dict]] = { + "user_text": [], + "user_tool_result": [], + "user_image": [], + "assistant_text": [], + "assistant_tool_use": [], + "assistant_thinking": [], + "system": [], + "summary": [], + "queue_operation": [], + "file_history_snapshot": [], + } + + tool_samples: dict[str, list[dict]] = {} + + for data_dir in data_dirs: + for jsonl_file in data_dir.rglob("*.jsonl"): + try: + with open(jsonl_file) as f: + for line in f: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + + msg_type = msg.get("type", "") + + if msg_type == "user": + content = msg.get("message", {}).get("content", []) + if isinstance(content, str): + if len(samples["user_text"]) < 2: + samples["user_text"].append(msg) + elif isinstance(content, list): + for item in content: + item_type = item.get("type", "") + if ( + item_type == "text" + and len(samples["user_text"]) < 2 + ): + samples["user_text"].append(msg) + elif item_type == "tool_result": + if len(samples["user_tool_result"]) < 2: + samples["user_tool_result"].append(msg) + elif ( + item_type == "image" + and len(samples["user_image"]) < 2 + ): + samples["user_image"].append(msg) + + elif msg_type == "assistant": + content = msg.get("message", {}).get("content", []) + if isinstance(content, list): + has_text = has_thinking = has_tool = False + for item in content: + item_type = item.get("type", "") + if item_type == "text": + has_text = True + elif item_type == "thinking": + has_thinking = True + elif item_type == "tool_use": + has_tool = True + tool_name = item.get("name", "") + if tool_name and tool_name not in tool_samples: + tool_samples[tool_name] = [] + if ( + tool_name + and len(tool_samples[tool_name]) < 1 + ): + tool_samples[tool_name].append(msg) + + if has_text and len(samples["assistant_text"]) < 2: + samples["assistant_text"].append(msg) + if ( + has_thinking + and len(samples["assistant_thinking"]) < 2 + ): + samples["assistant_thinking"].append(msg) + if has_tool and len(samples["assistant_tool_use"]) < 2: + samples["assistant_tool_use"].append(msg) + + elif msg_type == "system": + if len(samples["system"]) < 2: + samples["system"].append(msg) + + elif msg_type == "summary": + if len(samples["summary"]) < 2: + samples["summary"].append(msg) + + elif msg_type == "queue-operation": + if len(samples["queue_operation"]) < 2: + samples["queue_operation"].append(msg) + + elif msg_type == "file-history-snapshot": + if len(samples["file_history_snapshot"]) < 1: + samples["file_history_snapshot"].append(msg) + + except Exception as e: + print(f"Error processing {jsonl_file}: {e}") + + return samples, tool_samples + + +def main(): + test_data = Path(__file__).parent.parent / "test" / "test_data" + data_dirs = [ + test_data / "sessions", + test_data / "real_projects", + ] + + output_dir = Path(__file__).parent.parent / "dev-docs" / "messages" + output_dir.mkdir(parents=True, exist_ok=True) + + samples, tool_samples = find_samples(data_dirs) + + # Write samples + for sample_type, messages in samples.items(): + if not messages: + continue + out_file = output_dir / f"{sample_type}.json" + abbreviated = [abbreviate_message(m) for m in messages[:1]] + with open(out_file, "w") as f: + json.dump( + abbreviated[0] if len(abbreviated) == 1 else abbreviated, f, indent=2 + ) + print(f"Wrote {out_file}") + + # Write tool samples + tools_dir = output_dir / "tools" + tools_dir.mkdir(exist_ok=True) + for tool_name, messages in sorted(tool_samples.items()): + if not messages: + continue + out_file = tools_dir / f"{tool_name.lower()}.json" + abbreviated = abbreviate_message(messages[0]) + with open(out_file, "w") as f: + json.dump(abbreviated, f, indent=2) + print(f"Wrote {out_file}") + + +if __name__ == "__main__": + main() From af7d5483bf3f309315c852e0763c455a5becea73 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 2 Dec 2025 04:34:49 +0100 Subject: [PATCH 002/102] Add flatten() methods to TemplateMessage for tree traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add instance method flatten() and static method flatten_all() to TemplateMessage class for converting tree structures back to flat lists. These methods provide backward compatibility with the flat-list template rendering approach during the children-based architecture migration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 6ea537a0..446a0884 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -1864,6 +1864,8 @@ def __init__( self.is_paired = False self.pair_role: Optional[str] = None # "pair_first", "pair_last", "pair_middle" self.pair_duration: Optional[str] = None # Duration for pair_last messages + # Children for tree-based rendering (future use) + self.children: List["TemplateMessage"] = [] def get_immediate_children_label(self) -> str: """Generate human-readable label for immediate children.""" @@ -1873,6 +1875,30 @@ def get_total_descendants_label(self) -> str: """Generate human-readable label for all descendants.""" return _format_type_counts(self.total_descendants_by_type) + def flatten(self) -> List["TemplateMessage"]: + """Recursively flatten this message and all children into a list. + + Returns a list with this message followed by all descendants in + depth-first order. This provides backward compatibility with the + flat-list template rendering approach. + """ + result: List["TemplateMessage"] = [self] + for child in self.children: + result.extend(child.flatten()) + return result + + @staticmethod + def flatten_all(messages: List["TemplateMessage"]) -> List["TemplateMessage"]: + """Flatten a list of root messages into a single flat list. + + Useful for converting a tree structure back to a flat list for + templates that expect the traditional flat message list. + """ + result: List["TemplateMessage"] = [] + for message in messages: + result.extend(message.flatten()) + return result + class TemplateProject: """Structured project data for template rendering.""" From e0331a0362dec95aabbd04331220211fe25172bf Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 2 Dec 2025 04:38:02 +0100 Subject: [PATCH 003/102] Add _build_message_tree() to populate children fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create tree structure by populating the children field of each TemplateMessage based on the ancestry relationships computed by _build_message_hierarchy(). The function returns root messages (level 0) with all descendants linked via children. This is called after _mark_messages_with_children() but the result is not yet used for template rendering - the flat list continues to be passed to the template for backward compatibility. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 446a0884..7518b5ed 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -3020,6 +3020,53 @@ def _mark_messages_with_children(messages: List[TemplateMessage]) -> None: ) +def _build_message_tree(messages: List[TemplateMessage]) -> List[TemplateMessage]: + """Build tree structure by populating children fields based on ancestry. + + This function takes a flat list of messages (with message_id and ancestry + already set by _build_message_hierarchy) and populates the children field + of each message to form an explicit tree structure. + + The tree structure enables: + - Recursive template rendering with nested DOM elements + - Simpler JavaScript fold/unfold (just hide/show children container) + - More natural parent-child traversal + + Args: + messages: List of template messages with message_id and ancestry set + + Returns: + List of root messages (those with empty ancestry). Each message's + children field is populated with its direct children. + """ + # Build index of messages by ID for O(1) lookup + message_by_id: dict[str, TemplateMessage] = {} + for message in messages: + if message.message_id: + message_by_id[message.message_id] = message + + # Clear any existing children (in case of re-processing) + for message in messages: + message.children = [] + + # Collect root messages (those with no ancestry) + root_messages: List[TemplateMessage] = [] + + # Populate children based on ancestry + for message in messages: + if not message.ancestry: + # Root message (level 0, no parent) + root_messages.append(message) + else: + # Has a parent - add to parent's children + immediate_parent_id = message.ancestry[-1] + if immediate_parent_id in message_by_id: + parent = message_by_id[immediate_parent_id] + parent.children.append(message) + + return root_messages + + def deduplicate_messages(messages: List[TranscriptEntry]) -> List[TranscriptEntry]: """Remove duplicate messages based on (type, timestamp, sessionId, content_key). @@ -3246,6 +3293,15 @@ def generate_html( with log_timing("Mark messages with children", t_start): _mark_messages_with_children(template_messages) + # Build tree structure by populating children fields + # This enables future recursive template rendering while maintaining + # backward compatibility with the current flat-list approach + with log_timing("Build message tree", t_start): + _root_messages = _build_message_tree(template_messages) + # Note: root_messages contains just the top-level messages with children populated + # For now, we continue using template_messages (flat list) for template rendering + # Future: pass root_messages to a recursive template macro + # Render template with log_timing("Template environment setup", t_start): env = _get_template_environment() From 4299b9a2b286898409bebc4020135a8e3d45f53f Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 2 Dec 2025 04:41:44 +0100 Subject: [PATCH 004/102] Add tests for TemplateMessage tree and flatten functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TestTemplateMessageTree class with unit tests for: - Single message flattening - Messages with direct children - Nested children (depth-first order) - Multiple branches - flatten_all static method - Empty list handling - Children field default value - Order preservation Also add TestTreeBuildingIntegration for integration tests verifying tree building works with real transcript data and flatten roundtrip preserves message count. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- test/test_template_data.py | 215 +++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/test/test_template_data.py b/test/test_template_data.py index b578fc80..6da73cee 100644 --- a/test/test_template_data.py +++ b/test/test_template_data.py @@ -375,5 +375,220 @@ def test_malformed_message_handling(self): test_file_path.unlink() +class TestTemplateMessageTree: + """Test TemplateMessage tree building and flatten functionality.""" + + def _create_message( + self, msg_type: str, msg_id: str = None, ancestry: list = None + ) -> TemplateMessage: + """Helper to create a minimal TemplateMessage for testing.""" + msg = TemplateMessage( + message_type=msg_type, + content_html=f"

{msg_type} content

", + formatted_timestamp="2025-06-14 10:00:00", + css_class=msg_type, + raw_timestamp=None, + ) + if msg_id: + msg.message_id = msg_id + if ancestry: + msg.ancestry = ancestry + return msg + + def test_flatten_single_message(self): + """Test flattening a single message with no children.""" + msg = self._create_message("user", "m1", []) + + result = msg.flatten() + + assert len(result) == 1 + assert result[0] is msg + + def test_flatten_with_children(self): + """Test flattening a message with children.""" + parent = self._create_message("user", "m1", []) + child1 = self._create_message("assistant", "m2", ["m1"]) + child2 = self._create_message("tool_use", "m3", ["m1"]) + + parent.children = [child1, child2] + + result = parent.flatten() + + assert len(result) == 3 + assert result[0] is parent + assert result[1] is child1 + assert result[2] is child2 + + def test_flatten_nested_children(self): + """Test flattening with nested children (depth-first order).""" + root = self._create_message("user", "m1", []) + child = self._create_message("assistant", "m2", ["m1"]) + grandchild = self._create_message("tool_use", "m3", ["m1", "m2"]) + + child.children = [grandchild] + root.children = [child] + + result = root.flatten() + + assert len(result) == 3 + # Depth-first order: root, child, grandchild + assert result[0] is root + assert result[1] is child + assert result[2] is grandchild + + def test_flatten_multiple_branches(self): + """Test flattening with multiple branches (depth-first order).""" + root = self._create_message("user", "m1", []) + branch1 = self._create_message("assistant", "m2", ["m1"]) + branch2 = self._create_message("assistant", "m3", ["m1"]) + leaf1 = self._create_message("tool_use", "m4", ["m1", "m2"]) + leaf2 = self._create_message("tool_use", "m5", ["m1", "m3"]) + + branch1.children = [leaf1] + branch2.children = [leaf2] + root.children = [branch1, branch2] + + result = root.flatten() + + # Depth-first: root -> branch1 -> leaf1 -> branch2 -> leaf2 + assert len(result) == 5 + assert result[0] is root + assert result[1] is branch1 + assert result[2] is leaf1 + assert result[3] is branch2 + assert result[4] is leaf2 + + def test_flatten_all_single_root(self): + """Test flatten_all with a single root message.""" + root = self._create_message("user", "m1", []) + child = self._create_message("assistant", "m2", ["m1"]) + root.children = [child] + + result = TemplateMessage.flatten_all([root]) + + assert len(result) == 2 + assert result[0] is root + assert result[1] is child + + def test_flatten_all_multiple_roots(self): + """Test flatten_all with multiple root messages.""" + root1 = self._create_message("user", "m1", []) + child1 = self._create_message("assistant", "m2", ["m1"]) + root1.children = [child1] + + root2 = self._create_message("user", "m3", []) + child2 = self._create_message("assistant", "m4", ["m3"]) + root2.children = [child2] + + result = TemplateMessage.flatten_all([root1, root2]) + + assert len(result) == 4 + assert result[0] is root1 + assert result[1] is child1 + assert result[2] is root2 + assert result[3] is child2 + + def test_flatten_all_empty_list(self): + """Test flatten_all with an empty list.""" + result = TemplateMessage.flatten_all([]) + + assert result == [] + + def test_children_field_default_empty(self): + """Test that children field defaults to empty list.""" + msg = self._create_message("user") + + assert msg.children == [] + + def test_flatten_preserves_order(self): + """Test that flatten preserves insertion order of children.""" + root = self._create_message("user", "m1", []) + children = [ + self._create_message("assistant", f"m{i}", ["m1"]) for i in range(2, 7) + ] + root.children = children + + result = root.flatten() + + # First element is root, rest are children in order + assert result[0] is root + for i, child in enumerate(children): + assert result[i + 1] is child + + +class TestTreeBuildingIntegration: + """Integration tests for tree building with real transcript data.""" + + def test_tree_built_from_representative_messages(self): + """Test that tree structure is built when rendering real messages.""" + test_data_path = ( + Path(__file__).parent / "test_data" / "representative_messages.jsonl" + ) + + messages = load_transcript(test_data_path) + # Generate HTML (this builds the tree internally) + generate_html(messages, "Test Transcript") + + # Note: We can't easily access the internal tree structure since + # _build_message_tree is private. This test just verifies the + # tree building doesn't break normal HTML generation. + + def test_flatten_roundtrip_preserves_count(self): + """Test that flatten of built tree gives same count as input.""" + # Create a manual tree and verify flatten returns all messages + root = TemplateMessage( + message_type="session", + content_html="

Session

", + formatted_timestamp="2025-06-14 10:00:00", + css_class="session", + raw_timestamp=None, + ) + root.message_id = "session-1" + root.ancestry = [] + + user = TemplateMessage( + message_type="user", + content_html="

User

", + formatted_timestamp="2025-06-14 10:00:01", + css_class="user", + raw_timestamp=None, + ) + user.message_id = "d-1" + user.ancestry = ["session-1"] + + assistant = TemplateMessage( + message_type="assistant", + content_html="

Assistant

", + formatted_timestamp="2025-06-14 10:00:02", + css_class="assistant", + raw_timestamp=None, + ) + assistant.message_id = "d-2" + assistant.ancestry = ["session-1", "d-1"] + + tool = TemplateMessage( + message_type="tool_use", + content_html="

Tool

", + formatted_timestamp="2025-06-14 10:00:03", + css_class="tool_use", + raw_timestamp=None, + ) + tool.message_id = "d-3" + tool.ancestry = ["session-1", "d-1", "d-2"] + + # Build tree manually + assistant.children = [tool] + user.children = [assistant] + root.children = [user] + + # Flatten and verify + flat = TemplateMessage.flatten_all([root]) + assert len(flat) == 4 + assert flat[0].message_id == "session-1" + assert flat[1].message_id == "d-1" + assert flat[2].message_id == "d-2" + assert flat[3].message_id == "d-3" + + if __name__ == "__main__": pytest.main([__file__]) From c05cd6a1292df69d5da7dbaa77f3bf8e867b9190 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 2 Dec 2025 04:42:12 +0100 Subject: [PATCH 005/102] Update architecture doc: mark Phase 1 and Phase 2 complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document completed work on children-based TemplateMessage architecture: - Phase 1: children field, flatten() methods with tests - Phase 2: _build_message_tree() function integrated in render pipeline Add notes on current state, rationale for keeping both data structures, and performance considerations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- dev-docs/TEMPLATE_MESSAGE_CHILDREN.md | 45 ++++++++++++++++++++------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/dev-docs/TEMPLATE_MESSAGE_CHILDREN.md b/dev-docs/TEMPLATE_MESSAGE_CHILDREN.md index aac6d336..1b7ab2ec 100644 --- a/dev-docs/TEMPLATE_MESSAGE_CHILDREN.md +++ b/dev-docs/TEMPLATE_MESSAGE_CHILDREN.md @@ -72,24 +72,45 @@ messageEl.querySelector('.children').style.display = ''; ## Exploration Log -### Phase 1: Foundation (TODO) -- [ ] Add `children` field to TemplateMessage -- [ ] Keep existing flat-list behavior working -- [ ] Add `flatten()` method for backward compatibility - -### Phase 2: Tree Building (TODO) -- [ ] Create `_build_message_tree()` function -- [ ] Return root messages instead of flat list -- [ ] Update child counting to work recursively - -### Phase 3: Template Migration (TODO) +### Phase 1: Foundation ✅ COMPLETE +- [x] Add `children` field to TemplateMessage (commit `7077f68`) +- [x] Keep existing flat-list behavior working +- [x] Add `flatten()` method for backward compatibility (commit `ed4d7b3`) + - Instance method `flatten()` returns self + all descendants in depth-first order + - Static method `flatten_all()` flattens list of root messages + - Unit tests in `test/test_template_data.py::TestTemplateMessageTree` + +### Phase 2: Tree Building ✅ COMPLETE +- [x] Create `_build_message_tree()` function (commit `83fcf31`) + - Takes flat list with `message_id` and `ancestry` already set + - Populates `children` field based on ancestry + - Returns list of root messages (those with empty ancestry) +- [x] Called after `_mark_messages_with_children()` in render pipeline +- [x] Root messages stored but flat list still passed to template +- [x] Integration tests verify tree building doesn't break HTML generation + +### Phase 3: Template Migration (TODO - Future Work) - [ ] Create recursive render macro - [ ] Update DOM structure to use nested `.children` divs - [ ] Migrate JavaScript fold/unfold +- [ ] Pass `root_messages` to template instead of flat list ### Challenges & Notes -*To be filled as exploration progresses...* +**Current State (2025-12-02):** +- Tree is built internally but not yet used for rendering +- Both data structures exist: flat list (used by template) and tree (populated but unused) +- This allows incremental migration - template can switch to tree rendering later + +**Why Keep Both:** +1. Backward compatibility with existing template +2. Can test tree-building logic without breaking rendering +3. `flatten_all()` provides escape hatch if tree rendering has issues + +**Performance Consideration:** +- Tree building is O(n) where n = number of messages +- No significant overhead observed in timing logs +- Most time spent in template rendering, not data structure manipulation ## Related Work From 0a0ae089b96217d3650591ba7ac4629db30f3860 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 6 Dec 2025 19:25:23 +0100 Subject: [PATCH 006/102] Fix type annotation issues in scripts and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix return type annotation in extract_message_samples.py to return tuple instead of dict - Fix parameter type annotations in test_template_data.py to use union type (str | None) instead of invalid default syntax 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- scripts/extract_message_samples.py | 5 +++-- test/test_template_data.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/extract_message_samples.py b/scripts/extract_message_samples.py index 6765a99c..2fd4e94f 100644 --- a/scripts/extract_message_samples.py +++ b/scripts/extract_message_samples.py @@ -6,7 +6,6 @@ """ import json -import re from pathlib import Path from typing import Any @@ -122,7 +121,9 @@ def abbreviate_content_item(item: dict[str, Any]) -> dict[str, Any]: return result -def find_samples(data_dirs: list[Path]) -> dict[str, list[dict]]: +def find_samples( + data_dirs: list[Path], +) -> tuple[dict[str, list[dict]], dict[str, list[dict]]]: """Find sample messages of each type.""" samples: dict[str, list[dict]] = { "user_text": [], diff --git a/test/test_template_data.py b/test/test_template_data.py index 6da73cee..0824360e 100644 --- a/test/test_template_data.py +++ b/test/test_template_data.py @@ -379,7 +379,7 @@ class TestTemplateMessageTree: """Test TemplateMessage tree building and flatten functionality.""" def _create_message( - self, msg_type: str, msg_id: str = None, ancestry: list = None + self, msg_type: str, msg_id: str | None = None, ancestry: list | None = None ) -> TemplateMessage: """Helper to create a minimal TemplateMessage for testing.""" msg = TemplateMessage( From 7f1d9c8041bf01e2cfb76e5ee854bdb3d9d9e28c Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 6 Dec 2025 19:25:30 +0100 Subject: [PATCH 007/102] Extract ANSI colors and code rendering to dedicated modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3: ANSI Color Module Extraction - Create claude_code_log/ansi_colors.py (261 lines) - Move _convert_ansi_to_html() -> convert_ansi_to_html() - Update imports in renderer.py and test_ansi_colors.py Phase 4: Code Rendering Module Extraction - Create claude_code_log/renderer_code.py (330 lines) - Move highlight_code_with_pygments(), truncate_highlighted_preview() - Move render_single_diff(), render_line_diff() - Update imports in renderer.py and test_preview_truncation.py - Remove unused Pygments imports from renderer.py Results: renderer.py reduced from 4246 to 3730 lines (516 lines extracted) Add MESSAGE_REFACTORING.md documenting the refactoring plan, completed phases, and integration notes for related branches. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/ansi_colors.py | 261 +++++++++++++++ claude_code_log/renderer.py | 551 +------------------------------ claude_code_log/renderer_code.py | 330 ++++++++++++++++++ dev-docs/MESSAGE_REFACTORING.md | 308 +++++++++++++++++ test/test_ansi_colors.py | 38 +-- test/test_preview_truncation.py | 16 +- 6 files changed, 943 insertions(+), 561 deletions(-) create mode 100644 claude_code_log/ansi_colors.py create mode 100644 claude_code_log/renderer_code.py create mode 100755 dev-docs/MESSAGE_REFACTORING.md diff --git a/claude_code_log/ansi_colors.py b/claude_code_log/ansi_colors.py new file mode 100644 index 00000000..cd14f989 --- /dev/null +++ b/claude_code_log/ansi_colors.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""ANSI escape code to HTML conversion. + +This module provides utilities for converting terminal ANSI escape codes +to HTML with appropriate CSS classes for styling. +""" + +import html +import re +from typing import Any, Dict, List + + +def _escape_html(text: str) -> str: + """Escape HTML special characters in text. + + Also normalizes line endings (CRLF -> LF) to prevent double spacing in
 blocks.
+    """
+    # Normalize CRLF to LF to prevent double line breaks in HTML
+    normalized = text.replace("\r\n", "\n").replace("\r", "\n")
+    return html.escape(normalized)
+
+
+def convert_ansi_to_html(text: str) -> str:
+    """Convert ANSI escape codes to HTML spans with CSS classes.
+
+    Supports:
+    - Colors (30-37, 90-97 for foreground; 40-47, 100-107 for background)
+    - RGB colors (38;2;r;g;b for foreground; 48;2;r;g;b for background)
+    - Bold (1), Dim (2), Italic (3), Underline (4)
+    - Reset (0, 39, 49, 22, 23, 24)
+    - Strips cursor movement and screen manipulation codes
+    """
+    # First, strip cursor movement and screen manipulation codes
+    # Common patterns: [1A (cursor up), [2K (erase line), [?25l (hide cursor), etc.
+    cursor_patterns = [
+        r"\x1b\[[0-9]*[ABCD]",  # Cursor movement (up, down, forward, back)
+        r"\x1b\[[0-9]*[EF]",  # Cursor next/previous line
+        r"\x1b\[[0-9]*[GH]",  # Cursor horizontal/home position
+        r"\x1b\[[0-9;]*[Hf]",  # Cursor position
+        r"\x1b\[[0-9]*[JK]",  # Erase display/line
+        r"\x1b\[[0-9]*[ST]",  # Scroll up/down
+        r"\x1b\[\?[0-9]*[hl]",  # Private mode set/reset (show/hide cursor, etc.)
+        r"\x1b\[[0-9]*[PXYZ@]",  # Insert/delete operations
+        r"\x1b\[=[0-9]*[A-Za-z]",  # Alternate character set
+        r"\x1b\][0-9];[^\x07]*\x07",  # Operating System Command (OSC)
+        r"\x1b\][0-9];[^\x1b]*\x1b\\",  # OSC with string terminator
+    ]
+
+    # Strip all cursor movement and screen manipulation codes
+    for pattern in cursor_patterns:
+        text = re.sub(pattern, "", text)
+
+    # Also strip any remaining unhandled escape sequences that aren't color codes
+    # This catches any we might have missed, but preserves \x1b[...m color codes
+    text = re.sub(r"\x1b\[(?![0-9;]*m)[0-9;]*[A-Za-z]", "", text)
+
+    result: List[str] = []
+    segments: List[Dict[str, Any]] = []
+
+    # First pass: split text into segments with their styles
+    last_end = 0
+    current_fg = None
+    current_bg = None
+    current_bold = False
+    current_dim = False
+    current_italic = False
+    current_underline = False
+    current_rgb_fg = None
+    current_rgb_bg = None
+
+    for match in re.finditer(r"\x1b\[([0-9;]+)m", text):
+        # Add text before this escape code
+        if match.start() > last_end:
+            segments.append(
+                {
+                    "text": text[last_end : match.start()],
+                    "fg": current_fg,
+                    "bg": current_bg,
+                    "bold": current_bold,
+                    "dim": current_dim,
+                    "italic": current_italic,
+                    "underline": current_underline,
+                    "rgb_fg": current_rgb_fg,
+                    "rgb_bg": current_rgb_bg,
+                }
+            )
+
+        # Process escape codes
+        codes = match.group(1).split(";")
+        i = 0
+        while i < len(codes):
+            code = codes[i]
+
+            # Reset codes
+            if code == "0":
+                current_fg = None
+                current_bg = None
+                current_bold = False
+                current_dim = False
+                current_italic = False
+                current_underline = False
+                current_rgb_fg = None
+                current_rgb_bg = None
+            elif code == "39":
+                current_fg = None
+                current_rgb_fg = None
+            elif code == "49":
+                current_bg = None
+                current_rgb_bg = None
+            elif code == "22":
+                current_bold = False
+                current_dim = False
+            elif code == "23":
+                current_italic = False
+            elif code == "24":
+                current_underline = False
+
+            # Style codes
+            elif code == "1":
+                current_bold = True
+            elif code == "2":
+                current_dim = True
+            elif code == "3":
+                current_italic = True
+            elif code == "4":
+                current_underline = True
+
+            # Standard foreground colors
+            elif code in ["30", "31", "32", "33", "34", "35", "36", "37"]:
+                color_map = {
+                    "30": "black",
+                    "31": "red",
+                    "32": "green",
+                    "33": "yellow",
+                    "34": "blue",
+                    "35": "magenta",
+                    "36": "cyan",
+                    "37": "white",
+                }
+                current_fg = f"ansi-{color_map[code]}"
+                current_rgb_fg = None
+
+            # Standard background colors
+            elif code in ["40", "41", "42", "43", "44", "45", "46", "47"]:
+                color_map = {
+                    "40": "black",
+                    "41": "red",
+                    "42": "green",
+                    "43": "yellow",
+                    "44": "blue",
+                    "45": "magenta",
+                    "46": "cyan",
+                    "47": "white",
+                }
+                current_bg = f"ansi-bg-{color_map[code]}"
+                current_rgb_bg = None
+
+            # Bright foreground colors
+            elif code in ["90", "91", "92", "93", "94", "95", "96", "97"]:
+                color_map = {
+                    "90": "bright-black",
+                    "91": "bright-red",
+                    "92": "bright-green",
+                    "93": "bright-yellow",
+                    "94": "bright-blue",
+                    "95": "bright-magenta",
+                    "96": "bright-cyan",
+                    "97": "bright-white",
+                }
+                current_fg = f"ansi-{color_map[code]}"
+                current_rgb_fg = None
+
+            # Bright background colors
+            elif code in ["100", "101", "102", "103", "104", "105", "106", "107"]:
+                color_map = {
+                    "100": "bright-black",
+                    "101": "bright-red",
+                    "102": "bright-green",
+                    "103": "bright-yellow",
+                    "104": "bright-blue",
+                    "105": "bright-magenta",
+                    "106": "bright-cyan",
+                    "107": "bright-white",
+                }
+                current_bg = f"ansi-bg-{color_map[code]}"
+                current_rgb_bg = None
+
+            # RGB foreground color
+            elif code == "38" and i + 1 < len(codes) and codes[i + 1] == "2":
+                if i + 4 < len(codes):
+                    r, g, b = codes[i + 2], codes[i + 3], codes[i + 4]
+                    current_rgb_fg = f"color: rgb({r}, {g}, {b})"
+                    current_fg = None
+                    i += 4
+
+            # RGB background color
+            elif code == "48" and i + 1 < len(codes) and codes[i + 1] == "2":
+                if i + 4 < len(codes):
+                    r, g, b = codes[i + 2], codes[i + 3], codes[i + 4]
+                    current_rgb_bg = f"background-color: rgb({r}, {g}, {b})"
+                    current_bg = None
+                    i += 4
+
+            i += 1
+
+        last_end = match.end()
+
+    # Add remaining text
+    if last_end < len(text):
+        segments.append(
+            {
+                "text": text[last_end:],
+                "fg": current_fg,
+                "bg": current_bg,
+                "bold": current_bold,
+                "dim": current_dim,
+                "italic": current_italic,
+                "underline": current_underline,
+                "rgb_fg": current_rgb_fg,
+                "rgb_bg": current_rgb_bg,
+            }
+        )
+
+    # Second pass: build HTML
+    for segment in segments:
+        if not segment["text"]:
+            continue
+
+        classes: List[str] = []
+        styles: List[str] = []
+
+        if segment["fg"]:
+            classes.append(segment["fg"])
+        if segment["bg"]:
+            classes.append(segment["bg"])
+        if segment["bold"]:
+            classes.append("ansi-bold")
+        if segment["dim"]:
+            classes.append("ansi-dim")
+        if segment["italic"]:
+            classes.append("ansi-italic")
+        if segment["underline"]:
+            classes.append("ansi-underline")
+        if segment["rgb_fg"]:
+            styles.append(segment["rgb_fg"])
+        if segment["rgb_bg"]:
+            styles.append(segment["rgb_bg"])
+
+        escaped_text = _escape_html(segment["text"])
+
+        if classes or styles:
+            attrs: List[str] = []
+            if classes:
+                attrs.append(f'class="{" ".join(classes)}"')
+            if styles:
+                attrs.append(f'style="{"; ".join(styles)}"')
+            result.append(f"{escaped_text}")
+        else:
+            result.append(escaped_text)
+
+    return "".join(result)
diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py
index 7518b5ed..99c93b86 100644
--- a/claude_code_log/renderer.py
+++ b/claude_code_log/renderer.py
@@ -2,7 +2,6 @@
 """Render Claude transcript data to HTML format."""
 
 import json
-import os
 import re
 import time
 from pathlib import Path
@@ -14,10 +13,6 @@
 import html
 import mistune
 from jinja2 import Environment, FileSystemLoader, select_autoescape
-from pygments import highlight  # type: ignore[reportUnknownVariableType]
-from pygments.lexers import TextLexer  # type: ignore[reportUnknownVariableType]
-from pygments.formatters import HtmlFormatter  # type: ignore[reportUnknownVariableType]
-from pygments.util import ClassNotFound  # type: ignore[reportUnknownVariableType]
 
 from .models import (
     TranscriptEntry,
@@ -51,6 +46,12 @@
     log_timing,
 )
 from .cache import get_library_version
+from .ansi_colors import convert_ansi_to_html
+from .renderer_code import (
+    highlight_code_with_pygments,
+    truncate_highlighted_preview,
+    render_single_diff,
+)
 
 
 def starts_with_emoji(text: str) -> bool:
@@ -341,7 +342,7 @@ def render_file_content_collapsible(
         HTML string with highlighted code, collapsible if >line_threshold lines
     """
     # Highlight code with Pygments (single call)
-    highlighted_html = _highlight_code_with_pygments(
+    highlighted_html = highlight_code_with_pygments(
         code_content, file_path, linenostart=linenostart
     )
 
@@ -350,7 +351,7 @@ def render_file_content_collapsible(
     lines = code_content.split("\n")
     if len(lines) > line_threshold:
         # Extract preview from already-highlighted HTML (avoids double highlighting)
-        preview_html = _truncate_highlighted_preview(
+        preview_html = truncate_highlighted_preview(
             highlighted_html, preview_line_count
         )
         html_parts.append(
@@ -618,143 +619,6 @@ def format_todowrite_content(tool_use: ToolUseContent) -> str:
     """
 
 
-def _highlight_code_with_pygments(
-    code: str, file_path: str, show_linenos: bool = True, linenostart: int = 1
-) -> str:
-    """Highlight code using Pygments with appropriate lexer based on file path.
-
-    Args:
-        code: The source code to highlight
-        file_path: Path to determine the appropriate lexer
-        show_linenos: Whether to show line numbers (default: True)
-        linenostart: Starting line number for display (default: 1)
-
-    Returns:
-        HTML string with syntax-highlighted code
-    """
-    # PERFORMANCE FIX: Use Pygments' public API to build filename pattern mapping, avoiding filesystem I/O
-    # get_lexer_for_filename performs I/O operations (file existence checks, reading bytes)
-    # which causes severe slowdowns, especially on Windows with antivirus scanning
-    # Solution: Build a reverse mapping from filename patterns to lexer aliases using get_all_lexers() (done once)
-    import fnmatch
-    from pygments.lexers import get_lexer_by_name, get_all_lexers  # type: ignore[reportUnknownVariableType]
-
-    # Build pattern->alias mapping on first call (cached as function attribute)
-    # OPTIMIZATION: Create both direct extension lookup and full pattern cache
-    if not hasattr(_highlight_code_with_pygments, "_pattern_cache"):
-        pattern_cache: dict[str, str] = {}
-        extension_cache: dict[str, str] = {}  # Fast lookup for simple *.ext patterns
-
-        # Use public API: get_all_lexers() returns (name, aliases, patterns, mimetypes) tuples
-        for name, aliases, patterns, mimetypes in get_all_lexers():  # type: ignore[reportUnknownVariableType]
-            if aliases and patterns:
-                # Use first alias as the lexer name
-                lexer_alias = aliases[0]
-                # Map each filename pattern to this lexer alias
-                for pattern in patterns:
-                    pattern_lower = pattern.lower()
-                    pattern_cache[pattern_lower] = lexer_alias
-                    # Extract simple extension patterns (*.ext) for fast lookup
-                    if (
-                        pattern_lower.startswith("*.")
-                        and "*" not in pattern_lower[2:]
-                        and "?" not in pattern_lower[2:]
-                    ):
-                        ext = pattern_lower[2:]  # Remove "*."
-                        # Prefer first match for each extension
-                        if ext not in extension_cache:
-                            extension_cache[ext] = lexer_alias
-
-        _highlight_code_with_pygments._pattern_cache = pattern_cache  # type: ignore[attr-defined]
-        _highlight_code_with_pygments._extension_cache = extension_cache  # type: ignore[attr-defined]
-
-    # Get basename for matching (patterns are like "*.py")
-    basename = os.path.basename(file_path).lower()
-
-    try:
-        # Get caches
-        pattern_cache = _highlight_code_with_pygments._pattern_cache  # type: ignore[attr-defined]
-        extension_cache = _highlight_code_with_pygments._extension_cache  # type: ignore[attr-defined]
-
-        # OPTIMIZATION: Try fast extension lookup first (O(1) dict lookup)
-        lexer_alias = None
-        if "." in basename:
-            ext = basename.split(".")[-1]  # Get last extension (handles .tar.gz, etc.)
-            lexer_alias = extension_cache.get(ext)
-
-        # Fall back to pattern matching only if extension lookup failed
-        if lexer_alias is None:
-            for pattern, lex_alias in pattern_cache.items():
-                if fnmatch.fnmatch(basename, pattern):
-                    lexer_alias = lex_alias
-                    break
-
-        # Get lexer or use TextLexer as fallback
-        # Note: stripall=False preserves leading whitespace (important for code indentation)
-        if lexer_alias:
-            lexer = get_lexer_by_name(lexer_alias, stripall=False)  # type: ignore[reportUnknownVariableType]
-        else:
-            lexer = TextLexer()  # type: ignore[reportUnknownVariableType]
-    except ClassNotFound:
-        # Fall back to plain text lexer
-        lexer = TextLexer()  # type: ignore[reportUnknownVariableType]
-
-    # Create formatter with line numbers in table format
-    formatter = HtmlFormatter(  # type: ignore[reportUnknownVariableType]
-        linenos="table" if show_linenos else False,
-        cssclass="highlight",
-        wrapcode=True,
-        linenostart=linenostart,
-    )
-
-    # Highlight the code with timing if enabled
-    with timing_stat("_pygments_timings"):
-        return str(highlight(code, lexer, formatter))  # type: ignore[reportUnknownArgumentType]
-
-
-def _truncate_highlighted_preview(highlighted_html: str, max_lines: int) -> str:
-    """Truncate Pygments highlighted HTML to first N lines.
-
-    HtmlFormatter(linenos="table") produces a single  with two s:
-      
LINE_NUMS
-
CODE
- - We truncate content within each
 tag to the first max_lines lines.
-
-    Args:
-        highlighted_html: Full Pygments-highlighted HTML
-        max_lines: Maximum number of lines to include in preview
-
-    Returns:
-        Truncated HTML with same structure but fewer lines
-    """
-
-    def truncate_pre_content(match: re.Match[str]) -> str:
-        """Truncate content inside a 
 tag to max_lines."""
-        prefix, content, suffix = match.groups()
-        lines = content.split("\n")
-        truncated = "\n".join(lines[:max_lines])
-        return prefix + truncated + suffix
-
-    # Truncate linenos 
 content (line numbers separated by newlines)
-    result = re.sub(
-        r'(
)(.*?)(
)', - truncate_pre_content, - highlighted_html, - flags=re.DOTALL, - ) - - # Truncate code
 content
-    result = re.sub(
-        r'(
]*>)(.*?)(
)', - truncate_pre_content, - result, - flags=re.DOTALL, - ) - - return result - - def format_read_tool_content(tool_use: ToolUseContent) -> str: # noqa: ARG001 """Format Read tool use content showing file path. @@ -858,104 +722,6 @@ def render_params_table(params: Dict[str, Any]) -> str: return "".join(html_parts) -def _render_single_diff(old_string: str, new_string: str) -> str: - """Render a single diff between old_string and new_string. - - Returns HTML for the diff view with intra-line highlighting. - """ - import difflib - - # Split into lines for diff - old_lines = old_string.splitlines(keepends=True) - new_lines = new_string.splitlines(keepends=True) - - # Generate unified diff to identify changed lines - differ = difflib.Differ() - diff: list[str] = list(differ.compare(old_lines, new_lines)) - - html_parts = ["
"] - - i = 0 - while i < len(diff): - line = diff[i] - prefix = line[0:2] - content = line[2:] - - if prefix == "- ": - # Removed line - look ahead for corresponding addition - removed_lines: list[str] = [content] - j = i + 1 - - # Collect consecutive removed lines - while j < len(diff) and diff[j].startswith("- "): - removed_lines.append(diff[j][2:]) - j += 1 - - # Skip '? ' hint lines - while j < len(diff) and diff[j].startswith("? "): - j += 1 - - # Collect consecutive added lines - added_lines: list[str] = [] - while j < len(diff) and diff[j].startswith("+ "): - added_lines.append(diff[j][2:]) - j += 1 - - # Skip '? ' hint lines - while j < len(diff) and diff[j].startswith("? "): - j += 1 - - # Generate character-level diff for paired lines - if added_lines: - for old_line, new_line in zip(removed_lines, added_lines): - html_parts.append(_render_line_diff(old_line, new_line)) - - # Handle any unpaired lines - for old_line in removed_lines[len(added_lines) :]: - escaped = escape_html(old_line.rstrip("\n")) - html_parts.append( - f"
-{escaped}
" - ) - - for new_line in added_lines[len(removed_lines) :]: - escaped = escape_html(new_line.rstrip("\n")) - html_parts.append( - f"
+{escaped}
" - ) - else: - # No corresponding addition - just removed - for old_line in removed_lines: - escaped = escape_html(old_line.rstrip("\n")) - html_parts.append( - f"
-{escaped}
" - ) - - i = j - - elif prefix == "+ ": - # Added line without corresponding removal - escaped = escape_html(content.rstrip("\n")) - html_parts.append( - f"
+{escaped}
" - ) - i += 1 - - elif prefix == "? ": - # Skip hint lines (already processed) - i += 1 - - else: - # Unchanged line - show for context - escaped = escape_html(content.rstrip("\n")) - html_parts.append( - f"
{escaped}
" - ) - i += 1 - - html_parts.append("
") - return "".join(html_parts) - - def format_multiedit_tool_content(tool_use: ToolUseContent) -> str: """Format Multiedit tool use content showing multiple diffs.""" file_path = tool_use.input.get("file_path", "") @@ -977,7 +743,7 @@ def format_multiedit_tool_content(tool_use: ToolUseContent) -> str: html_parts.append( f"
Edit #{idx}
" ) - html_parts.append(_render_single_diff(old_string, new_string)) + html_parts.append(render_single_diff(old_string, new_string)) html_parts.append("
") html_parts.append("
") @@ -1003,52 +769,12 @@ def format_edit_tool_content(tool_use: ToolUseContent) -> str: ) # Use shared diff rendering helper - html_parts.append(_render_single_diff(old_string, new_string)) + html_parts.append(render_single_diff(old_string, new_string)) html_parts.append("
") return "".join(html_parts) -def _render_line_diff(old_line: str, new_line: str) -> str: - """Render a pair of changed lines with character-level highlighting.""" - import difflib - - # Use SequenceMatcher for character-level diff - sm = difflib.SequenceMatcher(None, old_line.rstrip("\n"), new_line.rstrip("\n")) - - # Build old line with highlighting - old_parts: list[str] = [] - old_parts.append( - "
-" - ) - for tag, i1, i2, j1, j2 in sm.get_opcodes(): - chunk = old_line[i1:i2] - if tag == "equal": - old_parts.append(escape_html(chunk)) - elif tag in ("delete", "replace"): - old_parts.append( - f"{escape_html(chunk)}" - ) - old_parts.append("
") - - # Build new line with highlighting - new_parts: list[str] = [] - new_parts.append( - "
+" - ) - for tag, i1, i2, j1, j2 in sm.get_opcodes(): - chunk = new_line[j1:j2] - if tag == "equal": - new_parts.append(escape_html(chunk)) - elif tag in ("insert", "replace"): - new_parts.append( - f"{escape_html(chunk)}" - ) - new_parts.append("
") - - return "".join(old_parts) + "".join(new_parts) - - def format_task_tool_content(tool_use: ToolUseContent) -> str: """Format Task tool content with markdown-rendered prompt. @@ -1370,7 +1096,7 @@ def format_tool_result_content( # Check if this looks like Bash tool output and process ANSI codes # Bash tool results often contain ANSI escape sequences and terminal output if _looks_like_bash_output(raw_content): - escaped_content = _convert_ansi_to_html(raw_content) + escaped_content = convert_ansi_to_html(raw_content) else: escaped_content = escape_html(raw_content) @@ -2080,7 +1806,7 @@ def _render_hook_summary(message: "SystemTranscriptEntry") -> str: error_html = '
' for err in message.hookErrors: # Convert ANSI codes in error output - formatted_err = _convert_ansi_to_html(err) + formatted_err = convert_ansi_to_html(err) error_html += f'
{formatted_err}
' error_html += "
" @@ -2093,249 +1819,6 @@ def _render_hook_summary(message: "SystemTranscriptEntry") -> str: """ -def _convert_ansi_to_html(text: str) -> str: - """Convert ANSI escape codes to HTML spans with CSS classes. - - Supports: - - Colors (30-37, 90-97 for foreground; 40-47, 100-107 for background) - - RGB colors (38;2;r;g;b for foreground; 48;2;r;g;b for background) - - Bold (1), Dim (2), Italic (3), Underline (4) - - Reset (0, 39, 49, 22, 23, 24) - - Strips cursor movement and screen manipulation codes - """ - import re - - # First, strip cursor movement and screen manipulation codes - # Common patterns: [1A (cursor up), [2K (erase line), [?25l (hide cursor), etc. - cursor_patterns = [ - r"\x1b\[[0-9]*[ABCD]", # Cursor movement (up, down, forward, back) - r"\x1b\[[0-9]*[EF]", # Cursor next/previous line - r"\x1b\[[0-9]*[GH]", # Cursor horizontal/home position - r"\x1b\[[0-9;]*[Hf]", # Cursor position - r"\x1b\[[0-9]*[JK]", # Erase display/line - r"\x1b\[[0-9]*[ST]", # Scroll up/down - r"\x1b\[\?[0-9]*[hl]", # Private mode set/reset (show/hide cursor, etc.) - r"\x1b\[[0-9]*[PXYZ@]", # Insert/delete operations - r"\x1b\[=[0-9]*[A-Za-z]", # Alternate character set - r"\x1b\][0-9];[^\x07]*\x07", # Operating System Command (OSC) - r"\x1b\][0-9];[^\x1b]*\x1b\\", # OSC with string terminator - ] - - # Strip all cursor movement and screen manipulation codes - for pattern in cursor_patterns: - text = re.sub(pattern, "", text) - - # Also strip any remaining unhandled escape sequences that aren't color codes - # This catches any we might have missed, but preserves \x1b[...m color codes - text = re.sub(r"\x1b\[(?![0-9;]*m)[0-9;]*[A-Za-z]", "", text) - - result: List[str] = [] - segments: List[Dict[str, Any]] = [] - - # First pass: split text into segments with their styles - last_end = 0 - current_fg = None - current_bg = None - current_bold = False - current_dim = False - current_italic = False - current_underline = False - current_rgb_fg = None - current_rgb_bg = None - - for match in re.finditer(r"\x1b\[([0-9;]+)m", text): - # Add text before this escape code - if match.start() > last_end: - segments.append( - { - "text": text[last_end : match.start()], - "fg": current_fg, - "bg": current_bg, - "bold": current_bold, - "dim": current_dim, - "italic": current_italic, - "underline": current_underline, - "rgb_fg": current_rgb_fg, - "rgb_bg": current_rgb_bg, - } - ) - - # Process escape codes - codes = match.group(1).split(";") - i = 0 - while i < len(codes): - code = codes[i] - - # Reset codes - if code == "0": - current_fg = None - current_bg = None - current_bold = False - current_dim = False - current_italic = False - current_underline = False - current_rgb_fg = None - current_rgb_bg = None - elif code == "39": - current_fg = None - current_rgb_fg = None - elif code == "49": - current_bg = None - current_rgb_bg = None - elif code == "22": - current_bold = False - current_dim = False - elif code == "23": - current_italic = False - elif code == "24": - current_underline = False - - # Style codes - elif code == "1": - current_bold = True - elif code == "2": - current_dim = True - elif code == "3": - current_italic = True - elif code == "4": - current_underline = True - - # Standard foreground colors - elif code in ["30", "31", "32", "33", "34", "35", "36", "37"]: - color_map = { - "30": "black", - "31": "red", - "32": "green", - "33": "yellow", - "34": "blue", - "35": "magenta", - "36": "cyan", - "37": "white", - } - current_fg = f"ansi-{color_map[code]}" - current_rgb_fg = None - - # Standard background colors - elif code in ["40", "41", "42", "43", "44", "45", "46", "47"]: - color_map = { - "40": "black", - "41": "red", - "42": "green", - "43": "yellow", - "44": "blue", - "45": "magenta", - "46": "cyan", - "47": "white", - } - current_bg = f"ansi-bg-{color_map[code]}" - current_rgb_bg = None - - # Bright foreground colors - elif code in ["90", "91", "92", "93", "94", "95", "96", "97"]: - color_map = { - "90": "bright-black", - "91": "bright-red", - "92": "bright-green", - "93": "bright-yellow", - "94": "bright-blue", - "95": "bright-magenta", - "96": "bright-cyan", - "97": "bright-white", - } - current_fg = f"ansi-{color_map[code]}" - current_rgb_fg = None - - # Bright background colors - elif code in ["100", "101", "102", "103", "104", "105", "106", "107"]: - color_map = { - "100": "bright-black", - "101": "bright-red", - "102": "bright-green", - "103": "bright-yellow", - "104": "bright-blue", - "105": "bright-magenta", - "106": "bright-cyan", - "107": "bright-white", - } - current_bg = f"ansi-bg-{color_map[code]}" - current_rgb_bg = None - - # RGB foreground color - elif code == "38" and i + 1 < len(codes) and codes[i + 1] == "2": - if i + 4 < len(codes): - r, g, b = codes[i + 2], codes[i + 3], codes[i + 4] - current_rgb_fg = f"color: rgb({r}, {g}, {b})" - current_fg = None - i += 4 - - # RGB background color - elif code == "48" and i + 1 < len(codes) and codes[i + 1] == "2": - if i + 4 < len(codes): - r, g, b = codes[i + 2], codes[i + 3], codes[i + 4] - current_rgb_bg = f"background-color: rgb({r}, {g}, {b})" - current_bg = None - i += 4 - - i += 1 - - last_end = match.end() - - # Add remaining text - if last_end < len(text): - segments.append( - { - "text": text[last_end:], - "fg": current_fg, - "bg": current_bg, - "bold": current_bold, - "dim": current_dim, - "italic": current_italic, - "underline": current_underline, - "rgb_fg": current_rgb_fg, - "rgb_bg": current_rgb_bg, - } - ) - - # Second pass: build HTML - for segment in segments: - if not segment["text"]: - continue - - classes: List[str] = [] - styles: List[str] = [] - - if segment["fg"]: - classes.append(segment["fg"]) - if segment["bg"]: - classes.append(segment["bg"]) - if segment["bold"]: - classes.append("ansi-bold") - if segment["dim"]: - classes.append("ansi-dim") - if segment["italic"]: - classes.append("ansi-italic") - if segment["underline"]: - classes.append("ansi-underline") - if segment["rgb_fg"]: - styles.append(segment["rgb_fg"]) - if segment["rgb_bg"]: - styles.append(segment["rgb_bg"]) - - escaped_text = escape_html(segment["text"]) - - if classes or styles: - attrs: List[str] = [] - if classes: - attrs.append(f'class="{" ".join(classes)}"') - if styles: - attrs.append(f'style="{"; ".join(styles)}"') - result.append(f"{escaped_text}") - else: - result.append(escaped_text) - - return "".join(result) - - # def _process_summary_message(message: SummaryTranscriptEntry) -> tuple[str, str, str]: # """Process a summary message and return (css_class, content_html, message_type).""" # css_class = "summary" @@ -2412,7 +1895,7 @@ def _process_local_command_output(text_content: str) -> tuple[str, str, str, str ) else: # Convert ANSI codes to HTML for colored display - html_content = _convert_ansi_to_html(stdout_content) + html_content = convert_ansi_to_html(stdout_content) # Use
 to preserve formatting and line breaks
             content_html = (
                 f"Command Output:
" @@ -2476,7 +1959,7 @@ def _process_bash_output(text_content: str) -> tuple[str, str, str, str]: if stdout_match: stdout_content = stdout_match.group(1).strip() if stdout_content: - escaped_stdout = _convert_ansi_to_html(stdout_content) + escaped_stdout = convert_ansi_to_html(stdout_content) stdout_lines = stdout_content.count("\n") + 1 total_lines += stdout_lines output_parts.append( @@ -2486,7 +1969,7 @@ def _process_bash_output(text_content: str) -> tuple[str, str, str, str]: if stderr_match: stderr_content = stderr_match.group(1).strip() if stderr_content: - escaped_stderr = _convert_ansi_to_html(stderr_content) + escaped_stderr = convert_ansi_to_html(stderr_content) stderr_lines = stderr_content.count("\n") + 1 total_lines += stderr_lines output_parts.append( @@ -3615,11 +3098,11 @@ def _process_messages_loop( elif command_output_match: # Extract and process command output output = command_output_match.group(1).strip() - html_content = _convert_ansi_to_html(output) + html_content = convert_ansi_to_html(output) content_html = f"{level_icon} {html_content}" else: # Process ANSI codes in system messages (they may contain command output) - html_content = _convert_ansi_to_html(message.content) + html_content = convert_ansi_to_html(message.content) content_html = f"{level_icon} {html_content}" # Store parent UUID for hierarchy rebuild (handled by _build_message_hierarchy) diff --git a/claude_code_log/renderer_code.py b/claude_code_log/renderer_code.py new file mode 100644 index 00000000..562b69a4 --- /dev/null +++ b/claude_code_log/renderer_code.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +"""Code rendering utilities for syntax highlighting and diffs. + +This module provides utilities for rendering source code with syntax highlighting +(using Pygments) and rendering diffs with intra-line highlighting. +""" + +import difflib +import fnmatch +import html +import os +import re +from typing import Callable, List, Optional + +from pygments import highlight # type: ignore[reportUnknownVariableType] +from pygments.lexers import TextLexer, get_lexer_by_name, get_all_lexers # type: ignore[reportUnknownVariableType] +from pygments.formatters import HtmlFormatter # type: ignore[reportUnknownVariableType] +from pygments.util import ClassNotFound # type: ignore[reportUnknownVariableType] + +from .renderer_timings import timing_stat + + +def _escape_html(text: str) -> str: + """Escape HTML special characters in text. + + Also normalizes line endings (CRLF -> LF) to prevent double spacing in
 blocks.
+    """
+    normalized = text.replace("\r\n", "\n").replace("\r", "\n")
+    return html.escape(normalized)
+
+
+# Cache for Pygments lexer pattern matching
+_pattern_cache: Optional[dict[str, str]] = None
+_extension_cache: Optional[dict[str, str]] = None
+
+
+def _init_lexer_caches() -> tuple[dict[str, str], dict[str, str]]:
+    """Initialize lexer pattern and extension caches.
+
+    Returns:
+        Tuple of (pattern_cache, extension_cache)
+    """
+    global _pattern_cache, _extension_cache
+
+    if _pattern_cache is not None and _extension_cache is not None:
+        return _pattern_cache, _extension_cache
+
+    pattern_cache: dict[str, str] = {}
+    extension_cache: dict[str, str] = {}
+
+    # Use public API: get_all_lexers() returns (name, aliases, patterns, mimetypes) tuples
+    for name, aliases, patterns, mimetypes in get_all_lexers():  # type: ignore[reportUnknownVariableType]
+        if aliases and patterns:
+            # Use first alias as the lexer name
+            lexer_alias = aliases[0]
+            # Map each filename pattern to this lexer alias
+            for pattern in patterns:
+                pattern_lower = pattern.lower()
+                pattern_cache[pattern_lower] = lexer_alias
+                # Extract simple extension patterns (*.ext) for fast lookup
+                if (
+                    pattern_lower.startswith("*.")
+                    and "*" not in pattern_lower[2:]
+                    and "?" not in pattern_lower[2:]
+                ):
+                    ext = pattern_lower[2:]  # Remove "*."
+                    # Prefer first match for each extension
+                    if ext not in extension_cache:
+                        extension_cache[ext] = lexer_alias
+
+    _pattern_cache = pattern_cache
+    _extension_cache = extension_cache
+    return pattern_cache, extension_cache
+
+
+def highlight_code_with_pygments(
+    code: str, file_path: str, show_linenos: bool = True, linenostart: int = 1
+) -> str:
+    """Highlight code using Pygments with appropriate lexer based on file path.
+
+    Args:
+        code: The source code to highlight
+        file_path: Path to determine the appropriate lexer
+        show_linenos: Whether to show line numbers (default: True)
+        linenostart: Starting line number for display (default: 1)
+
+    Returns:
+        HTML string with syntax-highlighted code
+    """
+    # Get caches (initialized lazily)
+    pattern_cache, extension_cache = _init_lexer_caches()
+
+    # Get basename for matching (patterns are like "*.py")
+    basename = os.path.basename(file_path).lower()
+
+    try:
+        # OPTIMIZATION: Try fast extension lookup first (O(1) dict lookup)
+        lexer_alias = None
+        if "." in basename:
+            ext = basename.split(".")[-1]  # Get last extension (handles .tar.gz, etc.)
+            lexer_alias = extension_cache.get(ext)
+
+        # Fall back to pattern matching only if extension lookup failed
+        if lexer_alias is None:
+            for pattern, lex_alias in pattern_cache.items():
+                if fnmatch.fnmatch(basename, pattern):
+                    lexer_alias = lex_alias
+                    break
+
+        # Get lexer or use TextLexer as fallback
+        # Note: stripall=False preserves leading whitespace (important for code indentation)
+        if lexer_alias:
+            lexer = get_lexer_by_name(lexer_alias, stripall=False)  # type: ignore[reportUnknownVariableType]
+        else:
+            lexer = TextLexer()  # type: ignore[reportUnknownVariableType]
+    except ClassNotFound:
+        # Fall back to plain text lexer
+        lexer = TextLexer()  # type: ignore[reportUnknownVariableType]
+
+    # Create formatter with line numbers in table format
+    formatter = HtmlFormatter(  # type: ignore[reportUnknownVariableType]
+        linenos="table" if show_linenos else False,
+        cssclass="highlight",
+        wrapcode=True,
+        linenostart=linenostart,
+    )
+
+    # Highlight the code with timing if enabled
+    with timing_stat("_pygments_timings"):
+        return str(highlight(code, lexer, formatter))  # type: ignore[reportUnknownArgumentType]
+
+
+def truncate_highlighted_preview(highlighted_html: str, max_lines: int) -> str:
+    """Truncate Pygments highlighted HTML to first N lines.
+
+    HtmlFormatter(linenos="table") produces a single  with two s:
+      
LINE_NUMS
+
CODE
+ + We truncate content within each
 tag to the first max_lines lines.
+
+    Args:
+        highlighted_html: Full Pygments-highlighted HTML
+        max_lines: Maximum number of lines to include in preview
+
+    Returns:
+        Truncated HTML with same structure but fewer lines
+    """
+
+    def truncate_pre_content(match: re.Match[str]) -> str:
+        """Truncate content inside a 
 tag to max_lines."""
+        prefix, content, suffix = match.groups()
+        lines = content.split("\n")
+        truncated = "\n".join(lines[:max_lines])
+        return prefix + truncated + suffix
+
+    # Truncate linenos 
 content (line numbers separated by newlines)
+    result = re.sub(
+        r'(
)(.*?)(
)', + truncate_pre_content, + highlighted_html, + flags=re.DOTALL, + ) + + # Truncate code
 content
+    result = re.sub(
+        r'(
]*>)(.*?)(
)', + truncate_pre_content, + result, + flags=re.DOTALL, + ) + + return result + + +def render_line_diff( + old_line: str, new_line: str, escape_fn: Optional[Callable[[str], str]] = None +) -> str: + """Render a pair of changed lines with character-level highlighting. + + Args: + old_line: The original line + new_line: The new line + escape_fn: Optional HTML escape function (defaults to internal _escape_html) + + Returns: + HTML string with both lines and character-level diff highlighting + """ + if escape_fn is None: + escape_fn = _escape_html + + # Use SequenceMatcher for character-level diff + sm = difflib.SequenceMatcher(None, old_line.rstrip("\n"), new_line.rstrip("\n")) + + # Build old line with highlighting + old_parts: List[str] = [] + old_parts.append( + "
-" + ) + for tag, i1, i2, j1, j2 in sm.get_opcodes(): + chunk = old_line[i1:i2] + if tag == "equal": + old_parts.append(escape_fn(chunk)) + elif tag in ("delete", "replace"): + old_parts.append( + f"{escape_fn(chunk)}" + ) + old_parts.append("
") + + # Build new line with highlighting + new_parts: List[str] = [] + new_parts.append( + "
+" + ) + for tag, i1, i2, j1, j2 in sm.get_opcodes(): + chunk = new_line[j1:j2] + if tag == "equal": + new_parts.append(escape_fn(chunk)) + elif tag in ("insert", "replace"): + new_parts.append(f"{escape_fn(chunk)}") + new_parts.append("
") + + return "".join(old_parts) + "".join(new_parts) + + +def render_single_diff( + old_string: str, new_string: str, escape_fn: Optional[Callable[[str], str]] = None +) -> str: + """Render a single diff between old_string and new_string. + + Args: + old_string: The original content + new_string: The new content + escape_fn: Optional HTML escape function (defaults to internal _escape_html) + + Returns: + HTML string with diff view and intra-line highlighting + """ + if escape_fn is None: + escape_fn = _escape_html + + # Split into lines for diff + old_lines = old_string.splitlines(keepends=True) + new_lines = new_string.splitlines(keepends=True) + + # Generate unified diff to identify changed lines + differ = difflib.Differ() + diff: List[str] = list(differ.compare(old_lines, new_lines)) + + html_parts = ["
"] + + i = 0 + while i < len(diff): + line = diff[i] + prefix = line[0:2] + content = line[2:] + + if prefix == "- ": + # Removed line - look ahead for corresponding addition + removed_lines: List[str] = [content] + j = i + 1 + + # Collect consecutive removed lines + while j < len(diff) and diff[j].startswith("- "): + removed_lines.append(diff[j][2:]) + j += 1 + + # Skip '? ' hint lines + while j < len(diff) and diff[j].startswith("? "): + j += 1 + + # Collect consecutive added lines + added_lines: List[str] = [] + while j < len(diff) and diff[j].startswith("+ "): + added_lines.append(diff[j][2:]) + j += 1 + + # Skip '? ' hint lines + while j < len(diff) and diff[j].startswith("? "): + j += 1 + + # Generate character-level diff for paired lines + if added_lines: + for old_line, new_line in zip(removed_lines, added_lines): + html_parts.append(render_line_diff(old_line, new_line, escape_fn)) + + # Handle any unpaired lines + for old_line in removed_lines[len(added_lines) :]: + escaped = escape_fn(old_line.rstrip("\n")) + html_parts.append( + f"
-{escaped}
" + ) + + for new_line in added_lines[len(removed_lines) :]: + escaped = escape_fn(new_line.rstrip("\n")) + html_parts.append( + f"
+{escaped}
" + ) + else: + # No corresponding addition - just removed + for old_line in removed_lines: + escaped = escape_fn(old_line.rstrip("\n")) + html_parts.append( + f"
-{escaped}
" + ) + + i = j + + elif prefix == "+ ": + # Added line without corresponding removal + escaped = escape_fn(content.rstrip("\n")) + html_parts.append( + f"
+{escaped}
" + ) + i += 1 + + elif prefix == "? ": + # Skip hint lines (already processed) + i += 1 + + else: + # Unchanged line - show for context + escaped = escape_fn(content.rstrip("\n")) + html_parts.append( + f"
{escaped}
" + ) + i += 1 + + html_parts.append("
") + return "".join(html_parts) diff --git a/dev-docs/MESSAGE_REFACTORING.md b/dev-docs/MESSAGE_REFACTORING.md new file mode 100755 index 00000000..34ada8c7 --- /dev/null +++ b/dev-docs/MESSAGE_REFACTORING.md @@ -0,0 +1,308 @@ +# Message Rendering Refactoring Plan + +This document tracks the ongoing refactoring effort to improve the message rendering code in `renderer.py`. + +## Current State (dev/message-tree-refactoring) + +As of December 2024, `renderer.py` has grown to **4246 lines** with several new subsystems: + +| Function/System | Lines | Notes | +|-----------------|-------|-------| +| `_process_messages_loop()` | ~687 | Main message processing - needs decomposition | +| `_convert_ansi_to_html()` | ~252 | Self-contained, could be extracted | +| `_identify_message_pairs()` | ~227 | Complex pairing logic | +| `_reorder_paired_messages()` | ~104 | Pair reordering | +| Hierarchy system | ~150 | `_build_message_hierarchy`, `_mark_messages_with_children` | +| Tree building | ~60 | `_build_message_tree()` - NEW: builds children hierarchy | +| Tool formatters | ~600 | Various `format_*_tool_content` functions | + +**New systems added since initial plan:** +- **Message pairing** - System command + slash-command, tool use + result +- **Hierarchy/fold system** - Level-based ancestry for fold/unfold UI +- **Message processors** - `_process_command_message`, `_process_bash_input`, etc. +- **ANSI color conversion** - Full terminal color to HTML support +- **Message tree** - `_build_message_tree()` and `TemplateMessage.flatten()` methods (Phase 1-2 of TEMPLATE_MESSAGE_CHILDREN.md) + +## Motivation + +The refactoring aims to: + +1. **Improve maintainability** - Functions are too large (some 600+ lines) +2. **Better separation of concerns** - Move specialized utilities to dedicated modules +3. **Improve type safety** - Use typed objects instead of generic dictionaries +4. **Enable testing** - Large functions are difficult to unit test +5. **Performance profiling** - Timing instrumentation to identify bottlenecks + +## Related Refactoring Branches + +### dev/message-tree-refactoring (Current Branch) + +This branch builds the foundation for tree-based message rendering. See [TEMPLATE_MESSAGE_CHILDREN.md](TEMPLATE_MESSAGE_CHILDREN.md) for details. + +**Completed Work:** +- ✅ Phase 1: Added `children: List[TemplateMessage]` field to TemplateMessage +- ✅ Phase 1: Added `flatten()` and `flatten_all()` methods for backward compatibility +- ✅ Phase 2: Implemented `_build_message_tree()` function +- ✅ Phase 2: Tree is built after hierarchy processing but flat list still used for templates + +**Integration with MESSAGE_REFACTORING.md:** +- The tree structure enables future **recursive template rendering** (Phase 3 in TEMPLATE_MESSAGE_CHILDREN.md) +- Provides foundation for **Visitor pattern** output formats (HTML, Markdown, JSON) +- `flatten_all()` ensures backward compatibility during migration + +### golergka's text-output-format Branch (ada7ef5) + +Adds text/markdown/chat output formats via new `content_extractor.py` module. + +**Key Changes:** +- Created `content_extractor.py` with dataclasses: `ExtractedText`, `ExtractedThinking`, `ExtractedToolUse`, `ExtractedToolResult`, `ExtractedImage` +- Refactored `render_message_content()` to use extraction layer (~70 lines changed) +- Added `text_renderer.py` for text-based output (426 lines) +- CLI `--format` option: html, text, markdown, chat + +**Relationship to This Refactoring:** + +| Aspect | golergka's Approach | This Refactoring | +|--------|---------------------|------------------| +| Focus | Multi-format output | Code organization | +| Data layer | ContentItem → ExtractedContent | TemplateMessage tree | +| Presentation | Separate renderers per format | Modular HTML renderer | +| Compatibility | Parallel to HTML | Refactor existing HTML | + +**Integration Assessment:** +- **Complementary**: golergka's extraction layer operates at ContentItem level, this refactoring at TemplateMessage level +- **Low conflict**: `content_extractor.py` is a new module, doesn't touch hierarchy/pairing code +- **Synergy opportunity**: Text renderer could benefit from tree structure for nested output +- **Risk**: `render_message_content()` changes in golergka's PR conflict with local changes + +**Recommendation:** Consider integrating golergka's work **after** completing Phase 3 (ANSI extraction) and Phase 4 (Tool formatters extraction). The content extraction layer is useful for multi-format support, but is tangential to the core refactoring goals of reducing renderer.py complexity. + +## Completed Phases + +### Phase 1: Timing Infrastructure (Commits: 56b2807, 8426f39) + +**Goal**: Centralize timing utilities and standardize timing instrumentation patterns + +**Changes**: +- ✅ Extracted timing utilities to `renderer_timings.py` module +- ✅ Moved `DEBUG_TIMING` environment variable handling to timing module +- ✅ Standardized `log_timing` context manager pattern - work goes INSIDE the `with` block +- ✅ Added support for dynamic phase names using lambda expressions +- ✅ Removed top-level `os` import from renderer.py (no longer needed) + +**Benefits**: +- All timing-related code centralized in one module +- Consistent timing instrumentation throughout renderer +- Easy to enable/disable timing with `CLAUDE_CODE_LOG_DEBUG_TIMING` environment variable +- Better insight into rendering performance + +### Phase 2: Tool Use Context Optimization (Commit: 56b2807) + +**Goal**: Simplify tool use context management and eliminate unnecessary pre-processing + +**Analysis**: +- `tool_use_context` was only used when processing tool results +- The "prompt" member stored for Task tools wasn't actually used in lookups +- Tool uses always appear before tool results chronologically +- No need for separate pre-processing pass + +**Changes**: +- ✅ Removed `_define_tool_use_context()` function (68 lines eliminated) +- ✅ Changed `tool_use_context` from `Dict[str, Dict[str, Any]]` to `Dict[str, ToolUseContent]` +- ✅ Build index inline when creating ToolUseContent objects during message processing +- ✅ Use attribute access instead of dictionary access for better type safety +- ✅ Replaced dead code in `render_message_content` with warnings + +**Benefits**: +- Eliminated entire pre-processing pass through messages +- Better type safety with ToolUseContent objects +- Cleaner code with inline index building +- ~70 lines of code removed + +### Phase 3: ANSI Color Module Extraction ✅ COMPLETE + +**Goal**: Extract ANSI color conversion to dedicated module + +**Changes**: +- ✅ Created `claude_code_log/ansi_colors.py` (261 lines) +- ✅ Moved `_convert_ansi_to_html()` → `convert_ansi_to_html()` +- ✅ Updated imports in `renderer.py` +- ✅ Updated test imports in `test_ansi_colors.py` + +**Result**: 242 lines removed from renderer.py (4246 → 4004) + +### Phase 4: Code Rendering Module Extraction ✅ COMPLETE + +**Goal**: Extract code-related rendering (Pygments highlighting, diff rendering) to dedicated module + +**Changes**: +- ✅ Created `claude_code_log/renderer_code.py` (330 lines) +- ✅ Moved `_highlight_code_with_pygments()` → `highlight_code_with_pygments()` +- ✅ Moved `_truncate_highlighted_preview()` → `truncate_highlighted_preview()` +- ✅ Moved `_render_single_diff()` → `render_single_diff()` +- ✅ Moved `_render_line_diff()` → `render_line_diff()` +- ✅ Updated imports in `renderer.py` +- ✅ Updated test imports in `test_preview_truncation.py` +- ✅ Removed unused Pygments imports from renderer.py + +**Result**: 274 lines removed from renderer.py (4004 → 3730) + +**Note**: The original Phase 4 plan targeted tool formatters (~600 lines), but due to tight coupling with `escape_html`, `render_markdown`, and other utilities, we extracted a cleaner subset: code highlighting and diff rendering. The remaining tool formatters could be extracted in a future phase once the shared utilities are better factored. + +## Planned Future Phases + +### Phase 5: Message Processing Decomposition + +**Goal**: Break down the 687-line `_process_messages_loop()` into smaller functions + +**Current Structure** (lines 3400-4086): +``` +_process_messages_loop() +├── Session header creation +├── Message type detection (user/assistant/system/summary) +├── Content extraction and rendering +├── Tool use context building +├── CSS class determination +├── TemplateMessage creation +├── Message processors (_process_command_message, etc.) +└── Stats accumulation +``` + +**Proposed Decomposition**: +1. **`_create_session_header()`** - Session header TemplateMessage creation +2. **`_process_user_message()`** - User message handling +3. **`_process_assistant_message()`** - Assistant message with tool use extraction +4. **`_process_system_message()`** - System message (commands, errors, info) +5. **`_process_summary_message()`** - Summary handling +6. **Message type router** - Dispatch to appropriate processor + +**Key Insight**: The current processors (`_process_command_message`, `_process_bash_input`, etc.) return `(header, content, css_class, border_color)` tuples. Consider using a dataclass: + +```python +@dataclass +class ProcessedContent: + header: str + content: str + css_class: str + border_color: str + preview_content: str = "" + additional_css: str = "" +``` + +**Expected Result**: `_process_messages_loop()` reduced to ~200 lines of orchestration + +### Phase 6: Message Pairing Simplification + +**Goal**: Simplify the complex pairing logic in `_identify_message_pairs()` + +**Current Complexity** (227 lines): +- Multiple pairing strategies (tool use/result, command/output, system/slash) +- Nested conditionals for edge cases +- Magic string matching for message content + +**Proposed Changes**: +1. Create explicit `PairingStrategy` classes: + - `ToolUsePairingStrategy` - tool_use_id matching + - `ParentUuidPairingStrategy` - parentUuid linking + - `ContentMatchPairingStrategy` - content-based matching (command output) +2. Apply strategies in sequence +3. Better documentation of pairing rules + +**Alternative**: If pairing logic is stable, leave as-is and focus on other phases first. + +### Phase 7: Hierarchy System Documentation + +**Goal**: Document the hierarchy/fold system architecture + +**Current Functions**: +- `_get_message_hierarchy_level()` - Level from CSS class (simplified in v0.9) +- `_build_message_hierarchy()` - Ancestry building +- `_mark_messages_with_children()` - Descendant counting + +**Document**: +- Level definitions (0=session, 1=user, 2=assistant/system, 3=tools) +- Ancestry calculation for fold/unfold +- Interaction with JavaScript fold controls +- Edge cases (sidechain, paired messages) + +**Location**: `dev-docs/FOLD_STATE_DIAGRAM.md` (update existing) + +### Phase 8: Testing Infrastructure + +**Goal**: Improve test coverage for refactored modules + +**Current Coverage**: ~78% + +**Priority Tests**: +1. Unit tests for extracted ANSI module +2. Unit tests for tool formatters with edge cases +3. Integration tests for message pairing +4. Property-based tests for hierarchy calculation +5. Snapshot tests for new message types + +**Test Data**: +- Add more representative JSONL samples to `test/test_data/` +- Create fixtures for common message patterns + +## Recommended Execution Order + +For maximum impact with minimum risk: + +1. **Phase 3 (ANSI)** - Low risk, self-contained, immediate ~250 line reduction +2. **Phase 4 (Tools)** - Medium risk, large reduction (~600 lines), clear boundaries +3. **Phase 7 (Docs)** - No code changes, improves understanding for Phase 5-6 +4. **Phase 5 (Processing)** - High impact, requires careful testing +5. **Phase 6 (Pairing)** - Only if pairing bugs persist; otherwise skip +6. **Phase 8 (Testing)** - Ongoing, add tests as modules are extracted + +**Tree Refactoring Integration:** +- Tree building (TEMPLATE_MESSAGE_CHILDREN.md Phase 1-2) is complete and non-blocking +- Template migration (Phase 3) should wait until after Phase 4 (Tools) here +- golergka's text formats can be integrated after Phase 4, leveraging both extraction layers + +**golergka Integration Timing:** +- Wait until Phase 3-4 complete to minimize merge conflicts +- When integrating, resolve `render_message_content()` conflicts carefully +- Consider whether tree structure benefits text renderer + +## Metrics to Track + +| Metric | Baseline (v0.9) | Current (Phase 3-4 done) | Target | +|--------|-----------------|-------------------------|--------| +| renderer.py lines | 4246 | 3730 | <3000 | +| Largest function | ~687 lines | ~687 lines | <100 lines | +| Module count | 3 (renderer, timings, models) | 5 (+ansi_colors, +renderer_code) | 6-7 | +| Test coverage | ~78% | ~78% | >85% | + +**Progress**: 516 lines extracted from renderer.py (12% reduction) + +## Quality Gates + +Before merging any phase: + +- [ ] `just test-all` passes +- [ ] `uv run pyright` passes with 0 errors +- [ ] `ruff check` passes +- [ ] Snapshot tests unchanged (or intentionally updated) +- [ ] No performance regression (check with `CLAUDE_CODE_LOG_DEBUG_TIMING=1`) + +## Notes + +- All changes should maintain backward compatibility +- Each phase should be committed separately for easy review +- Consider feature flags for large changes during development +- Run against real Claude projects to verify visual correctness + +## References + +- [renderer.py](../claude_code_log/renderer.py) - Main rendering module (3730 lines) +- [ansi_colors.py](../claude_code_log/ansi_colors.py) - ANSI color conversion (261 lines) - Phase 3 +- [renderer_code.py](../claude_code_log/renderer_code.py) - Code highlighting & diffs (330 lines) - Phase 4 +- [renderer_timings.py](../claude_code_log/renderer_timings.py) - Timing utilities +- [test/test_ansi_colors.py](../test/test_ansi_colors.py) - ANSI tests +- [test/test_preview_truncation.py](../test/test_preview_truncation.py) - Code preview tests +- [test/test_sidechain_agents.py](../test/test_sidechain_agents.py) - Integration tests +- [dev-docs/FOLD_STATE_DIAGRAM.md](FOLD_STATE_DIAGRAM.md) - Fold system documentation +- [dev-docs/TEMPLATE_MESSAGE_CHILDREN.md](TEMPLATE_MESSAGE_CHILDREN.md) - Tree architecture exploration +- [test/test_template_data.py](../test/test_template_data.py) - Tree building tests (TestTemplateMessageTree) +- golergka's branch: `remotes/golergka/feat/text-output-format` (commit ada7ef5) diff --git a/test/test_ansi_colors.py b/test/test_ansi_colors.py index 99c7e60f..cdf9b474 100644 --- a/test/test_ansi_colors.py +++ b/test/test_ansi_colors.py @@ -1,6 +1,6 @@ """Tests for ANSI color code conversion to HTML.""" -from claude_code_log.renderer import _convert_ansi_to_html +from claude_code_log.ansi_colors import convert_ansi_to_html class TestAnsiColorConversion: @@ -9,55 +9,55 @@ class TestAnsiColorConversion: def test_standard_colors(self): """Test standard ANSI color codes.""" text = "\x1b[31mRed text\x1b[0m" - result = _convert_ansi_to_html(text) + result = convert_ansi_to_html(text) assert 'Red text' in result text = "\x1b[32mGreen\x1b[0m and \x1b[34mBlue\x1b[0m" - result = _convert_ansi_to_html(text) + result = convert_ansi_to_html(text) assert 'Green' in result assert 'Blue' in result def test_bright_colors(self): """Test bright ANSI color codes.""" text = "\x1b[91mBright red\x1b[0m" - result = _convert_ansi_to_html(text) + result = convert_ansi_to_html(text) assert 'Bright red' in result def test_background_colors(self): """Test background color codes.""" text = "\x1b[41mRed background\x1b[0m" - result = _convert_ansi_to_html(text) + result = convert_ansi_to_html(text) assert 'Red background' in result def test_text_styles(self): """Test text style codes (bold, italic, etc).""" text = "\x1b[1mBold text\x1b[0m" - result = _convert_ansi_to_html(text) + result = convert_ansi_to_html(text) assert 'Bold text' in result text = "\x1b[3mItalic\x1b[0m and \x1b[4mUnderline\x1b[0m" - result = _convert_ansi_to_html(text) + result = convert_ansi_to_html(text) assert 'Italic' in result assert 'Underline' in result def test_rgb_colors(self): """Test RGB color codes.""" text = "\x1b[38;2;255;0;0mRGB Red\x1b[0m" - result = _convert_ansi_to_html(text) + result = convert_ansi_to_html(text) assert 'style="color: rgb(255, 0, 0)"' in result text = "\x1b[48;2;0;255;0mRGB Green Background\x1b[0m" - result = _convert_ansi_to_html(text) + result = convert_ansi_to_html(text) assert 'style="background-color: rgb(0, 255, 0)"' in result def test_combined_styles(self): """Test combinations of colors and styles.""" text = "\x1b[1;31mBold Red\x1b[0m" - result = _convert_ansi_to_html(text) + result = convert_ansi_to_html(text) assert 'class="ansi-red ansi-bold"' in result text = "\x1b[4;34;43mUnderlined Blue on Yellow\x1b[0m" - result = _convert_ansi_to_html(text) + result = convert_ansi_to_html(text) assert "ansi-blue" in result assert "ansi-bg-yellow" in result assert "ansi-underline" in result @@ -65,17 +65,17 @@ def test_combined_styles(self): def test_reset_codes(self): """Test various reset codes.""" text = "\x1b[31mRed\x1b[39m Normal" - result = _convert_ansi_to_html(text) + result = convert_ansi_to_html(text) assert 'Red Normal' in result text = "\x1b[1mBold\x1b[22m Normal" - result = _convert_ansi_to_html(text) + result = convert_ansi_to_html(text) assert 'Bold Normal' in result def test_html_escaping(self): """Test that HTML special characters are escaped.""" text = "\x1b[31m\x1b[0m" - result = _convert_ansi_to_html(text) + result = convert_ansi_to_html(text) assert "<script>" in result assert "</script>" in result assert " diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index b84f2ece..dcca563f 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -2353,17 +2353,14 @@ } /* Paired message styling */ - .message.paired-message { + .message.pair_first { margin-bottom: 0; - } - - .message.paired-message.pair_first { border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-bottom: none; } - .message.paired-message.pair_last { + .message.pair_last { margin-top: 0; margin-bottom: 1em; border-top-left-radius: 0; @@ -2371,8 +2368,9 @@ border-top: 1px solid #00000011; } - .message.paired-message.pair_middle { + .message.pair_middle { margin-top: 0; + margin-bottom: 0; border-radius: 0; border-top: 1px solid #00000011; border-bottom: none; @@ -2405,7 +2403,7 @@ } /* Dimmed assistant when paired with thinking */ - .assistant.paired-message { + .assistant.pair_last { border-left-color: var(--assistant-dimmed); } @@ -2639,7 +2637,7 @@ } /* Full purple when thinking is paired (as pair_first) */ - .thinking.paired-message.pair_first { + .thinking.pair_first { border-left-color: var(--assistant-color); } @@ -4938,7 +4936,7 @@ -
+
📝 Edit /tmp/decorator_example.py
@@ -4955,7 +4953,7 @@ -
+
@@ -5031,7 +5029,7 @@ -
+
🛠️ Bash Run the decorator example to show output
@@ -5048,7 +5046,7 @@ -
+
@@ -7083,17 +7081,14 @@ } /* Paired message styling */ - .message.paired-message { + .message.pair_first { margin-bottom: 0; - } - - .message.paired-message.pair_first { border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-bottom: none; } - .message.paired-message.pair_last { + .message.pair_last { margin-top: 0; margin-bottom: 1em; border-top-left-radius: 0; @@ -7101,8 +7096,9 @@ border-top: 1px solid #00000011; } - .message.paired-message.pair_middle { + .message.pair_middle { margin-top: 0; + margin-bottom: 0; border-radius: 0; border-top: 1px solid #00000011; border-bottom: none; @@ -7135,7 +7131,7 @@ } /* Dimmed assistant when paired with thinking */ - .assistant.paired-message { + .assistant.pair_last { border-left-color: var(--assistant-dimmed); } @@ -7369,7 +7365,7 @@ } /* Full purple when thinking is paired (as pair_first) */ - .thinking.paired-message.pair_first { + .thinking.pair_first { border-left-color: var(--assistant-color); } @@ -9691,7 +9687,7 @@ -
+
🛠️ FailingTool
@@ -9713,7 +9709,7 @@ -
+
🚨 Error
@@ -9730,7 +9726,7 @@ -
+
⚙️ System
@@ -9749,7 +9745,7 @@ -
+
System
@@ -11915,17 +11911,14 @@ } /* Paired message styling */ - .message.paired-message { + .message.pair_first { margin-bottom: 0; - } - - .message.paired-message.pair_first { border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-bottom: none; } - .message.paired-message.pair_last { + .message.pair_last { margin-top: 0; margin-bottom: 1em; border-top-left-radius: 0; @@ -11933,8 +11926,9 @@ border-top: 1px solid #00000011; } - .message.paired-message.pair_middle { + .message.pair_middle { margin-top: 0; + margin-bottom: 0; border-radius: 0; border-top: 1px solid #00000011; border-bottom: none; @@ -11967,7 +11961,7 @@ } /* Dimmed assistant when paired with thinking */ - .assistant.paired-message { + .assistant.pair_last { border-left-color: var(--assistant-dimmed); } @@ -12201,7 +12195,7 @@ } /* Full purple when thinking is paired (as pair_first) */ - .thinking.paired-message.pair_first { + .thinking.pair_first { border-left-color: var(--assistant-color); } @@ -14638,7 +14632,7 @@ -
+
📝 Edit /tmp/decorator_example.py
@@ -14655,7 +14649,7 @@ -
+
@@ -14731,7 +14725,7 @@ -
+
🛠️ Bash Run the decorator example to show output
@@ -14748,7 +14742,7 @@ -
+
@@ -16783,17 +16777,14 @@ } /* Paired message styling */ - .message.paired-message { + .message.pair_first { margin-bottom: 0; - } - - .message.paired-message.pair_first { border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-bottom: none; } - .message.paired-message.pair_last { + .message.pair_last { margin-top: 0; margin-bottom: 1em; border-top-left-radius: 0; @@ -16801,8 +16792,9 @@ border-top: 1px solid #00000011; } - .message.paired-message.pair_middle { + .message.pair_middle { margin-top: 0; + margin-bottom: 0; border-radius: 0; border-top: 1px solid #00000011; border-bottom: none; @@ -16835,7 +16827,7 @@ } /* Dimmed assistant when paired with thinking */ - .assistant.paired-message { + .assistant.pair_last { border-left-color: var(--assistant-dimmed); } @@ -17069,7 +17061,7 @@ } /* Full purple when thinking is paired (as pair_first) */ - .thinking.paired-message.pair_first { + .thinking.pair_first { border-left-color: var(--assistant-color); } @@ -19368,7 +19360,7 @@ -
+
📝 Edit /tmp/decorator_example.py
@@ -19385,7 +19377,7 @@ -
+
@@ -19461,7 +19453,7 @@ -
+
🛠️ Bash Run the decorator example to show output
@@ -19478,7 +19470,7 @@ -
+
diff --git a/test/test_template_rendering.py b/test/test_template_rendering.py index 0a10afbd..b16d1a7b 100644 --- a/test/test_template_rendering.py +++ b/test/test_template_rendering.py @@ -254,7 +254,7 @@ def test_css_classes_applied(self): # Summary messages are now integrated into session headers assert "session-summary" in html_content or "Summary:" in html_content - # Check tool message classes (tools are now top-level messages, may include paired-message class) + # Check tool message classes (tools are now top-level messages, may include pair_first/pair_last classes) assert "tool_use" in html_content and "class='message" in html_content assert "tool_result" in html_content and "class='message" in html_content From 9f60776f8fefbd445024832336433db5a5f17630 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 7 Dec 2025 12:16:32 +0100 Subject: [PATCH 017/102] Mark Phase 8 (Testing Infrastructure) complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 8 added: - test_phase8_message_variants.py: slash command, queue ops, CSS modifiers - test_renderer.py: system message and tool rendering edge cases - test_renderer_code.py: Pygments highlighting and diff rendering tests - CSS simplification: removed redundant paired-message class 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- dev-docs/MESSAGE_REFACTORING.md | 46 +++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/dev-docs/MESSAGE_REFACTORING.md b/dev-docs/MESSAGE_REFACTORING.md index cbcc7521..32d4a96d 100755 --- a/dev-docs/MESSAGE_REFACTORING.md +++ b/dev-docs/MESSAGE_REFACTORING.md @@ -203,25 +203,34 @@ Adds text/markdown/chat output formats via new `content_extractor.py` module. - Full tool table (16 tools with model info) - Cross-references to css-classes.md -### Phase 8: Testing Infrastructure +### Phase 8: Testing Infrastructure ✅ COMPLETE **Goal**: Improve test coverage for refactored modules -**Current Coverage**: ~78% - -**Priority Tests**: -1. Unit tests for extracted ANSI module -2. Unit tests for tool formatters with edge cases -3. Integration tests for message pairing -4. Property-based tests for hierarchy calculation -5. Snapshot tests for new message types -6. Tests for `is_meta` flag (slash command rendering) - currently no coverage -7. Tests for queue operations skip behavior -8. Edge case tests for css_class composition (multiple modifiers) - -**Test Data**: -- Add more representative JSONL samples to `test/test_data/` -- Create fixtures for common message patterns +**Completed Work**: +- ✅ Created `test/test_phase8_message_variants.py` with tests for: + - Slash command rendering (`isMeta=True` flag) + - Queue operations skip behavior (enqueue/dequeue not rendered) + - CSS class modifiers composition (`error`, `sidechain`, combinations) + - Deduplication with modifiers +- ✅ Created `test/test_renderer.py` with edge case tests for: + - System message handling + - Write and Edit tool rendering +- ✅ Created `test/test_renderer_code.py` with tests for: + - Pygments highlighting (pattern matching, unknown extensions, ClassNotFound) + - Truncated highlighted preview + - Diff rendering edge cases (consecutive removals, hint line skipping) +- ✅ Simplified CSS by removing redundant `paired-message` class +- ✅ Updated snapshot tests and documentation + +**Test Files Added**: +- [test/test_phase8_message_variants.py](../test/test_phase8_message_variants.py) - Message type variants +- [test/test_renderer.py](../test/test_renderer.py) - Renderer edge cases +- [test/test_renderer_code.py](../test/test_renderer_code.py) - Code highlighting/diff tests + +**Coverage Notes**: +- Some lines in `renderer_code.py` (116-118, 319) are unreachable due to algorithm behavior +- Pygments `ClassNotFound` exception path covered via mock testing ### Phase 9: Type Safety Improvements @@ -402,9 +411,9 @@ For maximum impact with minimum risk: 3. ✅ **Phase 5 (Processing)** - High impact, main loop 33% smaller 4. ✅ **Phase 6 (Pairing)** - Pairing function 69% smaller, clear helpers 5. ✅ **Phase 7 (Documentation)** - Complete CSS/message docs +6. ✅ **Phase 8 (Testing)** - Coverage gap tests, message variant tests, CSS simplification ### Next Steps -6. **Phase 8 (Testing)** - Ongoing, add tests as modules are extracted 7. **Phase 9 (Type Safety)** - Incremental, can start with MessageType enum 8. **Phase 10 (Parser)** - Low risk, tested simplification 9. **Phase 11 (Tool Models)** - Lower priority, current approach works @@ -470,6 +479,9 @@ Before merging any phase: - [test/test_preview_truncation.py](../test/test_preview_truncation.py) - Code preview tests - [test/test_sidechain_agents.py](../test/test_sidechain_agents.py) - Integration tests - [test/test_template_data.py](../test/test_template_data.py) - Tree building tests (TestTemplateMessageTree) +- [test/test_phase8_message_variants.py](../test/test_phase8_message_variants.py) - Phase 8: Message variants +- [test/test_renderer.py](../test/test_renderer.py) - Phase 8: Renderer edge cases +- [test/test_renderer_code.py](../test/test_renderer_code.py) - Phase 8: Code highlighting/diff tests ### External - golergka's branch: `remotes/golergka/feat/text-output-format` (commit ada7ef5) From 070ac15d9d3a76fc9837c31bb7b1685c2f2458f0 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 7 Dec 2025 12:25:11 +0100 Subject: [PATCH 018/102] Add MessageType enum and type guards for type safety (Phase 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 9 introduces type safety improvements: - Add MessageType(str, Enum) with all JSONL entry types and rendering types - Add type guards for TranscriptEntry union narrowing (for future use) - Update renderer.py to use MessageType enum instead of string literals - Maintain backward compatibility via str enum base class The enum covers: - JSONL entry types: USER, ASSISTANT, SYSTEM, SUMMARY, QUEUE_OPERATION - Rendering types: TOOL_USE, TOOL_RESULT, THINKING, IMAGE, etc. - System subtypes: SYSTEM_INFO, SYSTEM_WARNING, SYSTEM_ERROR 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/models.py | 102 +++++++++++++++++++++++++++++++++++- claude_code_log/renderer.py | 34 +++++++----- 2 files changed, 121 insertions(+), 15 deletions(-) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 8b40c10a..5b4ec2ec 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -3,13 +3,52 @@ Enhanced to leverage official Anthropic types where beneficial. """ -from typing import Any, List, Union, Optional, Dict, Literal, cast -from pydantic import BaseModel +from enum import Enum +from typing import Any, List, Union, Optional, Dict, Literal, cast, TypeGuard from anthropic.types import Message as AnthropicMessage from anthropic.types import StopReason from anthropic.types import Usage as AnthropicUsage from anthropic.types.content_block import ContentBlock +from pydantic import BaseModel + + +class MessageType(str, Enum): + """Primary message type classification. + + This enum covers both JSONL entry types and rendering types. + Using str as base class maintains backward compatibility with string comparisons. + + JSONL Entry Types (from transcript files): + - USER, ASSISTANT, SYSTEM, SUMMARY, QUEUE_OPERATION + + Rendering Types (derived during processing): + - TOOL_USE, TOOL_RESULT, THINKING, IMAGE + - BASH_INPUT, BASH_OUTPUT + - SESSION_HEADER, UNKNOWN + """ + + # JSONL entry types + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" + SUMMARY = "summary" + QUEUE_OPERATION = "queue-operation" + + # Rendering/display types (derived from content) + TOOL_USE = "tool_use" + TOOL_RESULT = "tool_result" + THINKING = "thinking" + IMAGE = "image" + BASH_INPUT = "bash-input" + BASH_OUTPUT = "bash-output" + SESSION_HEADER = "session-header" + UNKNOWN = "unknown" + + # System subtypes (for css_class) + SYSTEM_INFO = "system-info" + SYSTEM_WARNING = "system-warning" + SYSTEM_ERROR = "system-error" class TodoItem(BaseModel): @@ -437,3 +476,62 @@ def parse_transcript_entry(data: Dict[str, Any]) -> TranscriptEntry: else: raise ValueError(f"Unknown transcript entry type: {entry_type}") + + +# Type guards for TranscriptEntry union narrowing +# These enable type-safe access to entry-specific fields after type checking + + +def is_user_entry(entry: TranscriptEntry) -> TypeGuard[UserTranscriptEntry]: + """Check if entry is a user transcript entry.""" + return entry.type == MessageType.USER + + +def is_assistant_entry(entry: TranscriptEntry) -> TypeGuard[AssistantTranscriptEntry]: + """Check if entry is an assistant transcript entry.""" + return entry.type == MessageType.ASSISTANT + + +def is_system_entry(entry: TranscriptEntry) -> TypeGuard[SystemTranscriptEntry]: + """Check if entry is a system transcript entry.""" + return entry.type == MessageType.SYSTEM + + +def is_summary_entry(entry: TranscriptEntry) -> TypeGuard[SummaryTranscriptEntry]: + """Check if entry is a summary transcript entry.""" + return entry.type == MessageType.SUMMARY + + +def is_queue_operation_entry( + entry: TranscriptEntry, +) -> TypeGuard[QueueOperationTranscriptEntry]: + """Check if entry is a queue operation transcript entry.""" + return entry.type == MessageType.QUEUE_OPERATION + + +# Content item type guards + + +def is_tool_use_content(item: ContentItem) -> TypeGuard[ToolUseContent]: + """Check if content item is a tool use.""" + return getattr(item, "type", None) == "tool_use" + + +def is_tool_result_content(item: ContentItem) -> TypeGuard[ToolResultContent]: + """Check if content item is a tool result.""" + return getattr(item, "type", None) == "tool_result" + + +def is_thinking_content(item: ContentItem) -> TypeGuard[ThinkingContent]: + """Check if content item is thinking content.""" + return getattr(item, "type", None) == "thinking" + + +def is_image_content(item: ContentItem) -> TypeGuard[ImageContent]: + """Check if content item is an image.""" + return getattr(item, "type", None) == "image" + + +def is_text_content(item: ContentItem) -> TypeGuard[TextContent]: + """Check if content item is text content.""" + return getattr(item, "type", None) == "text" diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index f63593e9..888ab2b4 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -16,6 +16,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape from .models import ( + MessageType, TranscriptEntry, AssistantTranscriptEntry, UserTranscriptEntry, @@ -1372,7 +1373,7 @@ def render_message_content(content: List[ContentItem], message_type: str) -> str compacted session summaries. Those should be handled by render_user_message_content. """ if len(content) == 1 and isinstance(content[0], TextContent): - if message_type == "user": + if message_type == MessageType.USER: # User messages are shown as-is in preformatted blocks escaped_text = escape_html(content[0].text) return "
" + escaped_text + "
" @@ -1397,7 +1398,7 @@ def render_message_content(content: List[ContentItem], message_type: str) -> str ): # Handle both TextContent and Anthropic TextBlock text_value = getattr(item, "text", str(item)) - if message_type == "user": + if message_type == MessageType.USER: # User messages are shown as-is in preformatted blocks escaped_text = escape_html(text_value) rendered_parts.append("
" + escaped_text + "
") @@ -2035,7 +2036,7 @@ def _process_regular_message( is_compacted = False # Handle user-specific preprocessing - if message_type == "user": + if message_type == MessageType.USER: # Note: sidechain user messages are skipped before reaching this function if is_meta: # Slash command expanded prompts - render as collapsible markdown @@ -3272,7 +3273,11 @@ def _reorder_sidechain_template_messages( # tool_use ever gets agent_id in the future agent_id = message.agent_id - if agent_id and message.type == "tool_result" and agent_id in sidechain_map: + if ( + agent_id + and message.type == MessageType.TOOL_RESULT + and agent_id in sidechain_map + ): sidechain_msgs = sidechain_map[agent_id] # Deduplicate: find the last sidechain assistant with text content @@ -3280,7 +3285,7 @@ def _reorder_sidechain_template_messages( task_result_content = ( message.raw_text_content.strip() if message.raw_text_content else None ) - if task_result_content and message.type == "tool_result": + if task_result_content and message.type == MessageType.TOOL_RESULT: # Find the last assistant message in this sidechain for sidechain_msg in reversed(sidechain_msgs): sidechain_text = ( @@ -3289,7 +3294,7 @@ def _reorder_sidechain_template_messages( else None ) if ( - sidechain_msg.type == "assistant" + sidechain_msg.type == MessageType.ASSISTANT and sidechain_text and sidechain_text == task_result_content ): @@ -3404,7 +3409,7 @@ def _process_messages_loop( # Queue operations have content directly, not in message.message message_content = message.content if message.content else [] # Treat as user message type - message_type = "queue-operation" + message_type = MessageType.QUEUE_OPERATION else: # Extract message content first to check for duplicates # Must be UserTranscriptEntry or AssistantTranscriptEntry @@ -3431,7 +3436,7 @@ def _process_messages_loop( # Keep images inline for user messages and queue operations (steering), # extract for assistant messages if is_image and ( - message_type == "user" + message_type == MessageType.USER or isinstance(message, QueueOperationTranscriptEntry) ): text_only_items.append(item) @@ -3457,7 +3462,7 @@ def _process_messages_loop( # Skip sidechain user messages that are just prompts (no tool results) # Sidechain prompts duplicate the Task tool input and are redundant, # but tool results from sidechain agents should be rendered - if message_type == "user" and getattr(message, "isSidechain", False): + if message_type == MessageType.USER and getattr(message, "isSidechain", False): has_tool_results = any( getattr(item, "type", None) == "tool_result" or isinstance(item, ToolResultContent) @@ -3488,7 +3493,7 @@ def _process_messages_loop( # Get first user message content for preview first_user_message = "" if ( - message_type == "user" + message_type == MessageType.USER and not isinstance(message, QueueOperationTranscriptEntry) and hasattr(message, "message") and should_use_as_session_starter(text_content) @@ -3535,7 +3540,10 @@ def _process_messages_loop( template_messages.append(session_header) # Update first user message if this is a user message and we don't have one yet - elif message_type == "user" and not sessions[session_id]["first_user_message"]: + elif ( + message_type == MessageType.USER + and not sessions[session_id]["first_user_message"] + ): if not isinstance(message, QueueOperationTranscriptEntry) and hasattr( message, "message" ): @@ -3554,7 +3562,7 @@ def _process_messages_loop( # Extract and accumulate token usage for assistant messages # Only count tokens for the first message with each requestId to avoid duplicates - if message_type == "assistant" and hasattr(message, "message"): + if message_type == MessageType.ASSISTANT and hasattr(message, "message"): assistant_message = getattr(message, "message") request_id = getattr(message, "requestId", None) message_uuid = getattr(message, "uuid", "") @@ -3591,7 +3599,7 @@ def _process_messages_loop( # Extract token usage for assistant messages # Only show token usage for the first message with each requestId to avoid duplicates token_usage_str: Optional[str] = None - if message_type == "assistant" and hasattr(message, "message"): + if message_type == MessageType.ASSISTANT and hasattr(message, "message"): assistant_message = getattr(message, "message") message_uuid = getattr(message, "uuid", "") From 7aede597256e9508b01c46604ee19c59693146f8 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 7 Dec 2025 12:26:21 +0100 Subject: [PATCH 019/102] Update MESSAGE_REFACTORING.md: mark Phase 9 complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 9 (Type Safety) is now complete - Updated Metrics table to reflect Phase 9 - Updated Next Steps section - Noted type guards available for golergka integration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- dev-docs/MESSAGE_REFACTORING.md | 88 ++++++++------------------------- 1 file changed, 21 insertions(+), 67 deletions(-) diff --git a/dev-docs/MESSAGE_REFACTORING.md b/dev-docs/MESSAGE_REFACTORING.md index 32d4a96d..b443323e 100755 --- a/dev-docs/MESSAGE_REFACTORING.md +++ b/dev-docs/MESSAGE_REFACTORING.md @@ -232,72 +232,26 @@ Adds text/markdown/chat output formats via new `content_extractor.py` module. - Some lines in `renderer_code.py` (116-118, 319) are unreachable due to algorithm behavior - Pygments `ClassNotFound` exception path covered via mock testing -### Phase 9: Type Safety Improvements +### Phase 9: Type Safety Improvements ✅ COMPLETE **Goal**: Replace string-based type checking with enums and typed structures -**Analysis Summary** (from /tmp/renderer_analysis.md and /tmp/test_patterns_analysis.md): -- **22 NECESSARY** getattr/hasattr patterns - handle SDK interop correctly -- **36 DEFENSIVE** patterns - could use isinstance or direct access -- **3 LEGACY** patterns - redundant after type discrimination - -**9.1 MessageType Enum** - -```python -from enum import Enum - -class MessageType(str, Enum): - """Primary message classification (str for backward compatibility).""" - USER = "user" - ASSISTANT = "assistant" - SYSTEM = "system" - SUMMARY = "summary" - QUEUE_OPERATION = "queue-operation" - SESSION_HEADER = "session-header" - TOOL_USE = "tool_use" - TOOL_RESULT = "tool_result" - THINKING = "thinking" - IMAGE = "image" - BASH_INPUT = "bash-input" - BASH_OUTPUT = "bash-output" - UNKNOWN = "unknown" -``` - -**Impact**: 100+ string comparisons across tests/renderer -**Risk**: Low - `str` enum maintains backward compatibility - -**9.2 MessageModifiers Dataclass** - -```python -@dataclass -class MessageModifiers: - """Boolean flags that modify message behavior/rendering.""" - is_sidechain: bool = False # 60+ test references - is_error: bool = False # 7+ test references - is_meta: bool = False # Slash command prompts - is_compacted: bool = False # Compacted conversation -``` - -**Impact**: Consolidates scattered boolean flags -**Risk**: Low - additive change - -**9.3 Type Guards for Union Narrowing** - -```python -from typing import TypeGuard +**Completed Work**: +- ✅ Added `MessageType(str, Enum)` in `models.py` with all message types +- ✅ Added type guards for TranscriptEntry union narrowing (available for future use) +- ✅ Updated `renderer.py` to use `MessageType` enum for key comparisons +- ✅ Maintained backward compatibility via `str` base class -def is_assistant_entry(entry: TranscriptEntry) -> TypeGuard[AssistantTranscriptEntry]: - return entry.type == "assistant" +**MessageType Enum Values**: +- JSONL entry types: `USER`, `ASSISTANT`, `SYSTEM`, `SUMMARY`, `QUEUE_OPERATION` +- Rendering types: `TOOL_USE`, `TOOL_RESULT`, `THINKING`, `IMAGE`, `BASH_INPUT`, `BASH_OUTPUT`, `SESSION_HEADER`, `UNKNOWN` +- System subtypes: `SYSTEM_INFO`, `SYSTEM_WARNING`, `SYSTEM_ERROR` -# Usage - replaces LEGACY patterns -if is_assistant_entry(message): - assistant_message = message.message # Type-safe access -``` +**Type Guards Added**: +- `is_user_entry()`, `is_assistant_entry()`, `is_system_entry()`, `is_summary_entry()`, `is_queue_operation_entry()` +- `is_tool_use_content()`, `is_tool_result_content()`, `is_thinking_content()`, `is_image_content()`, `is_text_content()` -**Changes Required**: -- Add type guards for discriminated union narrowing -- Replace `hasattr(message, "message")` after type checks (3 locations) -- Replace `getattr(message, "field")` with direct `message.field` after narrowing +**Note**: MessageModifiers dataclass deferred - existing boolean flags work well for now ### Phase 10: Parser Simplification @@ -412,26 +366,26 @@ For maximum impact with minimum risk: 4. ✅ **Phase 6 (Pairing)** - Pairing function 69% smaller, clear helpers 5. ✅ **Phase 7 (Documentation)** - Complete CSS/message docs 6. ✅ **Phase 8 (Testing)** - Coverage gap tests, message variant tests, CSS simplification +7. ✅ **Phase 9 (Type Safety)** - MessageType enum and type guards added ### Next Steps -7. **Phase 9 (Type Safety)** - Incremental, can start with MessageType enum 8. **Phase 10 (Parser)** - Low risk, tested simplification 9. **Phase 11 (Tool Models)** - Lower priority, current approach works 10. **Phase 12 (Format Neutral)** - Long-term goal, enables multi-format output **Tree Refactoring Integration:** - Tree building (TEMPLATE_MESSAGE_CHILDREN.md Phase 1-2) is complete and non-blocking -- Template migration (Phase 3) should wait until after Phase 9 (Type Safety) -- golergka's text formats can be integrated after Phase 9, leveraging type guards +- Template migration (Phase 3) can now leverage MessageType enum +- golergka's text formats can be integrated using type guards **golergka Integration Timing:** -- Phase 9 type guards will improve interface clarity +- Phase 9 type guards available for interface clarity - When integrating, resolve `render_message_content()` conflicts carefully - Tree structure and MessageType enum benefit text renderer ## Metrics to Track -| Metric | Baseline (v0.9) | Current (Phase 6 done) | Target | +| Metric | Baseline (v0.9) | Current (Phase 9 done) | Target | |--------|-----------------|------------------------|--------| | renderer.py lines | 4246 | 3853 | <3000 | | Largest function | ~687 lines | ~460 lines | <100 lines | @@ -439,7 +393,7 @@ For maximum impact with minimum risk: | Module count | 3 (renderer, timings, models) | 5 (+ansi_colors, +renderer_code) | 6-7 | | Test coverage | ~78% | ~78% | >85% | -**Progress**: Main loop reduced by 33% (687 → 460 lines). Pairing function reduced by 69% (120 → 37 lines). File grew slightly due to new helper functions, but code is now more modular and testable. +**Progress**: Main loop reduced by 33% (687 → 460 lines). Pairing function reduced by 69% (120 → 37 lines). MessageType enum and type guards added for improved type safety. ## Quality Gates @@ -465,7 +419,7 @@ Before merging any phase: - [ansi_colors.py](../claude_code_log/ansi_colors.py) - ANSI color conversion (261 lines) - Phase 3 - [renderer_code.py](../claude_code_log/renderer_code.py) - Code highlighting & diffs (330 lines) - Phase 4 - [renderer_timings.py](../claude_code_log/renderer_timings.py) - Timing utilities -- [models.py](../claude_code_log/models.py) - Pydantic models (22 models, 3 unions) +- [models.py](../claude_code_log/models.py) - Pydantic models, MessageType enum, type guards - Phase 9 - [parser.py](../claude_code_log/parser.py) - JSONL parsing ### Documentation From 929222911ce1f0481f72cd7d6747f3560714dba8 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 7 Dec 2025 21:06:24 +0100 Subject: [PATCH 020/102] Fix slash command and command output rendering to use correct user type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slash commands () and command output () are user messages in JSONL but were incorrectly rendered as system messages. Changes: - renderer.py: _process_command_message() and _process_local_command_output() now return css_class="user slash-command"/"user command-output" instead of "system"/"system command-output" - renderer.py: Update _try_pair_adjacent() to pair user slash-command with user command-output (was system + command-output) - message_styles.css: Move command-output fold-bar styling from system to user - css-classes.md: Update documentation to reflect correct CSS classes - messages.md: Update css_class values in User Command and Command Output - test_command_handling.py: Update test to expect "user slash-command" class - Add sample files: user_command.json/.jsonl, command_output.json/.jsonl 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/renderer.py | 79 +++++++------------ .../templates/components/message_styles.css | 6 +- dev-docs/css-classes.md | 35 ++++---- dev-docs/messages.md | 60 +++++++++----- dev-docs/messages/user/command_output.json | 15 ++++ dev-docs/messages/user/command_output.jsonl | 1 + dev-docs/messages/user/user_command.json | 15 ++++ dev-docs/messages/user/user_command.jsonl | 1 + test/__snapshots__/test_snapshot_html.ambr | 64 ++++++++------- test/test_command_handling.py | 20 +++-- 10 files changed, 172 insertions(+), 124 deletions(-) create mode 100644 dev-docs/messages/user/command_output.json create mode 100644 dev-docs/messages/user/command_output.jsonl create mode 100644 dev-docs/messages/user/user_command.json create mode 100644 dev-docs/messages/user/user_command.jsonl diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 888ab2b4..567ccd3c 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -1830,8 +1830,12 @@ def _render_hook_summary(message: "SystemTranscriptEntry") -> str: def _process_command_message(text_content: str) -> tuple[str, str, str, str]: - """Process a command message and return (css_class, content_html, message_type, message_title).""" - css_class = "system" + """Process a slash command message and return (css_class, content_html, message_type, message_title). + + These are user messages containing slash command invocations (e.g., /context, /model). + The JSONL type is "user", not "system". + """ + css_class = "user slash-command" command_name, command_args, command_contents = extract_command_info(text_content) escaped_command_name = escape_html(command_name) escaped_command_args = escape_html(command_args) @@ -1864,16 +1868,20 @@ def _process_command_message(text_content: str) -> tuple[str, str, str, str]: content_parts.append(details_html) content_html = "
".join(content_parts) - message_type = "system" - message_title = "System" + message_type = "user" + message_title = "Slash Command" return css_class, content_html, message_type, message_title def _process_local_command_output(text_content: str) -> tuple[str, str, str, str]: - """Process local command output and return (css_class, content_html, message_type, message_title).""" + """Process slash command output and return (css_class, content_html, message_type, message_title). + + These are user messages containing the output from slash commands (e.g., /context, /model). + The JSONL type is "user", not "system". + """ import re - css_class = "system command-output" + css_class = "user command-output" stdout_match = re.search( r"(.*?)", @@ -1906,8 +1914,8 @@ def _process_local_command_output(text_content: str) -> tuple[str, str, str, str else: content_html = escape_html(text_content) - message_type = "system" - message_title = "System" + message_type = "user" + message_title = "Command Output" return css_class, content_html, message_type, message_title @@ -2084,9 +2092,11 @@ def _process_system_message( Handles: - Hook summaries (subtype="stop_hook_summary") - - Command name messages - - Command output messages - - Other system messages with level-specific styling + - Other system messages with level-specific styling (info, warning, error) + + Note: Slash command messages (, ) are user messages, + not system messages. They are handled by _process_command_message and + _process_local_command_output in the main processing loop. """ session_id = getattr(message, "sessionId", "unknown") timestamp = getattr(message, "timestamp", "") @@ -2105,47 +2115,14 @@ def _process_system_message( # Skip system messages without content (shouldn't happen normally) return None else: - # Extract command name if present - command_name_match = re.search( - r"(.*?)", message.content, re.DOTALL - ) - # Also check for command output (child of user command) - command_output_match = re.search( - r"(.*?)", - message.content, - re.DOTALL, - ) - # Create level-specific styling and icons level = getattr(message, "level", "info") level_icon = {"warning": "⚠️", "error": "❌", "info": "ℹ️"}.get(level, "ℹ️") + level_css = f"system system-{level}" - # Determine CSS class: - # - Command name (user-initiated): "system" only - # - Command output (assistant response): "system system-{level}" - # - Other system messages: "system system-{level}" - if command_name_match: - # User-initiated command - level_css = "system" - else: - # Command output or other system message - level_css = f"system system-{level}" - - # Process content: extract command name or command output, or use full content - if command_name_match: - # Show just the command name - command_name = command_name_match.group(1).strip() - html_content = f"{html.escape(command_name)}" - content_html = f"{level_icon} {html_content}" - elif command_output_match: - # Extract and process command output - output = command_output_match.group(1).strip() - html_content = convert_ansi_to_html(output) - content_html = f"{level_icon} {html_content}" - else: - # Process ANSI codes in system messages (they may contain command output) - html_content = convert_ansi_to_html(message.content) - content_html = f"{level_icon} {html_content}" + # Process ANSI codes in system messages (they may contain colored output) + html_content = convert_ansi_to_html(message.content) + content_html = f"{level_icon} {html_content}" # Store parent UUID for hierarchy rebuild (handled by _build_message_hierarchy) parent_uuid = getattr(message, "parentUuid", None) @@ -2467,12 +2444,12 @@ def _try_pair_adjacent( Returns True if messages were paired, False otherwise. Adjacent pairing rules: - - system + command-output + - user slash-command + user command-output - bash-input + bash-output - thinking + assistant """ - # System command + command output - if current.css_class == "system" and "command-output" in next_msg.css_class: + # Slash command + command output (both are user messages) + if "slash-command" in current.css_class and "command-output" in next_msg.css_class: _mark_pair(current, next_msg) return True diff --git a/claude_code_log/templates/components/message_styles.css b/claude_code_log/templates/components/message_styles.css index 15e3a106..1dad759c 100644 --- a/claude_code_log/templates/components/message_styles.css +++ b/claude_code_log/templates/components/message_styles.css @@ -94,7 +94,8 @@ } .fold-bar[data-border-color="user slash-command"] .fold-bar-section, -.fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section { +.fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section, +.fold-bar[data-border-color="user command-output"] .fold-bar-section { border-bottom-color: var(--user-dimmed); } @@ -103,8 +104,7 @@ border-bottom-color: var(--assistant-color); } -.fold-bar[data-border-color="system"] .fold-bar-section, -.fold-bar[data-border-color="system command-output"] .fold-bar-section { +.fold-bar[data-border-color="system"] .fold-bar-section { border-bottom-color: var(--system-color); } diff --git a/dev-docs/css-classes.md b/dev-docs/css-classes.md index b8766bcb..1001b78c 100755 --- a/dev-docs/css-classes.md +++ b/dev-docs/css-classes.md @@ -45,7 +45,7 @@ This document provides a comprehensive reference for CSS class combinations used | Modifier | Applied To | Description | |----------|------------|-------------| | `compacted` | `user` | Compacted conversation summary | -| `command-output` | `system` | Command output content | +| `command-output` | `user` | Slash command output content | | `error` | `tool_result` | Tool execution error | | `pair_first` | Various | First message in a pair | | `pair_last` | Various | Last message in a pair | @@ -82,14 +82,14 @@ Message pairing creates visual groupings for related messages. The `pair_first` | `tool_use` | `tool_result` | `tool_use_id` | | `bash-input` | `bash-output` | Sequential | | `thinking` | `assistant` | Sequential | -| `system` (command) | `system` (command-output) | Sequential | +| `user` (slash-command) | `user` (command-output) | Sequential | | `system` (system-info) | `system` (system-info) | Paired info | --- ## All Class Combinations by Support Level -### ✅ Full Support (24 combinations) +### ✅ Full Support (25 combinations) These combinations have dedicated CSS selectors: @@ -103,7 +103,6 @@ These combinations have dedicated CSS selectors: | `image` | Image content | (rare) | | `session-header` | Session header divider | 29 | | `system` | System message (user-initiated) | 20 | -| `system command-output` | Command output (assistant) | 19 | | `system system-hook` | Hook summary message | (rare) | | `system-error` | System error (assistant-generated) | (rare) | | `system-info` | System info message | 118 | @@ -117,8 +116,9 @@ These combinations have dedicated CSS selectors: | `tool_use` | Tool use message | 946 | | `tool_use sidechain` | Sub-assistant tool use | 84 | | `user` | Basic user message | 88 | +| `user command-output` | Slash command output | 19 | | `user compacted` | Compacted user conversation | (rare) | -| `user slash-command` | Slash command expanded prompt | 1 | +| `user slash-command` | Slash command invocation | 20 | | `user steering` | Out-of-band steering input | (rare) | ### ⚠️ Partial Support (7 combinations) @@ -133,7 +133,7 @@ These combinations inherit from parent selectors but have no dedicated rules: | `user compacted sidechain` | Compacted sidechain user | `.user`, `.compacted`, `.sidechain` | | `user sidechain` | Sub-assistant user prompt (deprecated) | `.user`, `.sidechain` | | `user slash-command sidechain` | Sidechain slash command | `.user`, `.slash-command`, `.sidechain` | -| `system command-output pair_last` | Command output in pair | `.system`, `.command-output` | +| `user command-output pair_last` | Command output in pair | `.user`, `.command-output` | ### ❌ No Support (1 combination) @@ -157,7 +157,6 @@ The fold-bar component uses `data-border-color` attribute to style borders based - `image sidechain` - `session-header` - `system` -- `system command-output` - `system-error` - `system-info` - `system-warning` @@ -172,6 +171,7 @@ The fold-bar component uses `data-border-color` attribute to style borders based - `unknown` - `unknown sidechain` - `user` +- `user command-output` - `user compacted` - `user compacted sidechain` - `user sidechain` @@ -203,11 +203,10 @@ These combinations appear in HTML but lack dedicated fold-bar border colors: ### `bash-output` (5 occurrences, 1 variation) - 5× `bash-output pair_last ` -### `system` (157 occurrences, 4 variations) +### `system` (138 occurrences, 3 variations) - 59× `system pair_first system-info` - 59× `system pair_last system-info` - 20× `system pair_first ` -- 19× `system command-output pair_last ` ### `thinking` (303 occurrences, 2 variations) - 199× `thinking` (standalone) @@ -223,9 +222,11 @@ These combinations appear in HTML but lack dedicated fold-bar border colors: - 946× `tool_use pair_first ` - 84× `tool_use pair_first sidechain` -### `user` (89 occurrences, 2 variations) +### `user` (128 occurrences, 4 variations) - 88× `user` (standalone) -- 1× `user pair_last slash-command` +- 20× `user pair_first slash-command` +- 19× `user command-output pair_last ` +- 1× `user pair_last slash-command` (unpaired) --- @@ -241,13 +242,15 @@ These combinations appear in HTML but lack dedicated fold-bar border colors: 4. **Error Handling**: The `error` modifier only appears on `tool_result` messages (84 total error results). -5. **System Messages**: Have the most variations (4), including: +5. **System Messages**: Have 3 variations: - System info pairs (118 total, always paired) - - Command output (19, always `pair_last`) - Generic system pairs (20, `pair_first`) -6. **Rare Cases**: - - `user` messages with `slash-command` (1 occurrence) +6. **Slash Commands**: User messages with `slash-command` and `command-output` pair together: + - `user slash-command` (20 occurrences, `pair_first`) + - `user command-output` (19 occurrences, `pair_last`) + +7. **Rare Cases**: - `tool_result` with both `error` and `sidechain` (1 occurrence) - `bash-input`/`bash-output` pairs (5 pairs total) @@ -269,7 +272,7 @@ These are excluded from semantic analysis but appear in all HTML output. - **Total CSS selectors in templates**: 495 - **Message-related selectors**: 78 - **Fold-bar combinations**: 28 -- **Full support combinations**: 24 +- **Full support combinations**: 25 - **Partial support combinations**: 7 - **No support combinations**: 1 diff --git a/dev-docs/messages.md b/dev-docs/messages.md index af54f1f2..60156219 100644 --- a/dev-docs/messages.md +++ b/dev-docs/messages.md @@ -27,8 +27,8 @@ This document maps input types to their intermediate and output representations. | `assistant` + text (sidechain) | `assistant` | `assistant sidechain` | Sub-agent response | | `assistant` + thinking | `thinking` | `thinking` | Extended thinking content | | `assistant` + tool_use | `tool_use` | `tool_use` | Tool invocation | -| `system` (command-name) | `system` | `system` | User-initiated command | -| `system` (command-output) | `system` | `system command-output` | Command output | +| `user` (command-name) | `user` | `user slash-command` | User-initiated slash command | +| `user` (command-output) | `user` | `user command-output` | Slash command output | | `system` (level=info) | `system` | `system system-info` | Info message | | `system` (level=warning) | `system` | `system system-warning` | Warning message | | `system` (level=error) | `system` | `system system-error` | Error message | @@ -217,6 +217,44 @@ The `isMeta` field indicates this is an LLM-generated prompt from a slash comman **Note**: These are typically **skipped** during rendering because they duplicate the Task tool input prompt. +### User Command (Slash Command) + +- **Input**: `user` with `` tag in content +- **Intermediate**: `message_type: "user"`, `css_class: "user slash-command"` +- **Files**: [user_command.json](messages/user/user_command.json) | [user_command.jsonl](messages/user/user_command.jsonl) + +```json +{ + "type": "user", + "message": { + "role": "user", + "content": "/model\n model\n " + }, + "isSidechain": false +} +``` + +Shows the slash command name (e.g., `/context`, `/model`) that the user executed. + +### Command Output (Slash Command Result) + +- **Input**: `user` with `` tag in content +- **Intermediate**: `message_type: "user"`, `css_class: "user command-output"` +- **Files**: [command_output.json](messages/user/command_output.json) | [command_output.jsonl](messages/user/command_output.jsonl) + +```json +{ + "type": "user", + "message": { + "role": "user", + "content": "Set model to opus (claude-opus-4-5-20251101)" + }, + "isSidechain": false +} +``` + +Shows the output from the slash command with ANSI color support. + --- ## Tool Results @@ -347,23 +385,7 @@ See [messages/tools/](messages/tools/) for samples of each tool type. ## System Messages -System messages (`type: "system"`) convey commands and notifications. - -### User Command - -- **Input**: `system` with `` tag in content -- **Intermediate**: `message_type: "system"`, `css_class: "system"` -- **Files**: *(No sample in real_projects)* - -Shows the command name (e.g., `/context`, `/init`) in a styled block. - -### Command Output - -- **Input**: `system` with `` tag in content -- **Intermediate**: `message_type: "system"`, `css_class: "system command-output"` -- **Files**: *(No sample in real_projects)* - -Shows the command output with ANSI color support. +System messages (`type: "system"`) convey notifications and hook summaries. ### System Info diff --git a/dev-docs/messages/user/command_output.json b/dev-docs/messages/user/command_output.json new file mode 100644 index 00000000..b89f381b --- /dev/null +++ b/dev-docs/messages/user/command_output.json @@ -0,0 +1,15 @@ +{ + "type": "user", + "message": { + "role": "user", + "content": "Set model to \u001b[1mopus (claude-opus-4-5-20251101)\u001b[22m" + }, + "parentUuid": "200652a8-ed8f-40ca-9239-5a661fa2c9be", + "isSidechain": false, + "userType": "external", + "cwd": "/src/deep-manifest", + "sessionId": "a7da6a22-facc-4fcd-8bab-f83c87862004", + "version": "2.0.55", + "uuid": "f880c35d-8afe-4cfb-82bf-37c39f423457", + "timestamp": "2025-11-29T15:17:28.972Z" +} \ No newline at end of file diff --git a/dev-docs/messages/user/command_output.jsonl b/dev-docs/messages/user/command_output.jsonl new file mode 100644 index 00000000..ddaf3699 --- /dev/null +++ b/dev-docs/messages/user/command_output.jsonl @@ -0,0 +1 @@ +{"type": "user", "message": {"role": "user", "content": "Set model to \u001b[1mopus (claude-opus-4-5-20251101)\u001b[22m"}, "parentUuid": "200652a8-ed8f-40ca-9239-5a661fa2c9be", "isSidechain": false, "userType": "external", "cwd": "/src/deep-manifest", "sessionId": "a7da6a22-facc-4fcd-8bab-f83c87862004", "version": "2.0.55", "uuid": "f880c35d-8afe-4cfb-82bf-37c39f423457", "timestamp": "2025-11-29T15:17:28.972Z"} diff --git a/dev-docs/messages/user/user_command.json b/dev-docs/messages/user/user_command.json new file mode 100644 index 00000000..10367080 --- /dev/null +++ b/dev-docs/messages/user/user_command.json @@ -0,0 +1,15 @@ +{ + "type": "user", + "message": { + "role": "user", + "content": "/model\n model\n " + }, + "parentUuid": "92757a7c-5fef-4e3f-8f26-4cd4c3069187", + "isSidechain": false, + "userType": "external", + "cwd": "/src/deep-manifest", + "sessionId": "a7da6a22-facc-4fcd-8bab-f83c87862004", + "version": "2.0.55", + "uuid": "200652a8-ed8f-40ca-9239-5a661fa2c9be", + "timestamp": "2025-11-29T15:17:28.972Z" +} \ No newline at end of file diff --git a/dev-docs/messages/user/user_command.jsonl b/dev-docs/messages/user/user_command.jsonl new file mode 100644 index 00000000..5a1fee1c --- /dev/null +++ b/dev-docs/messages/user/user_command.jsonl @@ -0,0 +1 @@ +{"type": "user", "message": {"role": "user", "content": "/model\n model\n "}, "parentUuid": "92757a7c-5fef-4e3f-8f26-4cd4c3069187", "isSidechain": false, "userType": "external", "cwd": "/src/deep-manifest", "sessionId": "a7da6a22-facc-4fcd-8bab-f83c87862004", "version": "2.0.55", "uuid": "200652a8-ed8f-40ca-9239-5a661fa2c9be", "timestamp": "2025-11-29T15:17:28.972Z"} diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index dcca563f..3ea77270 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -2186,7 +2186,8 @@ } .fold-bar[data-border-color="user slash-command"] .fold-bar-section, - .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section { + .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section, + .fold-bar[data-border-color="user command-output"] .fold-bar-section { border-bottom-color: var(--user-dimmed); } @@ -2195,8 +2196,7 @@ border-bottom-color: var(--assistant-color); } - .fold-bar[data-border-color="system"] .fold-bar-section, - .fold-bar[data-border-color="system command-output"] .fold-bar-section { + .fold-bar[data-border-color="system"] .fold-bar-section { border-bottom-color: var(--system-color); } @@ -6914,7 +6914,8 @@ } .fold-bar[data-border-color="user slash-command"] .fold-bar-section, - .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section { + .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section, + .fold-bar[data-border-color="user command-output"] .fold-bar-section { border-bottom-color: var(--user-dimmed); } @@ -6923,8 +6924,7 @@ border-bottom-color: var(--assistant-color); } - .fold-bar[data-border-color="system"] .fold-bar-section, - .fold-bar[data-border-color="system command-output"] .fold-bar-section { + .fold-bar[data-border-color="system"] .fold-bar-section { border-bottom-color: var(--system-color); } @@ -9555,11 +9555,11 @@
-
+
- 4 users + 4 users, 1 user slash-command
-
+
▼▼ 4 users, 2 assistants, 3 more total
@@ -9671,13 +9671,9 @@
-
+
- 1 tool, 1 system, 1 more -
-
- ▼▼ - 2 tools, 1 system, 1 more total + 1 tool
@@ -9726,9 +9722,9 @@ -
+
- ⚙️ System + 🤷 Slash Command
2025-06-14 11:02:10 @@ -9745,9 +9741,9 @@ -
+
- System + 🤷 Command Output
2025-06-14 11:02:20 @@ -9762,12 +9758,26 @@ Status: SUCCESS Timestamp: 2025-06-14T11:02:20Z
+
+ + +
+ + 1 assistant +
+
+ ▼▼ + 1 assistant, 1 tool total +
+ +
+ -
+
🤖 Assistant
@@ -9797,7 +9807,7 @@ -
+
🛠️ MultiEdit
@@ -11744,7 +11754,8 @@ } .fold-bar[data-border-color="user slash-command"] .fold-bar-section, - .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section { + .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section, + .fold-bar[data-border-color="user command-output"] .fold-bar-section { border-bottom-color: var(--user-dimmed); } @@ -11753,8 +11764,7 @@ border-bottom-color: var(--assistant-color); } - .fold-bar[data-border-color="system"] .fold-bar-section, - .fold-bar[data-border-color="system command-output"] .fold-bar-section { + .fold-bar[data-border-color="system"] .fold-bar-section { border-bottom-color: var(--system-color); } @@ -16610,7 +16620,8 @@ } .fold-bar[data-border-color="user slash-command"] .fold-bar-section, - .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section { + .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section, + .fold-bar[data-border-color="user command-output"] .fold-bar-section { border-bottom-color: var(--user-dimmed); } @@ -16619,8 +16630,7 @@ border-bottom-color: var(--assistant-color); } - .fold-bar[data-border-color="system"] .fold-bar-section, - .fold-bar[data-border-color="system command-output"] .fold-bar-section { + .fold-bar[data-border-color="system"] .fold-bar-section { border-bottom-color: var(--system-color); } diff --git a/test/test_command_handling.py b/test/test_command_handling.py index 2336f226..033aada9 100644 --- a/test/test_command_handling.py +++ b/test/test_command_handling.py @@ -10,8 +10,12 @@ ) -def test_system_message_command_handling(): - """Test that system messages with command names are shown in expandable details.""" +def test_slash_command_handling(): + """Test that user messages with slash commands are rendered with correct CSS class. + + Slash command messages (containing tags) are user messages, + not system messages. They should render with "user slash-command" CSS class. + """ command_message = { "type": "user", "timestamp": "2025-06-11T22:44:17.436Z", @@ -59,16 +63,16 @@ def test_system_message_command_handling(): assert "Command: init" in html, ( "Should show command name in summary" ) - # Check for system CSS class (may have ancestor IDs appended) - assert "class='message system" in html, "Should have system CSS class" - - # Test passed successfully - pass + # Check for user slash-command CSS class (not "system") + # These are user messages with command tags, not system messages + assert "class='message user slash-command" in html, ( + "Should have 'user slash-command' CSS class" + ) finally: test_file_path.unlink() if __name__ == "__main__": - test_system_message_command_handling() + test_slash_command_handling() print("\n✅ All command handling tests passed!") From 6ce62b9483936cc615eda4a59bbb6eed8049576a Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 7 Dec 2025 21:19:16 +0100 Subject: [PATCH 021/102] Remove redundant labels from slash command and command output content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change slash command content from "Command: /xyz" to just code-styled "/xyz" (header already shows "Slash Command") - Remove "Command Output:" prefix from command output content (header already shows "Command Output") - Update tests and snapshots for the cleaner output 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/renderer.py | 14 ++++---------- test/__snapshots__/test_snapshot_html.ambr | 4 ++-- test/test_command_handling.py | 4 +--- test/test_context_command.py | 3 +-- 4 files changed, 8 insertions(+), 17 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 567ccd3c..53f4b9b7 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -1844,8 +1844,8 @@ def _process_command_message(text_content: str) -> tuple[str, str, str, str]: formatted_contents = command_contents.replace("\\n", "\n") escaped_command_contents = escape_html(formatted_contents) - # Build the content HTML - content_parts: List[str] = [f"Command: {escaped_command_name}"] + # Build the content HTML - command name is the primary content + content_parts: List[str] = [f"{escaped_command_name}"] if command_args: content_parts.append(f"Args: {escaped_command_args}") if command_contents: @@ -1899,18 +1899,12 @@ def _process_local_command_output(text_content: str) -> tuple[str, str, str, str import mistune markdown_html = mistune.html(stdout_content) - content_html = ( - f"Command Output:
" - f"
{markdown_html}
" - ) + content_html = f"
{markdown_html}
" else: # Convert ANSI codes to HTML for colored display html_content = convert_ansi_to_html(stdout_content) # Use
 to preserve formatting and line breaks
-            content_html = (
-                f"Command Output:
" - f"
{html_content}
" - ) + content_html = f"
{html_content}
" else: content_html = escape_html(text_content) diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 3ea77270..c4e77e65 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -9732,7 +9732,7 @@
-
Command: test-command
Args: --verbose --output /tmp/test.log
Content:
This is the actual command content with some JSON structure and escaped characters: "quotes" and 
+          
test-command
Args: --verbose --output /tmp/test.log
Content:
This is the actual command content with some JSON structure and escaped characters: "quotes" and 
   line breaks
   
@@ -9751,7 +9751,7 @@
-
Command Output:
Command output here:
+          
Command output here:
   Line 1 of output
   Line 2 of output
   Some data: 12345
diff --git a/test/test_command_handling.py b/test/test_command_handling.py
index 033aada9..b31385a5 100644
--- a/test/test_command_handling.py
+++ b/test/test_command_handling.py
@@ -60,9 +60,7 @@ def test_slash_command_handling():
         else:
             # For short content, should have pre tag with the escaped content
             assert "
" in html, "Should contain pre tag for short content"
-        assert "Command: init" in html, (
-            "Should show command name in summary"
-        )
+        assert "init" in html, "Should show command name"
         # Check for user slash-command CSS class (not "system")
         # These are user messages with command tags, not system messages
         assert "class='message user slash-command" in html, (
diff --git a/test/test_context_command.py b/test/test_context_command.py
index 8d55a699..a85fb053 100644
--- a/test/test_context_command.py
+++ b/test/test_context_command.py
@@ -57,8 +57,7 @@ def test_context_command_rendering():
     assert "⛀" in html
     assert "⛶" in html
 
-    # Check that command output container is present
-    assert "Command Output:" in html
+    # Check that command output container is present (title is in header, not content)
     assert "command-output-content" in html
 
     # Ensure ANSI codes are not present in raw form

From 82a9f1f347610b6bd8cd981f291e5153171a4df4 Mon Sep 17 00:00:00 2001
From: Christian Boos 
Date: Sun, 7 Dec 2025 22:52:13 +0100
Subject: [PATCH 022/102] Add error tool result sample from real_projects
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Extract Bash error sample from -src-deep-manifest session (line 49)
and update messages.md with file references and JSON example.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 
---
 dev-docs/messages.md                          | 18 ++++++++++++-
 .../tools/Bash-tool_result_error.json         | 25 +++++++++++++++++++
 .../tools/Bash-tool_result_error.jsonl        |  1 +
 3 files changed, 43 insertions(+), 1 deletion(-)
 create mode 100644 dev-docs/messages/tools/Bash-tool_result_error.json
 create mode 100644 dev-docs/messages/tools/Bash-tool_result_error.jsonl

diff --git a/dev-docs/messages.md b/dev-docs/messages.md
index 60156219..bac5108e 100644
--- a/dev-docs/messages.md
+++ b/dev-docs/messages.md
@@ -285,7 +285,23 @@ Tool results are contained within `user` messages as `tool_result` content items
 
 - **Input**: `user` with `tool_result` content, `is_error: true`
 - **Intermediate**: `message_type: "tool_result"`, `is_error: true`, `css_class: "tool_result error"`
-- **Files**: *(No sample in real_projects)*
+- **Files**: [Bash-tool_result_error.json](messages/tools/Bash-tool_result_error.json) | [Bash-tool_result_error.jsonl](messages/tools/Bash-tool_result_error.jsonl)
+
+```json
+{
+  "type": "user",
+  "message": {
+    "role": "user",
+    "content": [{
+      "type": "tool_result",
+      "content": "Exit code 127\n/bin/bash: line 1: pytest: command not found",
+      "is_error": true,
+      "tool_use_id": "toolu_xxx"
+    }]
+  },
+  "toolUseResult": "Error: Exit code 127\n/bin/bash: line 1: pytest: command not found"
+}
+```
 
 ---
 
diff --git a/dev-docs/messages/tools/Bash-tool_result_error.json b/dev-docs/messages/tools/Bash-tool_result_error.json
new file mode 100644
index 00000000..b9469c19
--- /dev/null
+++ b/dev-docs/messages/tools/Bash-tool_result_error.json
@@ -0,0 +1,25 @@
+{
+  "parentUuid": "edd1f7e9-e726-486d-91d3-cd0ca8ffd613",
+  "isSidechain": false,
+  "userType": "external",
+  "cwd": "/src/deep-manifest",
+  "sessionId": "a7da6a22-facc-4fcd-8bab-f83c87862004",
+  "version": "2.0.55",
+  "gitBranch": "master",
+  "slug": "humble-doodling-wolf",
+  "type": "user",
+  "message": {
+    "role": "user",
+    "content": [
+      {
+        "type": "tool_result",
+        "content": "Exit code 127\n/bin/bash: line 1: pytest: command not found",
+        "is_error": true,
+        "tool_use_id": "toolu_01GYstGW7ybCfds4h2gZtYxc"
+      }
+    ]
+  },
+  "uuid": "84cd96f7-b2c8-4846-ab0f-4bab1aedfc8a",
+  "timestamp": "2025-11-29T15:20:28.773Z",
+  "toolUseResult": "Error: Exit code 127\n/bin/bash: line 1: pytest: command not found"
+}
diff --git a/dev-docs/messages/tools/Bash-tool_result_error.jsonl b/dev-docs/messages/tools/Bash-tool_result_error.jsonl
new file mode 100644
index 00000000..69c13c7b
--- /dev/null
+++ b/dev-docs/messages/tools/Bash-tool_result_error.jsonl
@@ -0,0 +1 @@
+{"parentUuid":"edd1f7e9-e726-486d-91d3-cd0ca8ffd613","isSidechain":false,"userType":"external","cwd":"/src/deep-manifest","sessionId":"a7da6a22-facc-4fcd-8bab-f83c87862004","version":"2.0.55","gitBranch":"master","slug":"humble-doodling-wolf","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"Exit code 127\n/bin/bash: line 1: pytest: command not found","is_error":true,"tool_use_id":"toolu_01GYstGW7ybCfds4h2gZtYxc"}]},"uuid":"84cd96f7-b2c8-4846-ab0f-4bab1aedfc8a","timestamp":"2025-11-29T15:20:28.773Z","toolUseResult":"Error: Exit code 127\n/bin/bash: line 1: pytest: command not found"}

From 045671a040b6df7f55292d7c9c51b06d132ed26b Mon Sep 17 00:00:00 2001
From: Christian Boos 
Date: Sun, 7 Dec 2025 23:00:38 +0100
Subject: [PATCH 023/102] Show fold bar border only when collapsed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

When messages have children, the fold bar now shows a colored bottom
border only in the collapsed state, hinting at hidden content. When
expanded, the border is transparent, indicating content flows below.

This improves visual clarity by removing the "barrier" effect when
content is visible, and only showing the colored border as a visual
cue that there is more content to be expanded.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 
---
 .../templates/components/message_styles.css   |  74 ++---
 test/__snapshots__/test_snapshot_html.ambr    | 296 ++++++++----------
 2 files changed, 170 insertions(+), 200 deletions(-)

diff --git a/claude_code_log/templates/components/message_styles.css b/claude_code_log/templates/components/message_styles.css
index 1dad759c..1e6496b4 100644
--- a/claude_code_log/templates/components/message_styles.css
+++ b/claude_code_log/templates/components/message_styles.css
@@ -39,14 +39,14 @@
     font-weight: 500;
     padding: 0.4em;
     transition: all 0.2s ease;
-    border-bottom: 2px solid;
+    border-bottom: 2px solid transparent;
     background: linear-gradient(to bottom, #f8f8f844, #f0f0f0);
 }
 
-/* Double-line effect when folded */
+/* Show border only when folded (content is hidden) */
 .fold-bar-section.folded {
-    border-bottom-style: double;
-    border-bottom-width: 4px;
+    border-bottom-style: solid;
+    border-bottom-width: 2px;
 }
 
 .fold-bar-section:hover {
@@ -85,92 +85,86 @@
     font-size: 0.9em;
 }
 
-/* Border colors matching message types */
-.fold-bar[data-border-color="user"] .fold-bar-section,
-.fold-bar[data-border-color="user compacted"] .fold-bar-section,
-.fold-bar[data-border-color="user sidechain"] .fold-bar-section,
-.fold-bar[data-border-color="user compacted sidechain"] .fold-bar-section {
+/* Border colors matching message types - only shown when folded */
+.fold-bar[data-border-color="user"] .fold-bar-section.folded,
+.fold-bar[data-border-color="user compacted"] .fold-bar-section.folded,
+.fold-bar[data-border-color="user sidechain"] .fold-bar-section.folded,
+.fold-bar[data-border-color="user compacted sidechain"] .fold-bar-section.folded {
     border-bottom-color: var(--user-color);
 }
 
-.fold-bar[data-border-color="user slash-command"] .fold-bar-section,
-.fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section,
-.fold-bar[data-border-color="user command-output"] .fold-bar-section {
+.fold-bar[data-border-color="user slash-command"] .fold-bar-section.folded,
+.fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section.folded,
+.fold-bar[data-border-color="user command-output"] .fold-bar-section.folded {
     border-bottom-color: var(--user-dimmed);
 }
 
-.fold-bar[data-border-color="assistant"] .fold-bar-section,
-.fold-bar[data-border-color="assistant sidechain"] .fold-bar-section {
+.fold-bar[data-border-color="assistant"] .fold-bar-section.folded,
+.fold-bar[data-border-color="assistant sidechain"] .fold-bar-section.folded {
     border-bottom-color: var(--assistant-color);
 }
 
-.fold-bar[data-border-color="system"] .fold-bar-section {
+.fold-bar[data-border-color="system"] .fold-bar-section.folded {
     border-bottom-color: var(--system-color);
 }
 
-.fold-bar[data-border-color="system-warning"] .fold-bar-section {
+.fold-bar[data-border-color="system-warning"] .fold-bar-section.folded {
     border-bottom-color: var(--system-warning-color);
 }
 
-.fold-bar[data-border-color="system-error"] .fold-bar-section {
+.fold-bar[data-border-color="system-error"] .fold-bar-section.folded {
     border-bottom-color: var(--system-error-color);
 }
 
-.fold-bar[data-border-color="system-info"] .fold-bar-section {
+.fold-bar[data-border-color="system-info"] .fold-bar-section.folded {
     border-bottom-color: var(--info-dimmed);
 }
 
-.fold-bar[data-border-color="tool_use"] .fold-bar-section,
-.fold-bar[data-border-color="tool_use sidechain"] .fold-bar-section {
+.fold-bar[data-border-color="tool_use"] .fold-bar-section.folded,
+.fold-bar[data-border-color="tool_use sidechain"] .fold-bar-section.folded {
     border-bottom-color: var(--tool-use-color);
 }
 
-.fold-bar[data-border-color="tool_result"] .fold-bar-section,
-.fold-bar[data-border-color="tool_result sidechain"] .fold-bar-section {
+.fold-bar[data-border-color="tool_result"] .fold-bar-section.folded,
+.fold-bar[data-border-color="tool_result sidechain"] .fold-bar-section.folded {
     border-bottom-color: var(--success-dimmed);
 }
 
-.fold-bar[data-border-color="tool_result error"] .fold-bar-section,
-.fold-bar[data-border-color="tool_result error sidechain"] .fold-bar-section {
+.fold-bar[data-border-color="tool_result error"] .fold-bar-section.folded,
+.fold-bar[data-border-color="tool_result error sidechain"] .fold-bar-section.folded {
     border-bottom-color: var(--error-dimmed);
 }
 
-.fold-bar[data-border-color="thinking"] .fold-bar-section,
-.fold-bar[data-border-color="thinking sidechain"] .fold-bar-section {
+.fold-bar[data-border-color="thinking"] .fold-bar-section.folded,
+.fold-bar[data-border-color="thinking sidechain"] .fold-bar-section.folded {
     border-bottom-color: var(--assistant-dimmed);
 }
 
-.fold-bar[data-border-color="image"] .fold-bar-section,
-.fold-bar[data-border-color="image sidechain"] .fold-bar-section {
+.fold-bar[data-border-color="image"] .fold-bar-section.folded,
+.fold-bar[data-border-color="image sidechain"] .fold-bar-section.folded {
     border-bottom-color: var(--info-dimmed);
 }
 
-.fold-bar[data-border-color="unknown"] .fold-bar-section,
-.fold-bar[data-border-color="unknown sidechain"] .fold-bar-section {
+.fold-bar[data-border-color="unknown"] .fold-bar-section.folded,
+.fold-bar[data-border-color="unknown sidechain"] .fold-bar-section.folded {
     border-bottom-color: var(--neutral-dimmed);
 }
 
-.fold-bar[data-border-color="bash-input"] .fold-bar-section {
+.fold-bar[data-border-color="bash-input"] .fold-bar-section.folded {
     border-bottom-color: var(--user-color);
 }
 
-.fold-bar[data-border-color="bash-output"] .fold-bar-section {
+.fold-bar[data-border-color="bash-output"] .fold-bar-section.folded {
     border-bottom-color: var(--user-dimmed);
 }
 
-.fold-bar[data-border-color="session-header"] .fold-bar-section {
+.fold-bar[data-border-color="session-header"] .fold-bar-section.folded {
     border-bottom-color: var(--system-warning-color);
 }
 
-/* Sidechain (sub-assistant) fold-bar styling */
-.sidechain .fold-bar-section {
-    border-bottom-style: dashed;
-    border-bottom-width: 2px;
-}
-
+/* Sidechain (sub-assistant) fold-bar styling - dashed border when folded */
 .sidechain .fold-bar-section.folded {
     border-bottom-style: dashed;
-    border-bottom-width: 4px;
 }
 
 /* ========================================
diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr
index c4e77e65..f29a3b35 100644
--- a/test/__snapshots__/test_snapshot_html.ambr
+++ b/test/__snapshots__/test_snapshot_html.ambr
@@ -2131,14 +2131,14 @@
       font-weight: 500;
       padding: 0.4em;
       transition: all 0.2s ease;
-      border-bottom: 2px solid;
+      border-bottom: 2px solid transparent;
       background: linear-gradient(to bottom, #f8f8f844, #f0f0f0);
   }
   
-  /* Double-line effect when folded */
+  /* Show border only when folded (content is hidden) */
   .fold-bar-section.folded {
-      border-bottom-style: double;
-      border-bottom-width: 4px;
+      border-bottom-style: solid;
+      border-bottom-width: 2px;
   }
   
   .fold-bar-section:hover {
@@ -2177,92 +2177,86 @@
       font-size: 0.9em;
   }
   
-  /* Border colors matching message types */
-  .fold-bar[data-border-color="user"] .fold-bar-section,
-  .fold-bar[data-border-color="user compacted"] .fold-bar-section,
-  .fold-bar[data-border-color="user sidechain"] .fold-bar-section,
-  .fold-bar[data-border-color="user compacted sidechain"] .fold-bar-section {
+  /* Border colors matching message types - only shown when folded */
+  .fold-bar[data-border-color="user"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user compacted"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user sidechain"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user compacted sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--user-color);
   }
   
-  .fold-bar[data-border-color="user slash-command"] .fold-bar-section,
-  .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section,
-  .fold-bar[data-border-color="user command-output"] .fold-bar-section {
+  .fold-bar[data-border-color="user slash-command"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user command-output"] .fold-bar-section.folded {
       border-bottom-color: var(--user-dimmed);
   }
   
-  .fold-bar[data-border-color="assistant"] .fold-bar-section,
-  .fold-bar[data-border-color="assistant sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="assistant"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="assistant sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--assistant-color);
   }
   
-  .fold-bar[data-border-color="system"] .fold-bar-section {
+  .fold-bar[data-border-color="system"] .fold-bar-section.folded {
       border-bottom-color: var(--system-color);
   }
   
-  .fold-bar[data-border-color="system-warning"] .fold-bar-section {
+  .fold-bar[data-border-color="system-warning"] .fold-bar-section.folded {
       border-bottom-color: var(--system-warning-color);
   }
   
-  .fold-bar[data-border-color="system-error"] .fold-bar-section {
+  .fold-bar[data-border-color="system-error"] .fold-bar-section.folded {
       border-bottom-color: var(--system-error-color);
   }
   
-  .fold-bar[data-border-color="system-info"] .fold-bar-section {
+  .fold-bar[data-border-color="system-info"] .fold-bar-section.folded {
       border-bottom-color: var(--info-dimmed);
   }
   
-  .fold-bar[data-border-color="tool_use"] .fold-bar-section,
-  .fold-bar[data-border-color="tool_use sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="tool_use"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="tool_use sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--tool-use-color);
   }
   
-  .fold-bar[data-border-color="tool_result"] .fold-bar-section,
-  .fold-bar[data-border-color="tool_result sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="tool_result"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="tool_result sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--success-dimmed);
   }
   
-  .fold-bar[data-border-color="tool_result error"] .fold-bar-section,
-  .fold-bar[data-border-color="tool_result error sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="tool_result error"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="tool_result error sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--error-dimmed);
   }
   
-  .fold-bar[data-border-color="thinking"] .fold-bar-section,
-  .fold-bar[data-border-color="thinking sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="thinking"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="thinking sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--assistant-dimmed);
   }
   
-  .fold-bar[data-border-color="image"] .fold-bar-section,
-  .fold-bar[data-border-color="image sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="image"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="image sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--info-dimmed);
   }
   
-  .fold-bar[data-border-color="unknown"] .fold-bar-section,
-  .fold-bar[data-border-color="unknown sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="unknown"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="unknown sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--neutral-dimmed);
   }
   
-  .fold-bar[data-border-color="bash-input"] .fold-bar-section {
+  .fold-bar[data-border-color="bash-input"] .fold-bar-section.folded {
       border-bottom-color: var(--user-color);
   }
   
-  .fold-bar[data-border-color="bash-output"] .fold-bar-section {
+  .fold-bar[data-border-color="bash-output"] .fold-bar-section.folded {
       border-bottom-color: var(--user-dimmed);
   }
   
-  .fold-bar[data-border-color="session-header"] .fold-bar-section {
+  .fold-bar[data-border-color="session-header"] .fold-bar-section.folded {
       border-bottom-color: var(--system-warning-color);
   }
   
-  /* Sidechain (sub-assistant) fold-bar styling */
-  .sidechain .fold-bar-section {
-      border-bottom-style: dashed;
-      border-bottom-width: 2px;
-  }
-  
+  /* Sidechain (sub-assistant) fold-bar styling - dashed border when folded */
   .sidechain .fold-bar-section.folded {
       border-bottom-style: dashed;
-      border-bottom-width: 4px;
   }
   
   /* ========================================
@@ -6859,14 +6853,14 @@
       font-weight: 500;
       padding: 0.4em;
       transition: all 0.2s ease;
-      border-bottom: 2px solid;
+      border-bottom: 2px solid transparent;
       background: linear-gradient(to bottom, #f8f8f844, #f0f0f0);
   }
   
-  /* Double-line effect when folded */
+  /* Show border only when folded (content is hidden) */
   .fold-bar-section.folded {
-      border-bottom-style: double;
-      border-bottom-width: 4px;
+      border-bottom-style: solid;
+      border-bottom-width: 2px;
   }
   
   .fold-bar-section:hover {
@@ -6905,92 +6899,86 @@
       font-size: 0.9em;
   }
   
-  /* Border colors matching message types */
-  .fold-bar[data-border-color="user"] .fold-bar-section,
-  .fold-bar[data-border-color="user compacted"] .fold-bar-section,
-  .fold-bar[data-border-color="user sidechain"] .fold-bar-section,
-  .fold-bar[data-border-color="user compacted sidechain"] .fold-bar-section {
+  /* Border colors matching message types - only shown when folded */
+  .fold-bar[data-border-color="user"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user compacted"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user sidechain"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user compacted sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--user-color);
   }
   
-  .fold-bar[data-border-color="user slash-command"] .fold-bar-section,
-  .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section,
-  .fold-bar[data-border-color="user command-output"] .fold-bar-section {
+  .fold-bar[data-border-color="user slash-command"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user command-output"] .fold-bar-section.folded {
       border-bottom-color: var(--user-dimmed);
   }
   
-  .fold-bar[data-border-color="assistant"] .fold-bar-section,
-  .fold-bar[data-border-color="assistant sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="assistant"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="assistant sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--assistant-color);
   }
   
-  .fold-bar[data-border-color="system"] .fold-bar-section {
+  .fold-bar[data-border-color="system"] .fold-bar-section.folded {
       border-bottom-color: var(--system-color);
   }
   
-  .fold-bar[data-border-color="system-warning"] .fold-bar-section {
+  .fold-bar[data-border-color="system-warning"] .fold-bar-section.folded {
       border-bottom-color: var(--system-warning-color);
   }
   
-  .fold-bar[data-border-color="system-error"] .fold-bar-section {
+  .fold-bar[data-border-color="system-error"] .fold-bar-section.folded {
       border-bottom-color: var(--system-error-color);
   }
   
-  .fold-bar[data-border-color="system-info"] .fold-bar-section {
+  .fold-bar[data-border-color="system-info"] .fold-bar-section.folded {
       border-bottom-color: var(--info-dimmed);
   }
   
-  .fold-bar[data-border-color="tool_use"] .fold-bar-section,
-  .fold-bar[data-border-color="tool_use sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="tool_use"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="tool_use sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--tool-use-color);
   }
   
-  .fold-bar[data-border-color="tool_result"] .fold-bar-section,
-  .fold-bar[data-border-color="tool_result sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="tool_result"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="tool_result sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--success-dimmed);
   }
   
-  .fold-bar[data-border-color="tool_result error"] .fold-bar-section,
-  .fold-bar[data-border-color="tool_result error sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="tool_result error"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="tool_result error sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--error-dimmed);
   }
   
-  .fold-bar[data-border-color="thinking"] .fold-bar-section,
-  .fold-bar[data-border-color="thinking sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="thinking"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="thinking sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--assistant-dimmed);
   }
   
-  .fold-bar[data-border-color="image"] .fold-bar-section,
-  .fold-bar[data-border-color="image sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="image"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="image sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--info-dimmed);
   }
   
-  .fold-bar[data-border-color="unknown"] .fold-bar-section,
-  .fold-bar[data-border-color="unknown sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="unknown"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="unknown sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--neutral-dimmed);
   }
   
-  .fold-bar[data-border-color="bash-input"] .fold-bar-section {
+  .fold-bar[data-border-color="bash-input"] .fold-bar-section.folded {
       border-bottom-color: var(--user-color);
   }
   
-  .fold-bar[data-border-color="bash-output"] .fold-bar-section {
+  .fold-bar[data-border-color="bash-output"] .fold-bar-section.folded {
       border-bottom-color: var(--user-dimmed);
   }
   
-  .fold-bar[data-border-color="session-header"] .fold-bar-section {
+  .fold-bar[data-border-color="session-header"] .fold-bar-section.folded {
       border-bottom-color: var(--system-warning-color);
   }
   
-  /* Sidechain (sub-assistant) fold-bar styling */
-  .sidechain .fold-bar-section {
-      border-bottom-style: dashed;
-      border-bottom-width: 2px;
-  }
-  
+  /* Sidechain (sub-assistant) fold-bar styling - dashed border when folded */
   .sidechain .fold-bar-section.folded {
       border-bottom-style: dashed;
-      border-bottom-width: 4px;
   }
   
   /* ========================================
@@ -11699,14 +11687,14 @@
       font-weight: 500;
       padding: 0.4em;
       transition: all 0.2s ease;
-      border-bottom: 2px solid;
+      border-bottom: 2px solid transparent;
       background: linear-gradient(to bottom, #f8f8f844, #f0f0f0);
   }
   
-  /* Double-line effect when folded */
+  /* Show border only when folded (content is hidden) */
   .fold-bar-section.folded {
-      border-bottom-style: double;
-      border-bottom-width: 4px;
+      border-bottom-style: solid;
+      border-bottom-width: 2px;
   }
   
   .fold-bar-section:hover {
@@ -11745,92 +11733,86 @@
       font-size: 0.9em;
   }
   
-  /* Border colors matching message types */
-  .fold-bar[data-border-color="user"] .fold-bar-section,
-  .fold-bar[data-border-color="user compacted"] .fold-bar-section,
-  .fold-bar[data-border-color="user sidechain"] .fold-bar-section,
-  .fold-bar[data-border-color="user compacted sidechain"] .fold-bar-section {
+  /* Border colors matching message types - only shown when folded */
+  .fold-bar[data-border-color="user"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user compacted"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user sidechain"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user compacted sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--user-color);
   }
   
-  .fold-bar[data-border-color="user slash-command"] .fold-bar-section,
-  .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section,
-  .fold-bar[data-border-color="user command-output"] .fold-bar-section {
+  .fold-bar[data-border-color="user slash-command"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user command-output"] .fold-bar-section.folded {
       border-bottom-color: var(--user-dimmed);
   }
   
-  .fold-bar[data-border-color="assistant"] .fold-bar-section,
-  .fold-bar[data-border-color="assistant sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="assistant"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="assistant sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--assistant-color);
   }
   
-  .fold-bar[data-border-color="system"] .fold-bar-section {
+  .fold-bar[data-border-color="system"] .fold-bar-section.folded {
       border-bottom-color: var(--system-color);
   }
   
-  .fold-bar[data-border-color="system-warning"] .fold-bar-section {
+  .fold-bar[data-border-color="system-warning"] .fold-bar-section.folded {
       border-bottom-color: var(--system-warning-color);
   }
   
-  .fold-bar[data-border-color="system-error"] .fold-bar-section {
+  .fold-bar[data-border-color="system-error"] .fold-bar-section.folded {
       border-bottom-color: var(--system-error-color);
   }
   
-  .fold-bar[data-border-color="system-info"] .fold-bar-section {
+  .fold-bar[data-border-color="system-info"] .fold-bar-section.folded {
       border-bottom-color: var(--info-dimmed);
   }
   
-  .fold-bar[data-border-color="tool_use"] .fold-bar-section,
-  .fold-bar[data-border-color="tool_use sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="tool_use"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="tool_use sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--tool-use-color);
   }
   
-  .fold-bar[data-border-color="tool_result"] .fold-bar-section,
-  .fold-bar[data-border-color="tool_result sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="tool_result"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="tool_result sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--success-dimmed);
   }
   
-  .fold-bar[data-border-color="tool_result error"] .fold-bar-section,
-  .fold-bar[data-border-color="tool_result error sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="tool_result error"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="tool_result error sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--error-dimmed);
   }
   
-  .fold-bar[data-border-color="thinking"] .fold-bar-section,
-  .fold-bar[data-border-color="thinking sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="thinking"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="thinking sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--assistant-dimmed);
   }
   
-  .fold-bar[data-border-color="image"] .fold-bar-section,
-  .fold-bar[data-border-color="image sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="image"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="image sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--info-dimmed);
   }
   
-  .fold-bar[data-border-color="unknown"] .fold-bar-section,
-  .fold-bar[data-border-color="unknown sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="unknown"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="unknown sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--neutral-dimmed);
   }
   
-  .fold-bar[data-border-color="bash-input"] .fold-bar-section {
+  .fold-bar[data-border-color="bash-input"] .fold-bar-section.folded {
       border-bottom-color: var(--user-color);
   }
   
-  .fold-bar[data-border-color="bash-output"] .fold-bar-section {
+  .fold-bar[data-border-color="bash-output"] .fold-bar-section.folded {
       border-bottom-color: var(--user-dimmed);
   }
   
-  .fold-bar[data-border-color="session-header"] .fold-bar-section {
+  .fold-bar[data-border-color="session-header"] .fold-bar-section.folded {
       border-bottom-color: var(--system-warning-color);
   }
   
-  /* Sidechain (sub-assistant) fold-bar styling */
-  .sidechain .fold-bar-section {
-      border-bottom-style: dashed;
-      border-bottom-width: 2px;
-  }
-  
+  /* Sidechain (sub-assistant) fold-bar styling - dashed border when folded */
   .sidechain .fold-bar-section.folded {
       border-bottom-style: dashed;
-      border-bottom-width: 4px;
   }
   
   /* ========================================
@@ -16565,14 +16547,14 @@
       font-weight: 500;
       padding: 0.4em;
       transition: all 0.2s ease;
-      border-bottom: 2px solid;
+      border-bottom: 2px solid transparent;
       background: linear-gradient(to bottom, #f8f8f844, #f0f0f0);
   }
   
-  /* Double-line effect when folded */
+  /* Show border only when folded (content is hidden) */
   .fold-bar-section.folded {
-      border-bottom-style: double;
-      border-bottom-width: 4px;
+      border-bottom-style: solid;
+      border-bottom-width: 2px;
   }
   
   .fold-bar-section:hover {
@@ -16611,92 +16593,86 @@
       font-size: 0.9em;
   }
   
-  /* Border colors matching message types */
-  .fold-bar[data-border-color="user"] .fold-bar-section,
-  .fold-bar[data-border-color="user compacted"] .fold-bar-section,
-  .fold-bar[data-border-color="user sidechain"] .fold-bar-section,
-  .fold-bar[data-border-color="user compacted sidechain"] .fold-bar-section {
+  /* Border colors matching message types - only shown when folded */
+  .fold-bar[data-border-color="user"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user compacted"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user sidechain"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user compacted sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--user-color);
   }
   
-  .fold-bar[data-border-color="user slash-command"] .fold-bar-section,
-  .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section,
-  .fold-bar[data-border-color="user command-output"] .fold-bar-section {
+  .fold-bar[data-border-color="user slash-command"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="user command-output"] .fold-bar-section.folded {
       border-bottom-color: var(--user-dimmed);
   }
   
-  .fold-bar[data-border-color="assistant"] .fold-bar-section,
-  .fold-bar[data-border-color="assistant sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="assistant"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="assistant sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--assistant-color);
   }
   
-  .fold-bar[data-border-color="system"] .fold-bar-section {
+  .fold-bar[data-border-color="system"] .fold-bar-section.folded {
       border-bottom-color: var(--system-color);
   }
   
-  .fold-bar[data-border-color="system-warning"] .fold-bar-section {
+  .fold-bar[data-border-color="system-warning"] .fold-bar-section.folded {
       border-bottom-color: var(--system-warning-color);
   }
   
-  .fold-bar[data-border-color="system-error"] .fold-bar-section {
+  .fold-bar[data-border-color="system-error"] .fold-bar-section.folded {
       border-bottom-color: var(--system-error-color);
   }
   
-  .fold-bar[data-border-color="system-info"] .fold-bar-section {
+  .fold-bar[data-border-color="system-info"] .fold-bar-section.folded {
       border-bottom-color: var(--info-dimmed);
   }
   
-  .fold-bar[data-border-color="tool_use"] .fold-bar-section,
-  .fold-bar[data-border-color="tool_use sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="tool_use"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="tool_use sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--tool-use-color);
   }
   
-  .fold-bar[data-border-color="tool_result"] .fold-bar-section,
-  .fold-bar[data-border-color="tool_result sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="tool_result"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="tool_result sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--success-dimmed);
   }
   
-  .fold-bar[data-border-color="tool_result error"] .fold-bar-section,
-  .fold-bar[data-border-color="tool_result error sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="tool_result error"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="tool_result error sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--error-dimmed);
   }
   
-  .fold-bar[data-border-color="thinking"] .fold-bar-section,
-  .fold-bar[data-border-color="thinking sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="thinking"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="thinking sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--assistant-dimmed);
   }
   
-  .fold-bar[data-border-color="image"] .fold-bar-section,
-  .fold-bar[data-border-color="image sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="image"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="image sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--info-dimmed);
   }
   
-  .fold-bar[data-border-color="unknown"] .fold-bar-section,
-  .fold-bar[data-border-color="unknown sidechain"] .fold-bar-section {
+  .fold-bar[data-border-color="unknown"] .fold-bar-section.folded,
+  .fold-bar[data-border-color="unknown sidechain"] .fold-bar-section.folded {
       border-bottom-color: var(--neutral-dimmed);
   }
   
-  .fold-bar[data-border-color="bash-input"] .fold-bar-section {
+  .fold-bar[data-border-color="bash-input"] .fold-bar-section.folded {
       border-bottom-color: var(--user-color);
   }
   
-  .fold-bar[data-border-color="bash-output"] .fold-bar-section {
+  .fold-bar[data-border-color="bash-output"] .fold-bar-section.folded {
       border-bottom-color: var(--user-dimmed);
   }
   
-  .fold-bar[data-border-color="session-header"] .fold-bar-section {
+  .fold-bar[data-border-color="session-header"] .fold-bar-section.folded {
       border-bottom-color: var(--system-warning-color);
   }
   
-  /* Sidechain (sub-assistant) fold-bar styling */
-  .sidechain .fold-bar-section {
-      border-bottom-style: dashed;
-      border-bottom-width: 2px;
-  }
-  
+  /* Sidechain (sub-assistant) fold-bar styling - dashed border when folded */
   .sidechain .fold-bar-section.folded {
       border-bottom-style: dashed;
-      border-bottom-width: 4px;
   }
   
   /* ========================================

From 8a6cc3ae3c57c6f28c299692654e0ddd350df4a1 Mon Sep 17 00:00:00 2001
From: Christian Boos 
Date: Sun, 7 Dec 2025 23:07:13 +0100
Subject: [PATCH 024/102] Add Read tool error sample to emphasize generic error
 handling
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Error tool results can occur with any tool, not just Bash. Added a
Read tool error sample (EISDIR) alongside the existing Bash error
(command not found) to demonstrate that the `is_error: true` flag
triggers the `tool_result error` CSS class regardless of which tool.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 
---
 dev-docs/messages.md                          | 26 ++++++++++++++++---
 .../tools/Read-tool_result_error.json         | 26 +++++++++++++++++++
 .../tools/Read-tool_result_error.jsonl        |  1 +
 3 files changed, 50 insertions(+), 3 deletions(-)
 create mode 100644 dev-docs/messages/tools/Read-tool_result_error.json
 create mode 100644 dev-docs/messages/tools/Read-tool_result_error.jsonl

diff --git a/dev-docs/messages.md b/dev-docs/messages.md
index bac5108e..b74c452a 100644
--- a/dev-docs/messages.md
+++ b/dev-docs/messages.md
@@ -285,13 +285,17 @@ Tool results are contained within `user` messages as `tool_result` content items
 
 - **Input**: `user` with `tool_result` content, `is_error: true`
 - **Intermediate**: `message_type: "tool_result"`, `is_error: true`, `css_class: "tool_result error"`
-- **Files**: [Bash-tool_result_error.json](messages/tools/Bash-tool_result_error.json) | [Bash-tool_result_error.jsonl](messages/tools/Bash-tool_result_error.jsonl)
+- **Files**:
+  - Bash error: [Bash-tool_result_error.json](messages/tools/Bash-tool_result_error.json) | [Bash-tool_result_error.jsonl](messages/tools/Bash-tool_result_error.jsonl)
+  - Read error: [Read-tool_result_error.json](messages/tools/Read-tool_result_error.json) | [Read-tool_result_error.jsonl](messages/tools/Read-tool_result_error.jsonl)
 
+Error results can occur with any tool. The `is_error: true` flag triggers the `tool_result error` CSS class regardless of which tool failed.
+
+**Bash error example** (command not found):
 ```json
 {
   "type": "user",
   "message": {
-    "role": "user",
     "content": [{
       "type": "tool_result",
       "content": "Exit code 127\n/bin/bash: line 1: pytest: command not found",
@@ -299,7 +303,23 @@ Tool results are contained within `user` messages as `tool_result` content items
       "tool_use_id": "toolu_xxx"
     }]
   },
-  "toolUseResult": "Error: Exit code 127\n/bin/bash: line 1: pytest: command not found"
+  "toolUseResult": "Error: Exit code 127..."
+}
+```
+
+**Read error example** (directory instead of file):
+```json
+{
+  "type": "user",
+  "message": {
+    "content": [{
+      "type": "tool_result",
+      "content": "EISDIR: illegal operation on a directory, read",
+      "is_error": true,
+      "tool_use_id": "toolu_xxx"
+    }]
+  },
+  "toolUseResult": "Error: EISDIR: illegal operation on a directory, read"
 }
 ```
 
diff --git a/dev-docs/messages/tools/Read-tool_result_error.json b/dev-docs/messages/tools/Read-tool_result_error.json
new file mode 100644
index 00000000..611d0b92
--- /dev/null
+++ b/dev-docs/messages/tools/Read-tool_result_error.json
@@ -0,0 +1,26 @@
+{
+  "parentUuid": "d6ee300f-5e71-47c3-ac2d-c4aa5c6526e3",
+  "isSidechain": true,
+  "userType": "external",
+  "cwd": "/src/deep-manifest",
+  "sessionId": "a7da6a22-facc-4fcd-8bab-f83c87862004",
+  "version": "2.0.55",
+  "gitBranch": "master",
+  "agentId": "c8d9b115",
+  "slug": "humble-doodling-wolf",
+  "type": "user",
+  "message": {
+    "role": "user",
+    "content": [
+      {
+        "type": "tool_result",
+        "content": "EISDIR: illegal operation on a directory, read",
+        "is_error": true,
+        "tool_use_id": "toolu_019PsYX89dHWK39GLHCS6MVo"
+      }
+    ]
+  },
+  "uuid": "87fa9554-9180-4d41-8e41-6fac9cc2e302",
+  "timestamp": "2025-11-29T15:24:52.265Z",
+  "toolUseResult": "Error: EISDIR: illegal operation on a directory, read"
+}
diff --git a/dev-docs/messages/tools/Read-tool_result_error.jsonl b/dev-docs/messages/tools/Read-tool_result_error.jsonl
new file mode 100644
index 00000000..56b6b756
--- /dev/null
+++ b/dev-docs/messages/tools/Read-tool_result_error.jsonl
@@ -0,0 +1 @@
+{"parentUuid":"d6ee300f-5e71-47c3-ac2d-c4aa5c6526e3","isSidechain":true,"userType":"external","cwd":"/src/deep-manifest","sessionId":"a7da6a22-facc-4fcd-8bab-f83c87862004","version":"2.0.55","gitBranch":"master","agentId":"c8d9b115","slug":"humble-doodling-wolf","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"EISDIR: illegal operation on a directory, read","is_error":true,"tool_use_id":"toolu_019PsYX89dHWK39GLHCS6MVo"}]},"uuid":"87fa9554-9180-4d41-8e41-6fac9cc2e302","timestamp":"2025-11-29T15:24:52.265Z","toolUseResult":"Error: EISDIR: illegal operation on a directory, read"}

From f22162ffbd3c93d2009dbe3ab38b9e75f927e7e5 Mon Sep 17 00:00:00 2001
From: Christian Boos 
Date: Sun, 7 Dec 2025 23:13:49 +0100
Subject: [PATCH 025/102] Simplify extract_text_content() with isinstance
 checks (Phase 10)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Add imports for Anthropic SDK types (TextBlock, ThinkingBlock)
- Replace defensive hasattr/getattr patterns with clean isinstance checks
- 23% code reduction in extract_text_content() (17 → 13 lines)
- Update MESSAGE_REFACTORING.md to mark Phase 10 complete

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 
---
 claude_code_log/parser.py       | 27 ++++++++++-----------
 dev-docs/MESSAGE_REFACTORING.md | 42 ++++++++++++++++++++-------------
 2 files changed, 37 insertions(+), 32 deletions(-)

diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py
index a34ca5af..6e520545 100644
--- a/claude_code_log/parser.py
+++ b/claude_code_log/parser.py
@@ -8,6 +8,9 @@
 from datetime import datetime
 import dateparser
 
+from anthropic.types.text_block import TextBlock
+from anthropic.types.thinking_block import ThinkingBlock
+
 from .models import (
     TranscriptEntry,
     UserTranscriptEntry,
@@ -23,27 +26,21 @@
 
 
 def extract_text_content(content: Union[str, List[ContentItem], None]) -> str:
-    """Extract text content from Claude message content structure (supports both custom and Anthropic types)."""
+    """Extract text content from Claude message content structure.
+
+    Supports both custom models (TextContent, ThinkingContent) and official
+    Anthropic SDK types (TextBlock, ThinkingBlock).
+    """
     if content is None:
         return ""
     if isinstance(content, list):
         text_parts: List[str] = []
         for item in content:
-            # Handle both custom TextContent and official Anthropic TextBlock
-            if isinstance(item, TextContent):
+            # Handle text content (custom TextContent or Anthropic TextBlock)
+            if isinstance(item, (TextContent, TextBlock)):
                 text_parts.append(item.text)
-            elif (
-                hasattr(item, "type")
-                and hasattr(item, "text")
-                and getattr(item, "type") == "text"
-            ):
-                # Official Anthropic TextBlock
-                text_parts.append(getattr(item, "text"))
-            elif isinstance(item, ThinkingContent):
-                # Skip thinking content in main text extraction
-                continue
-            elif hasattr(item, "type") and getattr(item, "type") == "thinking":
-                # Skip official Anthropic thinking content too
+            # Skip thinking content (custom ThinkingContent or Anthropic ThinkingBlock)
+            elif isinstance(item, (ThinkingContent, ThinkingBlock)):
                 continue
         return "\n".join(text_parts)
     else:
diff --git a/dev-docs/MESSAGE_REFACTORING.md b/dev-docs/MESSAGE_REFACTORING.md
index b443323e..829345c2 100755
--- a/dev-docs/MESSAGE_REFACTORING.md
+++ b/dev-docs/MESSAGE_REFACTORING.md
@@ -253,27 +253,34 @@ Adds text/markdown/chat output formats via new `content_extractor.py` module.
 
 **Note**: MessageModifiers dataclass deferred - existing boolean flags work well for now
 
-### Phase 10: Parser Simplification
+### Phase 10: Parser Simplification ✅ COMPLETE
 
 **Goal**: Simplify `extract_text_content()` using isinstance checks
 
-**Analysis** (from /tmp/parser_analysis.md):
-- Current code uses defensive `getattr`/`hasattr` for SDK interop
-- All tests pass with simplified isinstance-based approach
-- 23% code reduction possible (17 lines → 13 lines)
+**Completed Work**:
+- ✅ Added imports for Anthropic SDK types: `TextBlock`, `ThinkingBlock`
+- ✅ Simplified `extract_text_content()` with clean isinstance checks
+- ✅ Removed defensive `hasattr`/`getattr` patterns
+- ✅ 23% code reduction (17 lines → 13 lines)
 
-**Proposed Change**:
+**Before** (defensive pattern):
 ```python
-# Import official Anthropic types
-from anthropic.types.text_block import TextBlock
-from anthropic.types.thinking_block import ThinkingBlock
+if hasattr(item, "type") and getattr(item, "type") == "text":
+    text = getattr(item, "text", "")
+    if text:
+        text_parts.append(text)
+```
 
-def extract_text_content(content: Union[str, List[ContentItem], None]) -> str:
-    # ... simplified with isinstance(item, (TextContent, TextBlock))
+**After** (clean isinstance):
+```python
+if isinstance(item, (TextContent, TextBlock)):
+    text_parts.append(item.text)
+elif isinstance(item, (ThinkingContent, ThinkingBlock)):
+    continue
 ```
 
-**Testing Evidence**: All 6 extract_text_content tests pass with simplified version
-**Risk**: Low - maintains same behavior, tested
+**Testing Evidence**: All 431 tests pass with simplified version
+**Risk**: Low - maintains same behavior, fully tested
 
 ### Phase 11: Tool Model Enhancement
 
@@ -369,7 +376,7 @@ For maximum impact with minimum risk:
 7. ✅ **Phase 9 (Type Safety)** - MessageType enum and type guards added
 
 ### Next Steps
-8. **Phase 10 (Parser)** - Low risk, tested simplification
+8. ✅ **Phase 10 (Parser)** - Simplified extract_text_content() with isinstance checks
 9. **Phase 11 (Tool Models)** - Lower priority, current approach works
 10. **Phase 12 (Format Neutral)** - Long-term goal, enables multi-format output
 
@@ -385,15 +392,16 @@ For maximum impact with minimum risk:
 
 ## Metrics to Track
 
-| Metric | Baseline (v0.9) | Current (Phase 9 done) | Target |
-|--------|-----------------|------------------------|--------|
+| Metric | Baseline (v0.9) | Current (Phase 10 done) | Target |
+|--------|-----------------|-------------------------|--------|
 | renderer.py lines | 4246 | 3853 | <3000 |
 | Largest function | ~687 lines | ~460 lines | <100 lines |
 | `_identify_message_pairs()` | ~120 lines | ~37 lines | - |
+| `extract_text_content()` | ~17 lines | ~13 lines | - |
 | Module count | 3 (renderer, timings, models) | 5 (+ansi_colors, +renderer_code) | 6-7 |
 | Test coverage | ~78% | ~78% | >85% |
 
-**Progress**: Main loop reduced by 33% (687 → 460 lines). Pairing function reduced by 69% (120 → 37 lines). MessageType enum and type guards added for improved type safety.
+**Progress**: Main loop reduced by 33% (687 → 460 lines). Pairing function reduced by 69% (120 → 37 lines). MessageType enum and type guards added. Parser simplified with isinstance checks (Phase 10).
 
 ## Quality Gates
 

From 9206ae26995da18c8d947f84dda8338d285c423d Mon Sep 17 00:00:00 2001
From: Christian Boos 
Date: Sun, 7 Dec 2025 23:22:49 +0100
Subject: [PATCH 026/102] Add typed input models for 9 common tools (Phase 11)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Add BashInput, ReadInput, WriteInput, EditInput, MultiEditInput
- Add GlobInput, GrepInput, TaskInput, TodoWriteInput
- Create ToolInput union type for type-safe tool input handling
- Add TOOL_INPUT_MODELS mapping and parse_tool_input() helper
- Maintain backward compatibility (ToolUseContent.input stays Dict)
- Update MESSAGE_REFACTORING.md to mark Phase 11 complete

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 
---
 claude_code_log/models.py       | 153 ++++++++++++++++++++++++++++++++
 dev-docs/MESSAGE_REFACTORING.md |  47 +++++-----
 2 files changed, 178 insertions(+), 22 deletions(-)

diff --git a/claude_code_log/models.py b/claude_code_log/models.py
index 5b4ec2ec..d81625de 100644
--- a/claude_code_log/models.py
+++ b/claude_code_log/models.py
@@ -58,6 +58,159 @@ class TodoItem(BaseModel):
     priority: Literal["high", "medium", "low"]
 
 
+# =============================================================================
+# Tool Input Models
+# =============================================================================
+# Typed models for tool inputs (Phase 11 of MESSAGE_REFACTORING.md)
+# These provide type safety and IDE autocompletion for tool parameters.
+
+
+class BashInput(BaseModel):
+    """Input parameters for the Bash tool."""
+
+    command: str
+    description: Optional[str] = None
+    timeout: Optional[int] = None
+    run_in_background: Optional[bool] = None
+    dangerouslyDisableSandbox: Optional[bool] = None
+
+
+class ReadInput(BaseModel):
+    """Input parameters for the Read tool."""
+
+    file_path: str
+    offset: Optional[int] = None
+    limit: Optional[int] = None
+
+
+class WriteInput(BaseModel):
+    """Input parameters for the Write tool."""
+
+    file_path: str
+    content: str
+
+
+class EditInput(BaseModel):
+    """Input parameters for the Edit tool."""
+
+    file_path: str
+    old_string: str
+    new_string: str
+    replace_all: Optional[bool] = None
+
+
+class EditItem(BaseModel):
+    """Single edit item for MultiEdit tool."""
+
+    old_string: str
+    new_string: str
+
+
+class MultiEditInput(BaseModel):
+    """Input parameters for the MultiEdit tool."""
+
+    file_path: str
+    edits: List[EditItem]
+
+
+class GlobInput(BaseModel):
+    """Input parameters for the Glob tool."""
+
+    pattern: str
+    path: Optional[str] = None
+
+
+class GrepInput(BaseModel):
+    """Input parameters for the Grep tool.
+
+    Note: Extra fields like -A, -B, -C are allowed for flexibility.
+    """
+
+    pattern: str
+    path: Optional[str] = None
+    glob: Optional[str] = None
+    type: Optional[str] = None
+    output_mode: Optional[Literal["content", "files_with_matches", "count"]] = None
+    multiline: Optional[bool] = None
+    head_limit: Optional[int] = None
+    offset: Optional[int] = None
+
+    model_config = {"extra": "allow"}  # Allow -A, -B, -C, -i, -n fields
+
+
+class TaskInput(BaseModel):
+    """Input parameters for the Task tool."""
+
+    prompt: str
+    subagent_type: str
+    description: str
+    model: Optional[Literal["sonnet", "opus", "haiku"]] = None
+    run_in_background: Optional[bool] = None
+    resume: Optional[str] = None
+
+
+class TodoWriteItem(BaseModel):
+    """Single todo item for TodoWrite tool (input format)."""
+
+    content: str
+    status: Literal["pending", "in_progress", "completed"]
+    activeForm: str
+
+
+class TodoWriteInput(BaseModel):
+    """Input parameters for the TodoWrite tool."""
+
+    todos: List[TodoWriteItem]
+
+
+# Union of all typed tool inputs
+ToolInput = Union[
+    BashInput,
+    ReadInput,
+    WriteInput,
+    EditInput,
+    MultiEditInput,
+    GlobInput,
+    GrepInput,
+    TaskInput,
+    TodoWriteInput,
+    Dict[str, Any],  # Fallback for unknown tools
+]
+
+# Mapping of tool names to their typed input models
+TOOL_INPUT_MODELS: Dict[str, type[BaseModel]] = {
+    "Bash": BashInput,
+    "Read": ReadInput,
+    "Write": WriteInput,
+    "Edit": EditInput,
+    "MultiEdit": MultiEditInput,
+    "Glob": GlobInput,
+    "Grep": GrepInput,
+    "Task": TaskInput,
+    "TodoWrite": TodoWriteInput,
+}
+
+
+def parse_tool_input(tool_name: str, input_data: Dict[str, Any]) -> ToolInput:
+    """Parse tool input dictionary into a typed model if available.
+
+    Args:
+        tool_name: The name of the tool (e.g., "Bash", "Read")
+        input_data: The raw input dictionary from the tool_use content
+
+    Returns:
+        A typed input model if available, otherwise the original dictionary
+    """
+    model_class = TOOL_INPUT_MODELS.get(tool_name)
+    if model_class is not None:
+        try:
+            return cast(ToolInput, model_class.model_validate(input_data))
+        except Exception:
+            # Fall back to raw dict if validation fails
+            return input_data
+    return input_data
+
+
 class UsageInfo(BaseModel):
     """Token usage information that extends Anthropic's Usage type to handle optional fields."""
 
diff --git a/dev-docs/MESSAGE_REFACTORING.md b/dev-docs/MESSAGE_REFACTORING.md
index 829345c2..60205210 100755
--- a/dev-docs/MESSAGE_REFACTORING.md
+++ b/dev-docs/MESSAGE_REFACTORING.md
@@ -282,40 +282,42 @@ elif isinstance(item, (ThinkingContent, ThinkingBlock)):
 **Testing Evidence**: All 431 tests pass with simplified version
 **Risk**: Low - maintains same behavior, fully tested
 
-### Phase 11: Tool Model Enhancement
+### Phase 11: Tool Model Enhancement ✅ COMPLETE
 
 **Goal**: Add typed models for tool inputs (currently all generic `Dict[str, Any]`)
 
-**Current State** (from /tmp/models_analysis.md):
-- **16 tools** with samples in dev-docs/messages/tools/
-- **ALL tools** use generic `ToolUseContent.input: Dict[str, Any]`
-- Only **4 tools** have specialized result models:
-  - Read → `FileReadResult`
-  - Bash → `CommandResult`
-  - Edit → `EditResult`
-  - TodoWrite → `TodoResult`
-
-**Proposed Typed Input Models**:
+**Completed Work**:
+- ✅ Added 9 typed input models to `models.py`:
+  - `BashInput`, `ReadInput`, `WriteInput`, `EditInput`, `MultiEditInput`
+  - `GlobInput`, `GrepInput`, `TaskInput`, `TodoWriteInput`
+- ✅ Created `ToolInput` union type for type-safe tool input handling
+- ✅ Added `TOOL_INPUT_MODELS` mapping for tool name → model class lookup
+- ✅ Added `parse_tool_input()` helper function with fallback to raw dict
+
+**Typed Input Models Added**:
 ```python
-class ReadInput(BaseModel):
-    file_path: str
-    limit: Optional[int] = None
-    offset: Optional[int] = None
-
 class BashInput(BaseModel):
     command: str
     description: Optional[str] = None
     timeout: Optional[int] = None
+    run_in_background: Optional[bool] = None
+    dangerouslyDisableSandbox: Optional[bool] = None
+
+class ReadInput(BaseModel):
+    file_path: str
+    offset: Optional[int] = None
+    limit: Optional[int] = None
 
 class EditInput(BaseModel):
     file_path: str
     old_string: str
     new_string: str
-    replace_all: bool = False
+    replace_all: Optional[bool] = None
 ```
 
-**Risk**: Medium - requires careful migration
-**Priority**: Low - current generic approach works
+**Note**: The `ToolUseContent.input` field remains `Dict[str, Any]` for backward compatibility.
+The new typed models are available for optional use via `parse_tool_input()`. Existing
+code continues to work unchanged with dictionary access.
 
 ### Phase 12: Renderer Decomposition - Format Neutral
 
@@ -377,7 +379,7 @@ For maximum impact with minimum risk:
 
 ### Next Steps
 8. ✅ **Phase 10 (Parser)** - Simplified extract_text_content() with isinstance checks
-9. **Phase 11 (Tool Models)** - Lower priority, current approach works
+9. ✅ **Phase 11 (Tool Models)** - Added typed input models for 9 common tools
 10. **Phase 12 (Format Neutral)** - Long-term goal, enables multi-format output
 
 **Tree Refactoring Integration:**
@@ -392,16 +394,17 @@ For maximum impact with minimum risk:
 
 ## Metrics to Track
 
-| Metric | Baseline (v0.9) | Current (Phase 10 done) | Target |
+| Metric | Baseline (v0.9) | Current (Phase 11 done) | Target |
 |--------|-----------------|-------------------------|--------|
 | renderer.py lines | 4246 | 3853 | <3000 |
 | Largest function | ~687 lines | ~460 lines | <100 lines |
 | `_identify_message_pairs()` | ~120 lines | ~37 lines | - |
 | `extract_text_content()` | ~17 lines | ~13 lines | - |
+| Typed tool input models | 0 | 9 | - |
 | Module count | 3 (renderer, timings, models) | 5 (+ansi_colors, +renderer_code) | 6-7 |
 | Test coverage | ~78% | ~78% | >85% |
 
-**Progress**: Main loop reduced by 33% (687 → 460 lines). Pairing function reduced by 69% (120 → 37 lines). MessageType enum and type guards added. Parser simplified with isinstance checks (Phase 10).
+**Progress**: Main loop reduced by 33% (687 → 460 lines). Pairing function reduced by 69% (120 → 37 lines). MessageType enum and type guards added. Parser simplified with isinstance checks (Phase 10). 9 typed tool input models added (Phase 11).
 
 ## Quality Gates
 

From e318be5d0cd17acfa805ab742bfef65faa68de2d Mon Sep 17 00:00:00 2001
From: Christian Boos 
Date: Sun, 7 Dec 2025 23:37:36 +0100
Subject: [PATCH 027/102] Document Phase 11/12 independence and add typed input
 models to tool table
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Add clarification in MESSAGE_REFACTORING.md that Phase 11 (typed tool inputs)
  and Phase 12 (format-neutral decomposition) are independent improvements
- Update Available Tool Samples table in messages.md to show typed input models
  in italics (BashInput, ReadInput, WriteInput, etc.)
- Update table note explaining the italic format indicates models available
  via parse_tool_input() but not yet used in renderer

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 
---
 dev-docs/MESSAGE_REFACTORING.md |  6 ++++++
 dev-docs/messages.md            | 20 ++++++++++----------
 2 files changed, 16 insertions(+), 10 deletions(-)

diff --git a/dev-docs/MESSAGE_REFACTORING.md b/dev-docs/MESSAGE_REFACTORING.md
index 60205210..0f9f8add 100755
--- a/dev-docs/MESSAGE_REFACTORING.md
+++ b/dev-docs/MESSAGE_REFACTORING.md
@@ -319,6 +319,12 @@ class EditInput(BaseModel):
 The new typed models are available for optional use via `parse_tool_input()`. Existing
 code continues to work unchanged with dictionary access.
 
+**Independence from Phase 12**: Phase 11 and Phase 12 are independent improvements.
+Phase 12 focuses on architectural decomposition (splitting renderer.py into format-neutral
+and format-specific modules), while Phase 11 provides typed tool input models as an
+optional type-safety enhancement. The typed models can be adopted incrementally by any
+code that wants to use them, independent of the format-neutral refactoring.
+
 ### Phase 12: Renderer Decomposition - Format Neutral
 
 **Goal**: Separate format-neutral logic from HTML-specific generation
diff --git a/dev-docs/messages.md b/dev-docs/messages.md
index b74c452a..207290d6 100644
--- a/dev-docs/messages.md
+++ b/dev-docs/messages.md
@@ -590,23 +590,23 @@ Tools are invoked via `tool_use` content items in assistant messages, with resul
 | Tool | Category | Use | Result | Input Model | Result Model |
 |------|----------|-----|--------|-------------|--------------|
 | AskUserQuestion | Agent | [tool_use](messages/tools/AskUserQuestion-tool_use.json) | [tool_result](messages/tools/AskUserQuestion-tool_result.json) | Generic | `str` |
-| Bash | Shell | [tool_use](messages/tools/Bash-tool_use.json) | [tool_result](messages/tools/Bash-tool_result.json) | Generic | `CommandResult` |
+| Bash | Shell | [tool_use](messages/tools/Bash-tool_use.json) | [tool_result](messages/tools/Bash-tool_result.json) | *`BashInput`* | `CommandResult` |
 | BashOutput | Shell | [tool_use](messages/tools/BashOutput-tool_use.json) | [tool_result](messages/tools/BashOutput-tool_result.json) | Generic | `str` |
-| Edit | File | [tool_use](messages/tools/Edit-tool_use.json) | [tool_result](messages/tools/Edit-tool_result.json) | Generic | `EditResult` |
+| Edit | File | [tool_use](messages/tools/Edit-tool_use.json) | [tool_result](messages/tools/Edit-tool_result.json) | *`EditInput`* | `EditResult` |
 | ExitPlanMode | Agent | [tool_use](messages/tools/ExitPlanMode-tool_use.json) | [tool_result](messages/tools/ExitPlanMode-tool_result.json) | Generic | `str` |
-| Glob | File | [tool_use](messages/tools/Glob-tool_use.json) | [tool_result](messages/tools/Glob-tool_result.json) | Generic | `str` |
-| Grep | File | [tool_use](messages/tools/Grep-tool_use.json) | [tool_result](messages/tools/Grep-tool_result.json) | Generic | `str` |
+| Glob | File | [tool_use](messages/tools/Glob-tool_use.json) | [tool_result](messages/tools/Glob-tool_result.json) | *`GlobInput`* | `str` |
+| Grep | File | [tool_use](messages/tools/Grep-tool_use.json) | [tool_result](messages/tools/Grep-tool_result.json) | *`GrepInput`* | `str` |
 | KillShell | Shell | [tool_use](messages/tools/KillShell-tool_use.json) | [tool_result](messages/tools/KillShell-tool_result.json) | Generic | `str` |
 | LS | File | [tool_use](messages/tools/LS-tool_use.json) | [tool_result](messages/tools/LS-tool_result.json) | Generic | `str` |
-| MultiEdit | File | [tool_use](messages/tools/MultiEdit-tool_use.json) | [tool_result](messages/tools/MultiEdit-tool_result.json) | Generic | `str` |
-| Read | File | [tool_use](messages/tools/Read-tool_use.json) | [tool_result](messages/tools/Read-tool_result.json) | Generic | `FileReadResult` |
-| Task | Agent | [tool_use](messages/tools/Task-tool_use.json) | [tool_result](messages/tools/Task-tool_result.json) | Generic | `str` |
-| TodoWrite | Agent | [tool_use](messages/tools/TodoWrite-tool_use.json) | [tool_result](messages/tools/TodoWrite-tool_result.json) | Generic | `TodoResult` |
+| MultiEdit | File | [tool_use](messages/tools/MultiEdit-tool_use.json) | [tool_result](messages/tools/MultiEdit-tool_result.json) | *`MultiEditInput`* | `str` |
+| Read | File | [tool_use](messages/tools/Read-tool_use.json) | [tool_result](messages/tools/Read-tool_result.json) | *`ReadInput`* | `FileReadResult` |
+| Task | Agent | [tool_use](messages/tools/Task-tool_use.json) | [tool_result](messages/tools/Task-tool_result.json) | *`TaskInput`* | `str` |
+| TodoWrite | Agent | [tool_use](messages/tools/TodoWrite-tool_use.json) | [tool_result](messages/tools/TodoWrite-tool_result.json) | *`TodoWriteInput`* | `TodoResult` |
 | WebFetch | Web | [tool_use](messages/tools/WebFetch-tool_use.json) | [tool_result](messages/tools/WebFetch-tool_result.json) | Generic | `str` |
 | WebSearch | Web | [tool_use](messages/tools/WebSearch-tool_use.json) | [tool_result](messages/tools/WebSearch-tool_result.json) | Generic | `str` |
-| Write | File | [tool_use](messages/tools/Write-tool_use.json) | [tool_result](messages/tools/Write-tool_result.json) | Generic | `str` |
+| Write | File | [tool_use](messages/tools/Write-tool_use.json) | [tool_result](messages/tools/Write-tool_result.json) | *`WriteInput`* | `str` |
 
-**Note**: All tools use generic `ToolUseContent` with `Dict[str, Any]` for input. Only 4 tools have specialized result models in `models.py`: `FileReadResult` (Read), `CommandResult` (Bash), `EditResult` (Edit), `TodoResult` (TodoWrite). See [models.py](../claude_code_log/models.py) for model definitions.
+**Note**: `ToolUseContent.input` remains `Dict[str, Any]` for backward compatibility. Input models shown in *italics* are available via `parse_tool_input()` but not yet used in the renderer. Only 4 tools have specialized result models: `FileReadResult` (Read), `CommandResult` (Bash), `EditResult` (Edit), `TodoResult` (TodoWrite). See [models.py](../claude_code_log/models.py) for model definitions.
 
 ---
 

From 9fc826083d6d817a4b7844f6780f8e38091781dd Mon Sep 17 00:00:00 2001
From: Christian Boos 
Date: Sun, 7 Dec 2025 23:41:49 +0100
Subject: [PATCH 028/102] Fix pairing documentation for slash command/command
 output
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The command output messages use css_class "user command-output", not
"system command-output". Update messages.md to reflect the correct
pairing behavior:

- Traits table: system command-output → user command-output
- Pairing Patterns: system (command) + system (command-output) →
  user (slash-command) + user (command-output)
- Pairing Rules: user can now be pair_first (slash-command) or
  pair_last (command-output)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 
---
 dev-docs/messages.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/dev-docs/messages.md b/dev-docs/messages.md
index 207290d6..f72b94bd 100644
--- a/dev-docs/messages.md
+++ b/dev-docs/messages.md
@@ -122,7 +122,7 @@ The `css_class` field encodes the base type plus modifier traits:
 | `"system system-info"` | system | level=info |
 | `"system system-warning"` | system | level=warning |
 | `"system system-error"` | system | level=error |
-| `"system command-output"` | system | command output |
+| `"user command-output"` | user | command output |
 | `"system system-hook"` | system | hook summary |
 
 **Note**: See [css-classes.md](css-classes.md) for complete CSS support status. Some combinations (7 partial, 1 none) inherit styling from parent selectors.
@@ -519,7 +519,7 @@ Related messages are paired together for visual grouping. Pairing uses CSS class
 | `tool_use` | `tool_result` | `tool_use_id` field |
 | `bash-input` | `bash-output` | Sequential (from Bash tool) |
 | `thinking` | `assistant` | Sequential (same response) |
-| `system` (command) | `system` (command-output) | Sequential |
+| `user` (slash-command) | `user` (command-output) | Sequential |
 | `system` (system-info) | `system` (system-info) | Paired info |
 
 ### Pairing Rules by Type
@@ -533,7 +533,7 @@ Related messages are paired together for visual grouping. Pairing uses CSS class
 | `thinking` | Yes | No |
 | `tool_result` | No | Yes |
 | `tool_use` | Yes | No |
-| `user` | No | Yes |
+| `user` | Yes (slash-command) | Yes (command-output) |
 
 ### Pairing Metadata
 

From 90c14a7d8160aac15e931c6981e79480b8a20397 Mon Sep 17 00:00:00 2001
From: Christian Boos 
Date: Mon, 8 Dec 2025 07:23:53 +0100
Subject: [PATCH 029/102] Phase 12a: Add MessageModifiers dataclass and
 modifiers field
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Add format-neutral MessageModifiers dataclass to models.py with:
- is_sidechain, is_slash_command, is_command_output
- is_compacted, is_error, is_steering
- system_level (info/warning/error/hook)

Add modifiers field to TemplateMessage with default empty MessageModifiers.
The css_class field is retained for backward compatibility.

This is the first step in Phase 12 format-neutral decomposition.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 
---
 claude_code_log/models.py   | 23 +++++++++++++++++++++++
 claude_code_log/renderer.py |  3 +++
 2 files changed, 26 insertions(+)

diff --git a/claude_code_log/models.py b/claude_code_log/models.py
index d81625de..5598c9c0 100644
--- a/claude_code_log/models.py
+++ b/claude_code_log/models.py
@@ -3,6 +3,7 @@
 Enhanced to leverage official Anthropic types where beneficial.
 """
 
+from dataclasses import dataclass
 from enum import Enum
 from typing import Any, List, Union, Optional, Dict, Literal, cast, TypeGuard
 
@@ -51,6 +52,28 @@ class MessageType(str, Enum):
     SYSTEM_ERROR = "system-error"
 
 
+@dataclass
+class MessageModifiers:
+    """Semantic modifiers that affect message display.
+
+    These are format-neutral flags that renderers can use to determine
+    how to display a message. HTML renderer converts these to CSS classes,
+    text renderer might use them for indentation or formatting.
+
+    The modifiers capture traits that were previously encoded in the
+    css_class string (e.g., "user sidechain slash-command").
+    """
+
+    is_sidechain: bool = False
+    is_slash_command: bool = False
+    is_command_output: bool = False
+    is_compacted: bool = False
+    is_error: bool = False
+    is_steering: bool = False
+    # System message level (mutually exclusive: info, warning, error, hook)
+    system_level: Optional[str] = None
+
+
 class TodoItem(BaseModel):
     id: str
     content: str
diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py
index 53f4b9b7..6f9f22b6 100644
--- a/claude_code_log/renderer.py
+++ b/claude_code_log/renderer.py
@@ -16,6 +16,7 @@
 from jinja2 import Environment, FileSystemLoader, select_autoescape
 
 from .models import (
+    MessageModifiers,
     MessageType,
     TranscriptEntry,
     AssistantTranscriptEntry,
@@ -1554,11 +1555,13 @@ def __init__(
         uuid: Optional[str] = None,
         parent_uuid: Optional[str] = None,
         agent_id: Optional[str] = None,
+        modifiers: Optional[MessageModifiers] = None,
     ):
         self.type = message_type
         self.content_html = content_html
         self.formatted_timestamp = formatted_timestamp
         self.css_class = css_class
+        self.modifiers = modifiers if modifiers is not None else MessageModifiers()
         self.raw_timestamp = raw_timestamp
         # Display title for message header (capitalized, with decorations)
         self.message_title = (

From 5a31556fe48607d46b763e62dfa827dabc4a835c Mon Sep 17 00:00:00 2001
From: Christian Boos 
Date: Mon, 8 Dec 2025 11:45:54 +0100
Subject: [PATCH 030/102] Phase 12e: Remove css_class field from
 TemplateMessage
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Complete the format-neutral decomposition by removing the css_class
field from TemplateMessage. Messages now use MessageModifiers for
format-neutral representation, with css_class_from_message() in
html_renderer.py reconstructing CSS classes for HTML rendering.

Changes:
- Remove css_class parameter from TemplateMessage.__init__
- Remove css_class field from ToolItemResult dataclass
- Add is_error field to ToolItemResult for tool_result error state
- Update tool item processing to build MessageModifiers directly
- Remove all css_class= arguments from TemplateMessage creation sites
- Update tests to remove css_class= parameters
- Update snapshot tests

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude 
---
 claude_code_log/html_renderer.py           |  86 ++++++++++++++++
 claude_code_log/renderer.py                | 109 ++++++++++++---------
 claude_code_log/templates/transcript.html  |  17 ++--
 test/__snapshots__/test_snapshot_html.ambr |   8 +-
 test/test_template_data.py                 |   8 --
 5 files changed, 161 insertions(+), 67 deletions(-)
 create mode 100644 claude_code_log/html_renderer.py

diff --git a/claude_code_log/html_renderer.py b/claude_code_log/html_renderer.py
new file mode 100644
index 00000000..ef9a7006
--- /dev/null
+++ b/claude_code_log/html_renderer.py
@@ -0,0 +1,86 @@
+"""HTML-specific rendering utilities.
+
+This module contains all HTML generation code:
+- CSS class computation from message type and modifiers
+- Message emoji generation
+- (Future: HTML escaping, markdown rendering, tool formatters)
+
+The functions here transform format-neutral TemplateMessage data into
+HTML-specific attributes like CSS classes and display emojis.
+"""
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from .renderer import TemplateMessage
+
+
+def css_class_from_message(msg: "TemplateMessage") -> str:
+    """Generate CSS class string from message type and modifiers.
+
+    This reconstructs the original css_class format for backward
+    compatibility with existing CSS and JavaScript.
+
+    The order of classes follows the original pattern:
+    1. Message type (required)
+    2. Modifier flags in order: slash-command, command-output, compacted,
+       error, steering, sidechain
+    3. System level suffix (e.g., "system-info", "system-warning")
+
+    Args:
+        msg: The template message to generate CSS classes for
+
+    Returns:
+        Space-separated CSS class string (e.g., "user slash-command sidechain")
+    """
+    parts = [msg.type]
+
+    mods = msg.modifiers
+    if mods.is_slash_command:
+        parts.append("slash-command")
+    if mods.is_command_output:
+        parts.append("command-output")
+    if mods.is_compacted:
+        parts.append("compacted")
+    if mods.is_error:
+        parts.append("error")
+    if mods.is_steering:
+        parts.append("steering")
+    if mods.is_sidechain:
+        parts.append("sidechain")
+    if mods.system_level:
+        parts.append(f"system-{mods.system_level}")
+
+    return " ".join(parts)
+
+
+def get_message_emoji(msg: "TemplateMessage") -> str:
+    """Return appropriate emoji for message type.
+
+    Args:
+        msg: The template message to get emoji for
+
+    Returns:
+        Emoji string for the message type, or empty string if no emoji
+    """
+    msg_type = msg.type
+
+    if msg_type == "session_header":
+        return "📋"
+    elif msg_type == "user":
+        return "🤷"
+    elif msg_type == "assistant":
+        return "🤖"
+    elif msg_type == "system":
+        return "⚙️"
+    elif msg_type == "tool_use":
+        return "🛠️"
+    elif msg_type == "tool_result":
+        if msg.modifiers.is_error:
+            return "🚨"
+        return "🧰"
+    elif msg_type == "thinking":
+        return "💭"
+    elif msg_type == "image":
+        return "🖼️"
+    return ""
diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py
index 6f9f22b6..d2888b4c 100644
--- a/claude_code_log/renderer.py
+++ b/claude_code_log/renderer.py
@@ -1531,6 +1531,23 @@ def _format_type_counts(type_counts: dict[str, int]) -> str:
         return f"{parts[0]}, {parts[1]}, {remaining} more"
 
 
+def _modifiers_from_css_class(css_class: str) -> MessageModifiers:
+    """Parse CSS class string into MessageModifiers.
+
+    This is a temporary bridge function during migration from css_class to modifiers.
+    It parses the space-separated CSS class string back into structured modifiers.
+    """
+    classes = css_class.split()
+    return MessageModifiers(
+        is_sidechain="sidechain" in classes,
+        is_slash_command="slash-command" in classes,
+        is_command_output="command-output" in classes,
+        is_compacted="compacted" in classes,
+        is_error="error" in classes,
+        is_steering="steering" in classes,
+    )
+
+
 class TemplateMessage:
     """Structured message data for template rendering."""
 
@@ -1539,7 +1556,6 @@ def __init__(
         message_type: str,
         content_html: str,
         formatted_timestamp: str,
-        css_class: str,
         raw_timestamp: Optional[str] = None,
         session_summary: Optional[str] = None,
         session_id: Optional[str] = None,
@@ -1560,7 +1576,6 @@ def __init__(
         self.type = message_type
         self.content_html = content_html
         self.formatted_timestamp = formatted_timestamp
-        self.css_class = css_class
         self.modifiers = modifiers if modifiers is not None else MessageModifiers()
         self.raw_timestamp = raw_timestamp
         # Display title for message header (capitalized, with decorations)
@@ -2128,7 +2143,6 @@ def _process_system_message(
         message_type="system",
         content_html=content_html,
         formatted_timestamp=formatted_timestamp,
-        css_class=level_css,
         raw_timestamp=timestamp,
         session_id=session_id,
         message_title=f"System {level.title()}",
@@ -2136,6 +2150,7 @@ def _process_system_message(
         ancestry=[],  # Will be assigned by _build_message_hierarchy
         uuid=message.uuid,
         parent_uuid=parent_uuid,
+        modifiers=MessageModifiers(system_level=level),
     )
 
 
@@ -2145,11 +2160,11 @@ class ToolItemResult:
 
     message_type: str
     content_html: str
-    css_class: str
     message_title: str
     tool_use_id: Optional[str] = None
     title_hint: Optional[str] = None
     pending_dedup: Optional[str] = None  # For Task result deduplication
+    is_error: bool = False  # For tool_result error state
 
 
 def _process_tool_use_item(
@@ -2239,7 +2254,6 @@ def _process_tool_use_item(
     return ToolItemResult(
         message_type="tool_use",
         content_html=tool_content_html,
-        css_class="tool_use",
         message_title=tool_message_title,
         tool_use_id=item_tool_use_id,
         title_hint=tool_title_hint,
@@ -2306,16 +2320,15 @@ def _process_tool_result_item(
     escaped_id = escape_html(tool_result.tool_use_id)
     tool_title_hint = f"ID: {escaped_id}"
     tool_message_title = "Error" if tool_result.is_error else ""
-    tool_css_class = "tool_result error" if tool_result.is_error else "tool_result"
 
     return ToolItemResult(
         message_type="tool_result",
         content_html=tool_content_html,
-        css_class=tool_css_class,
         message_title=tool_message_title,
         tool_use_id=tool_result.tool_use_id,
         title_hint=tool_title_hint,
         pending_dedup=pending_dedup,
+        is_error=tool_result.is_error,
     )
 
 
@@ -2337,7 +2350,6 @@ def _process_thinking_item(tool_item: ContentItem) -> Optional[ToolItemResult]:
     return ToolItemResult(
         message_type="thinking",
         content_html=format_thinking_content(thinking),
-        css_class="thinking",
         message_title="Thinking",
     )
 
@@ -2356,7 +2368,6 @@ def _process_image_item(tool_item: ContentItem) -> Optional[ToolItemResult]:
     return ToolItemResult(
         message_type="image",
         content_html=format_image_content(tool_item),
-        css_class="image",
         message_title="Image",
     )
 
@@ -2403,17 +2414,17 @@ def _build_pairing_indices(messages: List[TemplateMessage]) -> PairingIndices:
         # Index tool_use and tool_result by (session_id, tool_use_id)
         if msg.tool_use_id and msg.session_id:
             key = (msg.session_id, msg.tool_use_id)
-            if "tool_use" in msg.css_class:
+            if msg.type == "tool_use":
                 tool_use_index[key] = i
-            elif "tool_result" in msg.css_class:
+            elif msg.type == "tool_result":
                 tool_result_index[key] = i
 
         # Index system messages by UUID for parent-child pairing
-        if msg.uuid and "system" in msg.css_class:
+        if msg.uuid and msg.type == "system":
             uuid_index[msg.uuid] = i
 
         # Index slash-command user messages by parent_uuid
-        if msg.parent_uuid and "slash-command" in msg.css_class:
+        if msg.parent_uuid and msg.modifiers.is_slash_command:
             slash_command_by_parent[msg.parent_uuid] = i
 
     return PairingIndices(
@@ -2446,17 +2457,17 @@ def _try_pair_adjacent(
     - thinking + assistant
     """
     # Slash command + command output (both are user messages)
-    if "slash-command" in current.css_class and "command-output" in next_msg.css_class:
+    if current.modifiers.is_slash_command and next_msg.modifiers.is_command_output:
         _mark_pair(current, next_msg)
         return True
 
     # Bash input + bash output
-    if current.css_class == "bash-input" and next_msg.css_class == "bash-output":
+    if current.type == "bash-input" and next_msg.type == "bash-output":
         _mark_pair(current, next_msg)
         return True
 
     # Thinking + assistant
-    if "thinking" in current.css_class and "assistant" in next_msg.css_class:
+    if current.type == "thinking" and next_msg.type == "assistant":
         _mark_pair(current, next_msg)
         return True
 
@@ -2476,20 +2487,20 @@ def _try_pair_by_index(
     - system + slash-command (by uuid -> parent_uuid)
     """
     # Tool use + tool result (by tool_use_id within same session)
-    if "tool_use" in current.css_class and current.tool_use_id and current.session_id:
+    if current.type == "tool_use" and current.tool_use_id and current.session_id:
         key = (current.session_id, current.tool_use_id)
         if key in indices.tool_result:
             result_msg = messages[indices.tool_result[key]]
             _mark_pair(current, result_msg)
 
     # System child message finding its parent (by parent_uuid)
-    if "system" in current.css_class and current.parent_uuid:
+    if current.type == "system" and current.parent_uuid:
         if current.parent_uuid in indices.uuid:
             parent_msg = messages[indices.uuid[current.parent_uuid]]
             _mark_pair(parent_msg, current)
 
     # System command finding its slash-command child (by uuid -> parent_uuid)
-    if "system" in current.css_class and current.uuid:
+    if current.type == "system" and current.uuid:
         if current.uuid in indices.slash_command_by_parent:
             slash_msg = messages[indices.slash_command_by_parent[current.uuid]]
             _mark_pair(current, slash_msg)
@@ -2570,7 +2581,7 @@ def _reorder_paired_messages(messages: List[TemplateMessage]) -> List[TemplateMe
             msg.is_paired
             and msg.pair_role == "pair_last"
             and msg.parent_uuid
-            and "slash-command" in msg.css_class
+            and msg.modifiers.is_slash_command
         ):
             slash_command_pair_index[msg.parent_uuid] = i
 
@@ -2671,8 +2682,8 @@ def generate_session_html(
     )
 
 
-def _get_message_hierarchy_level(css_class: str, is_sidechain: bool) -> int:
-    """Determine the hierarchy level for a message based on its type and sidechain status.
+def _get_message_hierarchy_level(msg: TemplateMessage) -> int:
+    """Determine the hierarchy level for a message based on its type and modifiers.
 
     Correct hierarchy based on logical nesting:
     - Level 0: Session headers
@@ -2688,35 +2699,41 @@ def _get_message_hierarchy_level(css_class: str, is_sidechain: bool) -> int:
     Returns:
         Integer hierarchy level (1-5, session headers are 0)
     """
+    msg_type = msg.type
+    is_sidechain = msg.modifiers.is_sidechain
+    system_level = msg.modifiers.system_level
+
     # User messages at level 1 (under session)
     # Note: sidechain user messages are skipped before reaching this function
-    if "user" in css_class and not is_sidechain:
+    if msg_type == "user" and not is_sidechain:
         return 1
 
     # System info/warning at level 3 (tool-related, e.g., hook notifications)
     if (
-        "system-info" in css_class or "system-warning" in css_class
-    ) and not is_sidechain:
+        msg_type == "system"
+        and system_level in ("info", "warning")
+        and not is_sidechain
+    ):
         return 3
 
     # System commands/errors at level 2 (siblings to assistant)
-    if "system" in css_class and not is_sidechain:
+    if msg_type == "system" and not is_sidechain:
         return 2
 
     # Sidechain assistant/thinking at level 4 (nested under Task tool result)
-    if is_sidechain and ("assistant" in css_class or "thinking" in css_class):
+    if is_sidechain and msg_type in ("assistant", "thinking"):
         return 4
 
     # Sidechain tools at level 5
-    if is_sidechain and ("tool" in css_class):
+    if is_sidechain and msg_type in ("tool_use", "tool_result"):
         return 5
 
     # Main assistant/thinking at level 2 (nested under user)
-    if "assistant" in css_class or "thinking" in css_class:
+    if msg_type in ("assistant", "thinking"):
         return 2
 
     # Main tools at level 3 (nested under assistant)
-    if "tool" in css_class:
+    if msg_type in ("tool_use", "tool_result"):
         return 3
 
     # Default to level 1
@@ -2743,11 +2760,8 @@ def _build_message_hierarchy(messages: List[TemplateMessage]) -> None:
         if message.is_session_header:
             current_level = 0
         else:
-            # Determine level from css_class
-            is_sidechain = "sidechain" in message.css_class
-            current_level = _get_message_hierarchy_level(
-                message.css_class, is_sidechain
-            )
+            # Determine level from message type and modifiers
+            current_level = _get_message_hierarchy_level(message)
 
         # Pop stack until we find the appropriate parent level
         while hierarchy_stack and hierarchy_stack[-1][0] >= current_level:
@@ -2805,7 +2819,7 @@ def _mark_messages_with_children(messages: List[TemplateMessage]) -> None:
         immediate_parent_id = message.ancestry[-1]
 
         # Get message type for categorization
-        msg_type = message.css_class or message.type
+        msg_type = message.type
 
         # Increment immediate parent's child count
         if immediate_parent_id in message_by_id:
@@ -3115,6 +3129,9 @@ def generate_html(
         env = _get_template_environment()
         template = env.get_template("transcript.html")
 
+    # Import html_renderer functions for template use
+    from .html_renderer import css_class_from_message, get_message_emoji
+
     with log_timing(lambda: f"Template rendering ({len(html_output)} chars)", t_start):
         html_output = str(
             template.render(
@@ -3123,6 +3140,8 @@ def generate_html(
                 sessions=session_nav,
                 combined_transcript_link=combined_transcript_link,
                 library_version=get_library_version(),
+                css_class_from_message=css_class_from_message,
+                get_message_emoji=get_message_emoji,
             )
         )
 
@@ -3219,7 +3238,7 @@ def _reorder_sidechain_template_messages(
     sidechain_map: Dict[str, List[TemplateMessage]] = {}
 
     for message in messages:
-        is_sidechain = "sidechain" in message.css_class
+        is_sidechain = message.modifiers.is_sidechain
         agent_id = message.agent_id
 
         if is_sidechain and agent_id:
@@ -3503,13 +3522,13 @@ def _process_messages_loop(
                     message_type="session_header",
                     content_html=session_title,
                     formatted_timestamp="",
-                    css_class="session-header",
                     raw_timestamp=None,
                     session_summary=current_session_summary,
                     session_id=session_id,
                     is_session_header=True,
                     message_id=None,  # Will be assigned by _build_message_hierarchy
                     ancestry=[],  # Session headers are top-level
+                    modifiers=MessageModifiers(),  # No modifiers for session headers
                 )
                 template_messages.append(session_header)
 
@@ -3646,7 +3665,6 @@ def _process_messages_loop(
                 message_type=message_type,
                 content_html=content_html,
                 formatted_timestamp=formatted_timestamp,
-                css_class=css_class,
                 raw_timestamp=timestamp,
                 session_summary=session_summary,
                 session_id=session_id,
@@ -3657,6 +3675,7 @@ def _process_messages_loop(
                 agent_id=getattr(message, "agentId", None),
                 uuid=getattr(message, "uuid", None),
                 parent_uuid=getattr(message, "parentUuid", None),
+                modifiers=_modifiers_from_css_class(css_class),
             )
 
             # Store raw text content for potential future use (e.g., deduplication,
@@ -3690,7 +3709,6 @@ def _process_messages_loop(
                 tool_result = ToolItemResult(
                     message_type="unknown",
                     content_html=f"

Unknown content type: {escape_html(str(type(tool_item)))}

", - css_class="unknown", message_title="Unknown Content", ) @@ -3699,10 +3717,13 @@ def _process_messages_loop( continue # Preserve sidechain context for tool/thinking/image content within sidechain messages - tool_css_class = tool_result.css_class tool_is_sidechain = getattr(message, "isSidechain", False) - if tool_is_sidechain: - tool_css_class += " sidechain" + + # Build modifiers directly from tool_result properties + tool_modifiers = MessageModifiers( + is_sidechain=tool_is_sidechain, + is_error=tool_result.is_error, + ) # Generate unique UUID for this tool message # Use tool_use_id if available, otherwise fall back to message UUID + index @@ -3716,7 +3737,6 @@ def _process_messages_loop( message_type=tool_result.message_type, content_html=tool_result.content_html, formatted_timestamp=tool_formatted_timestamp, - css_class=tool_css_class, raw_timestamp=tool_timestamp, session_summary=session_summary, session_id=session_id, @@ -3727,6 +3747,7 @@ def _process_messages_loop( ancestry=[], # Will be assigned by _build_message_hierarchy agent_id=getattr(message, "agentId", None), uuid=tool_uuid, + modifiers=tool_modifiers, ) # Store raw text for Task result deduplication diff --git a/claude_code_log/templates/transcript.html b/claude_code_log/templates/transcript.html index 9a88e499..87b3c095 100644 --- a/claude_code_log/templates/transcript.html +++ b/claude_code_log/templates/transcript.html @@ -101,19 +101,14 @@

🔍 Search & Filter

{% endif %}
{% else %} - {% set markdown = message.css_class in ['assistant', 'thinking', 'sidechain'] or (message.css_class and 'compacted' in message.css_class) %} -
+ {%- set msg_css_class = css_class_from_message(message) %} + {% set markdown = message.type in ['assistant', 'thinking'] or message.modifiers.is_sidechain or message.modifiers.is_compacted %} +
+ {% set msg_emoji = get_message_emoji(message) -%} {% if message.message_title %}{% if message.message_title == 'Memory' %}💭 {% - elif message.css_class.startswith('user') %}🤷 {% - elif message.css_class.startswith('assistant') %}🤖 {% - elif message.css_class == 'system' %}⚙️ {% - elif message.css_class.startswith('tool_use') and not starts_with_emoji(message.message_title) %}🛠️ {% - elif message.css_class == 'tool_result error' %}🚨 {% - elif message.css_class.startswith('tool_result') %}🧰 {% - elif message.css_class.startswith('thinking') %}💭 {% - elif message.css_class == 'image' %}🖼️ {% endif %}{{ message.message_title | safe }}{% endif %} + elif msg_emoji and (message.type != 'tool_use' or not starts_with_emoji(message.message_title)) %}{{ msg_emoji }} {% endif %}{{ message.message_title | safe }}{% endif %}
{{ message.formatted_timestamp }} @@ -125,7 +120,7 @@

🔍 Search & Filter

{{ message.content_html | safe }}
{% if message.has_children %} -
+
{% if message.immediate_children_count == message.total_descendants_count %} {# Same count = only one level, show single full-width button #}
diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index f29a3b35..890fa90e 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -9543,13 +9543,13 @@
-
+
- 4 users, 1 user slash-command + 5 users
-
+
▼▼ - 4 users, 2 assistants, 3 more total + 5 users, 2 assistants, 2 more total
diff --git a/test/test_template_data.py b/test/test_template_data.py index 0824360e..31d82ecf 100644 --- a/test/test_template_data.py +++ b/test/test_template_data.py @@ -23,14 +23,12 @@ def test_template_message_creation(self): message_type="user", content_html="

Test content

", formatted_timestamp="2025-06-14 10:00:00", - css_class="user", raw_timestamp=None, ) assert msg.type == "user" assert msg.content_html == "

Test content

" assert msg.formatted_timestamp == "2025-06-14 10:00:00" - assert msg.css_class == "user" assert msg.message_title == "User" def test_template_message_title_capitalization(self): @@ -47,7 +45,6 @@ def test_template_message_title_capitalization(self): message_type=msg_type, content_html="content", formatted_timestamp="time", - css_class="class", raw_timestamp=None, ) assert msg.message_title == expected_display @@ -386,7 +383,6 @@ def _create_message( message_type=msg_type, content_html=f"

{msg_type} content

", formatted_timestamp="2025-06-14 10:00:00", - css_class=msg_type, raw_timestamp=None, ) if msg_id: @@ -540,7 +536,6 @@ def test_flatten_roundtrip_preserves_count(self): message_type="session", content_html="

Session

", formatted_timestamp="2025-06-14 10:00:00", - css_class="session", raw_timestamp=None, ) root.message_id = "session-1" @@ -550,7 +545,6 @@ def test_flatten_roundtrip_preserves_count(self): message_type="user", content_html="

User

", formatted_timestamp="2025-06-14 10:00:01", - css_class="user", raw_timestamp=None, ) user.message_id = "d-1" @@ -560,7 +554,6 @@ def test_flatten_roundtrip_preserves_count(self): message_type="assistant", content_html="

Assistant

", formatted_timestamp="2025-06-14 10:00:02", - css_class="assistant", raw_timestamp=None, ) assistant.message_id = "d-2" @@ -570,7 +563,6 @@ def test_flatten_roundtrip_preserves_count(self): message_type="tool_use", content_html="

Tool

", formatted_timestamp="2025-06-14 10:00:03", - css_class="tool_use", raw_timestamp=None, ) tool.message_id = "d-3" From f3e5979c998500ac68989bd3232fa5a03dd49b05 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 8 Dec 2025 11:52:39 +0100 Subject: [PATCH 031/102] Remove css_class from message processing functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete removal of css_class intermediate representation: - Update _process_command_message, _process_local_command_output, _process_bash_input, _process_bash_output, _process_regular_message to return MessageModifiers instead of css_class strings - Update calling code in _process_messages_loop to unpack modifiers directly from processing functions - Pass modifiers directly to TemplateMessage constructor - Remove _modifiers_from_css_class() bridge function (no longer needed) - Handle steering modifier directly in MessageModifiers Message processing functions now return format-neutral data that css_class_from_message() in html_renderer.py converts to CSS classes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/renderer.py | 97 ++++++++++++++++++------------------- 1 file changed, 47 insertions(+), 50 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index d2888b4c..cc169d57 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -1531,23 +1531,6 @@ def _format_type_counts(type_counts: dict[str, int]) -> str: return f"{parts[0]}, {parts[1]}, {remaining} more" -def _modifiers_from_css_class(css_class: str) -> MessageModifiers: - """Parse CSS class string into MessageModifiers. - - This is a temporary bridge function during migration from css_class to modifiers. - It parses the space-separated CSS class string back into structured modifiers. - """ - classes = css_class.split() - return MessageModifiers( - is_sidechain="sidechain" in classes, - is_slash_command="slash-command" in classes, - is_command_output="command-output" in classes, - is_compacted="compacted" in classes, - is_error="error" in classes, - is_steering="steering" in classes, - ) - - class TemplateMessage: """Structured message data for template rendering.""" @@ -1847,13 +1830,15 @@ def _render_hook_summary(message: "SystemTranscriptEntry") -> str: # return css_class, content_html, message_type -def _process_command_message(text_content: str) -> tuple[str, str, str, str]: - """Process a slash command message and return (css_class, content_html, message_type, message_title). +def _process_command_message( + text_content: str, +) -> tuple[MessageModifiers, str, str, str]: + """Process a slash command message and return (modifiers, content_html, message_type, message_title). These are user messages containing slash command invocations (e.g., /context, /model). The JSONL type is "user", not "system". """ - css_class = "user slash-command" + modifiers = MessageModifiers(is_slash_command=True) command_name, command_args, command_contents = extract_command_info(text_content) escaped_command_name = escape_html(command_name) escaped_command_args = escape_html(command_args) @@ -1888,18 +1873,20 @@ def _process_command_message(text_content: str) -> tuple[str, str, str, str]: content_html = "
".join(content_parts) message_type = "user" message_title = "Slash Command" - return css_class, content_html, message_type, message_title + return modifiers, content_html, message_type, message_title -def _process_local_command_output(text_content: str) -> tuple[str, str, str, str]: - """Process slash command output and return (css_class, content_html, message_type, message_title). +def _process_local_command_output( + text_content: str, +) -> tuple[MessageModifiers, str, str, str]: + """Process slash command output and return (modifiers, content_html, message_type, message_title). These are user messages containing the output from slash commands (e.g., /context, /model). The JSONL type is "user", not "system". """ import re - css_class = "user command-output" + modifiers = MessageModifiers(is_command_output=True) stdout_match = re.search( r"(.*?)", @@ -1928,14 +1915,14 @@ def _process_local_command_output(text_content: str) -> tuple[str, str, str, str message_type = "user" message_title = "Command Output" - return css_class, content_html, message_type, message_title + return modifiers, content_html, message_type, message_title -def _process_bash_input(text_content: str) -> tuple[str, str, str, str]: - """Process bash input command and return (css_class, content_html, message_type, message_title).""" +def _process_bash_input(text_content: str) -> tuple[MessageModifiers, str, str, str]: + """Process bash input command and return (modifiers, content_html, message_type, message_title).""" import re - css_class = "bash-input" + modifiers = MessageModifiers() # bash-input is a message type, not a modifier bash_match = re.search( r"(.*?)", @@ -1952,16 +1939,16 @@ def _process_bash_input(text_content: str) -> tuple[str, str, str, str]: else: content_html = escape_html(text_content) - message_type = "bash" + message_type = "bash-input" message_title = "Bash" - return css_class, content_html, message_type, message_title + return modifiers, content_html, message_type, message_title -def _process_bash_output(text_content: str) -> tuple[str, str, str, str]: - """Process bash output and return (css_class, content_html, message_type, message_title).""" +def _process_bash_output(text_content: str) -> tuple[MessageModifiers, str, str, str]: + """Process bash output and return (modifiers, content_html, message_type, message_title).""" import re - css_class = "bash-output" + modifiers = MessageModifiers() # bash-output is a message type, not a modifier COLLAPSE_THRESHOLD = 10 # Collapse if more than this many lines stdout_match = re.search( @@ -2034,7 +2021,7 @@ def _process_bash_output(text_content: str) -> tuple[str, str, str, str]: message_type = "bash" message_title = "Bash" - return css_class, content_html, message_type, message_title + return modifiers, content_html, message_type, message_title def _process_regular_message( @@ -2042,8 +2029,8 @@ def _process_regular_message( message_type: str, is_sidechain: bool, is_meta: bool = False, -) -> tuple[str, str, str, str]: - """Process regular message and return (css_class, content_html, message_type, message_title). +) -> tuple[MessageModifiers, str, str, str]: + """Process regular message and return (modifiers, content_html, message_type, message_title). Note: Sidechain user messages (Sub-assistant prompts) are now skipped entirely in the main processing loop since they duplicate the Task tool input prompt. @@ -2051,9 +2038,10 @@ def _process_regular_message( Args: is_meta: True for slash command expanded prompts (isMeta=True in JSONL) """ - css_class = f"{message_type}" message_title = message_type.title() # Default title is_compacted = False + is_slash_command = False + is_memory_input = False # Handle user-specific preprocessing if message_type == MessageType.USER: @@ -2061,7 +2049,7 @@ def _process_regular_message( if is_meta: # Slash command expanded prompts - render as collapsible markdown # These contain LLM-generated instruction text (markdown formatted) - css_class = f"{message_type} slash-command" + is_slash_command = True message_title = "User (slash command)" # Combine all text content (items may be TextContent, dicts, or SDK objects) all_text = "\n\n".join( @@ -2080,7 +2068,6 @@ def _process_regular_message( text_only_content ) if is_compacted: - css_class = f"{message_type} compacted" message_title = "User (compacted conversation)" elif is_memory_input: message_title = "Memory" @@ -2089,12 +2076,17 @@ def _process_regular_message( content_html = render_message_content(text_only_content, message_type) if is_sidechain: - css_class = f"{css_class} sidechain" # Update message title for display (only non-user types reach here) if not is_compacted: message_title = "🔗 Sub-assistant" - return css_class, content_html, message_type, message_title + modifiers = MessageModifiers( + is_sidechain=is_sidechain, + is_slash_command=is_slash_command, + is_compacted=is_compacted, + ) + + return modifiers, content_html, message_type, message_title def _process_system_message( @@ -3615,21 +3607,21 @@ def _process_messages_loop( token_parts.append(f"Cache Read: {usage.cache_read_input_tokens}") token_usage_str = " | ".join(token_parts) - # Determine CSS class and content based on message type and duplicate status + # Determine modifiers and content based on message type and duplicate status if is_command: - css_class, content_html, message_type, message_title = ( + modifiers, content_html, message_type, message_title = ( _process_command_message(text_content) ) elif is_local_output: - css_class, content_html, message_type, message_title = ( + modifiers, content_html, message_type, message_title = ( _process_local_command_output(text_content) ) elif is_bash_cmd: - css_class, content_html, message_type, message_title = _process_bash_input( + modifiers, content_html, message_type, message_title = _process_bash_input( text_content ) elif is_bash_result: - css_class, content_html, message_type, message_title = _process_bash_output( + modifiers, content_html, message_type, message_title = _process_bash_output( text_content ) else: @@ -3639,7 +3631,7 @@ def _process_messages_loop( else: effective_type = message_type - css_class, content_html, message_type_result, message_title = ( + modifiers, content_html, message_type_result, message_title = ( _process_regular_message( text_only_content, effective_type, @@ -3649,12 +3641,17 @@ def _process_messages_loop( ) message_type = message_type_result # Update message_type with result - # Add 'steering' CSS class for queue-operation 'remove' messages + # Add 'steering' modifier for queue-operation 'remove' messages if ( isinstance(message, QueueOperationTranscriptEntry) and message.operation == "remove" ): - css_class = f"{css_class} steering" + modifiers = MessageModifiers( + is_sidechain=modifiers.is_sidechain, + is_slash_command=modifiers.is_slash_command, + is_compacted=modifiers.is_compacted, + is_steering=True, + ) message_title = "User (steering)" # Only create main message if it has text content @@ -3675,7 +3672,7 @@ def _process_messages_loop( agent_id=getattr(message, "agentId", None), uuid=getattr(message, "uuid", None), parent_uuid=getattr(message, "parentUuid", None), - modifiers=_modifiers_from_css_class(css_class), + modifiers=modifiers, ) # Store raw text content for potential future use (e.g., deduplication, From 56ed1bbbf65ebc7cc6fb29aa93280bd642e30620 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 8 Dec 2025 13:39:36 +0100 Subject: [PATCH 032/102] Fix lint and type errors after css_class removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused level_css variables (now using MessageModifiers) - Fix is_error type: use `or False` to convert bool|None to bool 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/renderer.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index cc169d57..b3ed88cf 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -2113,7 +2113,6 @@ def _process_system_message( return None # Render hook summary with collapsible details content_html = _render_hook_summary(message) - level_css = "system system-hook" level = "hook" elif not message.content: # Skip system messages without content (shouldn't happen normally) @@ -2122,7 +2121,6 @@ def _process_system_message( # Create level-specific styling and icons level = getattr(message, "level", "info") level_icon = {"warning": "⚠️", "error": "❌", "info": "ℹ️"}.get(level, "ℹ️") - level_css = f"system system-{level}" # Process ANSI codes in system messages (they may contain colored output) html_content = convert_ansi_to_html(message.content) @@ -2320,7 +2318,7 @@ def _process_tool_result_item( tool_use_id=tool_result.tool_use_id, title_hint=tool_title_hint, pending_dedup=pending_dedup, - is_error=tool_result.is_error, + is_error=tool_result.is_error or False, ) From 0339f2e92b9293c6ca45d655b22c8edeae846f9d Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 8 Dec 2025 15:59:54 +0100 Subject: [PATCH 033/102] Use dataclasses.replace() for cleaner modifier updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace verbose MessageModifiers reconstruction with idiomatic dataclasses.replace(modifiers, is_steering=True). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/renderer.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index b3ed88cf..87f8744a 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -4,7 +4,7 @@ import json import re import time -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import List, Optional, Dict, Any, cast, TYPE_CHECKING @@ -3644,12 +3644,7 @@ def _process_messages_loop( isinstance(message, QueueOperationTranscriptEntry) and message.operation == "remove" ): - modifiers = MessageModifiers( - is_sidechain=modifiers.is_sidechain, - is_slash_command=modifiers.is_slash_command, - is_compacted=modifiers.is_compacted, - is_steering=True, - ) + modifiers = replace(modifiers, is_steering=True) message_title = "User (steering)" # Only create main message if it has text content From 6a791e66aaf1772884de3ce79e260ef451b59a79 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 8 Dec 2025 19:13:42 +0100 Subject: [PATCH 034/102] Move HTML utilities to html_renderer.py (Phase 13, Step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move escape_html(), _create_pygments_plugin(), render_markdown() to html_renderer.py - Add "-- HTML Utilities" section header - Update renderer.py to import these functions from html_renderer - Remove unused top-level mistune import (local import remains where needed) - Remove duplicate local imports of css_class_from_message and get_message_emoji 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- PLAN_PHASE12.md | 345 +++++++++++++++++++++++++++++++ claude_code_log/html_renderer.py | 90 +++++++- claude_code_log/renderer.py | 80 +------ 3 files changed, 438 insertions(+), 77 deletions(-) create mode 100644 PLAN_PHASE12.md diff --git a/PLAN_PHASE12.md b/PLAN_PHASE12.md new file mode 100644 index 00000000..1dd2836d --- /dev/null +++ b/PLAN_PHASE12.md @@ -0,0 +1,345 @@ +# Phase 12: Format-Neutral Decomposition Plan + +## Overview + +This plan separates format-neutral logic from HTML-specific generation in renderer.py. The goal is to: +1. Create a `TemplateMessage` that stores logical attributes instead of CSS classes +2. Move HTML-specific rendering to a new `html_renderer.py` module +3. Keep format-neutral processing in `renderer.py` (to be renamed later) + +## Key Design Decisions + +### 1. Replace `css_class` with Typed Attributes + +Instead of encoding traits as space-separated CSS classes (e.g., `"user sidechain slash-command"`), we'll use explicit fields: + +```python +# In models.py - add MessageModifiers dataclass +@dataclass +class MessageModifiers: + """Semantic modifiers for message rendering.""" + is_sidechain: bool = False + is_slash_command: bool = False + is_command_output: bool = False + is_compacted: bool = False + is_error: bool = False + is_steering: bool = False + system_level: Optional[str] = None # "info", "warning", "error", "hook" +``` + +The `TemplateMessage` will have: +- `type: MessageType` (already have the enum) +- `modifiers: MessageModifiers` (new) +- Remove `css_class` field + +### 2. HTML Renderer Module (`html_renderer.py`) + +New module containing HTML-specific functions: + +```python +# html_renderer.py + +def css_class_from_message(msg: TemplateMessage) -> str: + """Generate CSS class string from message type and modifiers.""" + parts = [msg.type.value] + if msg.modifiers.is_sidechain: + parts.append("sidechain") + if msg.modifiers.is_slash_command: + parts.append("slash-command") + if msg.modifiers.is_command_output: + parts.append("command-output") + if msg.modifiers.is_compacted: + parts.append("compacted") + if msg.modifiers.is_error: + parts.append("error") + if msg.modifiers.is_steering: + parts.append("steering") + if msg.modifiers.system_level: + parts.append(f"system-{msg.modifiers.system_level}") + return " ".join(parts) + +def get_message_emoji(msg: TemplateMessage) -> str: + """Return emoji for message type.""" + # Move emoji logic from template to here + +def render_content_html(msg: TemplateMessage) -> str: + """Render message content to HTML.""" + # Delegates to format_* functions +``` + +### 3. Keep Format-Neutral Processing in renderer.py + +Functions that stay in renderer.py (format-neutral): +- `_process_messages_loop()` - but sets `modifiers` instead of `css_class` +- `_identify_message_pairs()` - pairing logic +- `_build_message_hierarchy()` - but uses `type` and `modifiers` instead of `css_class` +- `_reorder_paired_messages()` - reordering logic +- Deduplication logic +- Token aggregation + +### 4. Migration Strategy + +The migration will be done in phases to minimize disruption: + +**Phase 12a: Add MessageModifiers** +- Add `MessageModifiers` dataclass to `models.py` +- Add `modifiers` field to `TemplateMessage` +- Keep `css_class` field for backward compatibility + +**Phase 12b: Populate Modifiers** +- Update all TemplateMessage creation sites to set `modifiers` +- Replace `"x" in css_class` checks with `modifiers.is_x` + +**Phase 12c: Create html_renderer.py** +- Move `escape_html()`, `render_markdown()` to html_renderer.py +- Create `css_class_from_message()` function +- Move tool formatters to html_renderer.py + +**Phase 12d: Update Templates** +- Modify template to call `css_class_from_message(message)` +- Update emoji logic to use modifiers + +**Phase 12e: Remove css_class** +- Remove `css_class` parameter from TemplateMessage +- Clean up any remaining references + +## Detailed Implementation + +### Phase 12a: Add MessageModifiers (models.py) + +```python +from dataclasses import dataclass, field +from typing import Optional + +@dataclass +class MessageModifiers: + """Semantic modifiers that affect message display. + + These are format-neutral flags that renderers can use to determine + how to display a message. HTML renderer converts these to CSS classes, + text renderer might use them for indentation or formatting. + """ + is_sidechain: bool = False + is_slash_command: bool = False + is_command_output: bool = False + is_compacted: bool = False + is_error: bool = False + is_steering: bool = False + # System message level (mutually exclusive) + system_level: Optional[str] = None # "info", "warning", "error", "hook" +``` + +Add to TemplateMessage.__init__: +```python +def __init__( + self, + message_type: str, # Will become MessageType + content_html: str, + formatted_timestamp: str, + css_class: str, # Keep for now, will remove in 12e + modifiers: Optional[MessageModifiers] = None, # New + # ... other params +): + self.type = message_type + self.modifiers = modifiers or MessageModifiers() + # ... rest +``` + +### Phase 12b: Populate Modifiers + +Update each TemplateMessage creation site. Example from `_process_system_message`: + +```python +# Before +css_class = f"{message_type}" +if is_sidechain: + css_class = f"{css_class} sidechain" + +# After +modifiers = MessageModifiers(is_sidechain=is_sidechain) +css_class = f"{message_type}" # Keep for backward compat +if is_sidechain: + css_class = f"{css_class} sidechain" +``` + +Update `_get_message_hierarchy_level()`: +```python +# Before +if "sidechain" in css_class: + ... + +# After +def _get_message_hierarchy_level(msg: TemplateMessage) -> int: + is_sidechain = msg.modifiers.is_sidechain + msg_type = msg.type + + if msg_type == MessageType.USER and not is_sidechain: + return 1 + # ... +``` + +### Phase 12c: Create html_renderer.py + +```python +"""HTML-specific rendering utilities. + +This module contains all HTML generation code: +- CSS class computation +- HTML escaping +- Markdown rendering +- Tool-specific formatters +""" + +from html import escape +from typing import Optional, List +import mistune + +from .models import MessageType, MessageModifiers, TemplateMessage + + +def escape_html(text: str) -> str: + """Escape HTML special characters.""" + return escape(text, quote=True) + + +def render_markdown(text: str) -> str: + """Convert markdown to HTML.""" + return mistune.html(text) + + +def css_class_from_message(msg: TemplateMessage) -> str: + """Generate CSS class string from message type and modifiers. + + This reconstructs the original css_class format for backward + compatibility with existing CSS and JavaScript. + """ + parts: List[str] = [msg.type.value if isinstance(msg.type, MessageType) else msg.type] + + mods = msg.modifiers + if mods.is_slash_command: + parts.append("slash-command") + if mods.is_command_output: + parts.append("command-output") + if mods.is_compacted: + parts.append("compacted") + if mods.is_error: + parts.append("error") + if mods.is_steering: + parts.append("steering") + if mods.is_sidechain: + parts.append("sidechain") + if mods.system_level: + parts.append(f"system-{mods.system_level}") + + return " ".join(parts) + + +def get_message_emoji(msg: TemplateMessage) -> str: + """Return appropriate emoji for message type.""" + msg_type = msg.type if isinstance(msg.type, MessageType) else msg.type + + if msg_type == MessageType.SESSION_HEADER: + return "📋" + elif msg_type == MessageType.USER: + return "🤷" + elif msg_type == MessageType.ASSISTANT: + return "🤖" + elif msg_type == MessageType.SYSTEM: + return "⚙️" + elif msg_type == MessageType.TOOL_USE: + return "🛠️" + elif msg_type == MessageType.TOOL_RESULT: + if msg.modifiers.is_error: + return "🚨" + return "🧰" + elif msg_type == MessageType.THINKING: + return "💭" + elif msg_type == MessageType.IMAGE: + return "🖼️" + return "" + + +# Move format_* tool functions here: +# - format_ask_user_question_tool_content +# - format_todo_write_tool_content +# - format_bash_tool_content +# etc. +``` + +### Phase 12d: Update Templates + +Update transcript.html to use the new functions. Register them as Jinja filters or pass as context: + +```python +# In renderer.py when rendering template +from .html_renderer import css_class_from_message, get_message_emoji + +template = env.get_template("transcript.html") +html = template.render( + messages=messages, + css_class_from_message=css_class_from_message, + get_message_emoji=get_message_emoji, + # ... +) +``` + +Template changes: +```jinja +{# Before #} +
+ +{# After #} +
+``` + +### Phase 12e: Remove css_class + +Once all references use modifiers: +1. Remove `css_class` parameter from `TemplateMessage.__init__` +2. Remove `self.css_class = css_class` +3. Clean up all `css_class=...` at creation sites +4. Update tests to use modifiers + +## Files Changed + +| File | Changes | +|------|---------| +| `models.py` | Add `MessageModifiers` dataclass | +| `renderer.py` | Update TemplateMessage, populate modifiers, update hierarchy logic | +| `html_renderer.py` | New file with HTML utilities and css_class_from_message | +| `templates/transcript.html` | Use css_class_from_message filter | +| `test_*.py` | Update tests to use modifiers | + +## Testing Strategy + +1. **Snapshot tests**: Run after each phase to verify HTML output unchanged +2. **Unit tests for css_class_from_message**: Verify it produces same strings +3. **Unit tests for modifiers**: Test each modifier flag +4. **Integration tests**: Full render with real transcripts + +## Commit Plan + +1. `Add MessageModifiers dataclass to models.py` (12a) +2. `Add modifiers field to TemplateMessage` (12a) +3. `Populate modifiers in message processing` (12b part 1) +4. `Update hierarchy logic to use modifiers` (12b part 2) +5. `Create html_renderer.py with css_class_from_message` (12c) +6. `Move escape_html and render_markdown to html_renderer` (12c) +7. `Update template to use css_class_from_message` (12d) +8. `Remove css_class field from TemplateMessage` (12e) + +## Risk Assessment + +- **Low risk**: MessageModifiers is additive, doesn't break existing code +- **Medium risk**: Moving functions to html_renderer.py requires import updates +- **High risk**: Template changes and css_class removal need careful testing + +## Estimated Scope + +- Phase 12a: ~30 lines added to models.py, ~10 lines to renderer.py +- Phase 12b: ~50 modifications across renderer.py +- Phase 12c: ~200 lines new file, ~200 lines moved from renderer.py +- Phase 12d: ~10 lines template changes +- Phase 12e: ~20 lines removed + +Total: Moderate refactoring, ~5-8 commits diff --git a/claude_code_log/html_renderer.py b/claude_code_log/html_renderer.py index ef9a7006..f1e1486d 100644 --- a/claude_code_log/html_renderer.py +++ b/claude_code_log/html_renderer.py @@ -3,18 +3,30 @@ This module contains all HTML generation code: - CSS class computation from message type and modifiers - Message emoji generation -- (Future: HTML escaping, markdown rendering, tool formatters) +- HTML escaping and markdown rendering +- Collapsible content rendering +- Tool-specific HTML formatters +- Message content HTML rendering +- Template environment management The functions here transform format-neutral TemplateMessage data into -HTML-specific attributes like CSS classes and display emojis. +HTML-specific output. """ -from typing import TYPE_CHECKING +import html +from typing import Any, Optional, TYPE_CHECKING + +import mistune + +from .renderer_timings import timing_stat if TYPE_CHECKING: from .renderer import TemplateMessage +# -- CSS and Message Display -------------------------------------------------- + + def css_class_from_message(msg: "TemplateMessage") -> str: """Generate CSS class string from message type and modifiers. @@ -84,3 +96,75 @@ def get_message_emoji(msg: "TemplateMessage") -> str: elif msg_type == "image": return "🖼️" return "" + + +# -- HTML Utilities ----------------------------------------------------------- + + +def escape_html(text: str) -> str: + """Escape HTML special characters in text. + + Also normalizes line endings (CRLF -> LF) to prevent double spacing in
 blocks.
+    """
+    # Normalize CRLF to LF to prevent double line breaks in HTML
+    normalized = text.replace("\r\n", "\n").replace("\r", "\n")
+    return html.escape(normalized)
+
+
+def _create_pygments_plugin() -> Any:
+    """Create a mistune plugin that uses Pygments for code block syntax highlighting."""
+    from pygments import highlight  # type: ignore[reportUnknownVariableType]
+    from pygments.lexers import get_lexer_by_name, TextLexer  # type: ignore[reportUnknownVariableType]
+    from pygments.formatters import HtmlFormatter  # type: ignore[reportUnknownVariableType]
+    from pygments.util import ClassNotFound  # type: ignore[reportUnknownVariableType]
+
+    def plugin_pygments(md: Any) -> None:
+        """Plugin to add Pygments syntax highlighting to code blocks."""
+        original_render = md.renderer.block_code
+
+        def block_code(code: str, info: Optional[str] = None) -> str:
+            """Render code block with Pygments syntax highlighting if language is specified."""
+            if info:
+                # Language hint provided, use Pygments
+                lang = info.split()[0] if info else ""
+                try:
+                    lexer = get_lexer_by_name(lang, stripall=True)  # type: ignore[reportUnknownVariableType]
+                except ClassNotFound:
+                    lexer = TextLexer()  # type: ignore[reportUnknownVariableType]
+
+                formatter = HtmlFormatter(  # type: ignore[reportUnknownVariableType]
+                    linenos=False,  # No line numbers in markdown code blocks
+                    cssclass="highlight",
+                    wrapcode=True,
+                )
+                # Track Pygments timing if enabled
+                with timing_stat("_pygments_timings"):
+                    return str(highlight(code, lexer, formatter))  # type: ignore[reportUnknownArgumentType]
+            else:
+                # No language hint, use default rendering
+                return original_render(code, info)
+
+        md.renderer.block_code = block_code
+
+    return plugin_pygments
+
+
+def render_markdown(text: str) -> str:
+    """Convert markdown text to HTML using mistune with Pygments syntax highlighting."""
+    # Track markdown rendering time if enabled
+    with timing_stat("_markdown_timings"):
+        # Configure mistune with GitHub-flavored markdown features
+        renderer = mistune.create_markdown(
+            plugins=[
+                "strikethrough",
+                "footnotes",
+                "table",
+                "url",
+                "task_lists",
+                "def_list",
+                _create_pygments_plugin(),
+            ],
+            escape=False,  # Don't escape HTML since we want to render markdown properly
+            hard_wrap=True,  # Line break for newlines (checklists in Assistant messages)
+        )
+        return str(renderer(text))
diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py
index 87f8744a..fad5f07e 100644
--- a/claude_code_log/renderer.py
+++ b/claude_code_log/renderer.py
@@ -12,7 +12,6 @@
     from .cache import CacheManager
 from datetime import datetime
 import html
-import mistune
 from jinja2 import Environment, FileSystemLoader, select_autoescape
 
 from .models import (
@@ -44,7 +43,6 @@
 from .renderer_timings import (
     DEBUG_TIMING,
     report_timing_statistics,
-    timing_stat,
     set_timing_var,
     log_timing,
 )
@@ -55,6 +53,12 @@
     truncate_highlighted_preview,
     render_single_diff,
 )
+from .html_renderer import (
+    css_class_from_message,
+    get_message_emoji,
+    escape_html,
+    render_markdown,
+)
 
 
 def starts_with_emoji(text: str) -> bool:
@@ -177,75 +181,6 @@ def format_timestamp(timestamp_str: str | None) -> str:
         return timestamp_str
 
 
-def escape_html(text: str) -> str:
-    """Escape HTML special characters in text.
-
-    Also normalizes line endings (CRLF -> LF) to prevent double spacing in 
 blocks.
-    """
-    # Normalize CRLF to LF to prevent double line breaks in HTML
-    normalized = text.replace("\r\n", "\n").replace("\r", "\n")
-    return html.escape(normalized)
-
-
-def _create_pygments_plugin() -> Any:
-    """Create a mistune plugin that uses Pygments for code block syntax highlighting."""
-    from pygments import highlight  # type: ignore[reportUnknownVariableType]
-    from pygments.lexers import get_lexer_by_name, TextLexer  # type: ignore[reportUnknownVariableType]
-    from pygments.formatters import HtmlFormatter  # type: ignore[reportUnknownVariableType]
-    from pygments.util import ClassNotFound  # type: ignore[reportUnknownVariableType]
-
-    def plugin_pygments(md: Any) -> None:
-        """Plugin to add Pygments syntax highlighting to code blocks."""
-        original_render = md.renderer.block_code
-
-        def block_code(code: str, info: Optional[str] = None) -> str:
-            """Render code block with Pygments syntax highlighting if language is specified."""
-            if info:
-                # Language hint provided, use Pygments
-                lang = info.split()[0] if info else ""
-                try:
-                    lexer = get_lexer_by_name(lang, stripall=True)  # type: ignore[reportUnknownVariableType]
-                except ClassNotFound:
-                    lexer = TextLexer()  # type: ignore[reportUnknownVariableType]
-
-                formatter = HtmlFormatter(  # type: ignore[reportUnknownVariableType]
-                    linenos=False,  # No line numbers in markdown code blocks
-                    cssclass="highlight",
-                    wrapcode=True,
-                )
-                # Track Pygments timing if enabled
-                with timing_stat("_pygments_timings"):
-                    return str(highlight(code, lexer, formatter))  # type: ignore[reportUnknownArgumentType]
-            else:
-                # No language hint, use default rendering
-                return original_render(code, info)
-
-        md.renderer.block_code = block_code
-
-    return plugin_pygments
-
-
-def render_markdown(text: str) -> str:
-    """Convert markdown text to HTML using mistune with Pygments syntax highlighting."""
-    # Track markdown rendering time if enabled
-    with timing_stat("_markdown_timings"):
-        # Configure mistune with GitHub-flavored markdown features
-        renderer = mistune.create_markdown(
-            plugins=[
-                "strikethrough",
-                "footnotes",
-                "table",
-                "url",
-                "task_lists",
-                "def_list",
-                _create_pygments_plugin(),
-            ],
-            escape=False,  # Don't escape HTML since we want to render markdown properly
-            hard_wrap=True,  # Line break for newlines (checklists in Assistant messages)
-        )
-        return str(renderer(text))
-
-
 def render_collapsible_code(
     preview_html: str,
     full_html: str,
@@ -3119,9 +3054,6 @@ def generate_html(
         env = _get_template_environment()
         template = env.get_template("transcript.html")
 
-    # Import html_renderer functions for template use
-    from .html_renderer import css_class_from_message, get_message_emoji
-
     with log_timing(lambda: f"Template rendering ({len(html_output)} chars)", t_start):
         html_output = str(
             template.render(

From 743a1b735a9d564e17fa7e421ae8a77f25ceeb0d Mon Sep 17 00:00:00 2001
From: Christian Boos 
Date: Mon, 8 Dec 2025 19:19:16 +0100
Subject: [PATCH 035/102] Move collapsible rendering functions to
 html_renderer.py (Phase 13, Step 3)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Move render_collapsible_code, render_markdown_collapsible, and
render_file_content_collapsible from renderer.py to html_renderer.py.

These are pure HTML generation functions with no format-neutral aspects.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 
---
 claude_code_log/html_renderer.py | 129 +++++++++++++++++++++++++++++
 claude_code_log/renderer.py      | 135 +------------------------------
 2 files changed, 133 insertions(+), 131 deletions(-)

diff --git a/claude_code_log/html_renderer.py b/claude_code_log/html_renderer.py
index f1e1486d..89a667fa 100644
--- a/claude_code_log/html_renderer.py
+++ b/claude_code_log/html_renderer.py
@@ -18,6 +18,7 @@
 
 import mistune
 
+from .renderer_code import highlight_code_with_pygments, truncate_highlighted_preview
 from .renderer_timings import timing_stat
 
 if TYPE_CHECKING:
@@ -168,3 +169,131 @@ def render_markdown(text: str) -> str:
             hard_wrap=True,  # Line break for newlines (checklists in Assistant messages)
         )
         return str(renderer(text))
+
+
+# -- Collapsible Content Rendering --------------------------------------------
+
+
+def render_collapsible_code(
+    preview_html: str,
+    full_html: str,
+    line_count: int,
+    is_markdown: bool = False,
+) -> str:
+    """Render a collapsible code/content block with preview.
+
+    Creates a details element with a line count badge and preview content
+    that expands to show the full content.
+
+    Args:
+        preview_html: HTML content to show in the collapsed summary
+        full_html: HTML content to show when expanded
+        line_count: Number of lines (shown in the badge)
+        is_markdown: If True, adds 'markdown' class to preview and full content divs
+
+    Returns:
+        HTML string with collapsible details element
+    """
+    markdown_class = " markdown" if is_markdown else ""
+    return f"""
+ + {line_count} lines +
{preview_html}
+
+
{full_html}
+
""" + + +def render_markdown_collapsible( + raw_content: str, + css_class: str, + line_threshold: int = 20, + preview_line_count: int = 5, +) -> str: + """Render markdown content, making it collapsible if it exceeds a line threshold. + + For long content, creates a collapsible details element with a preview. + For short content, renders inline with the specified CSS class. + + Args: + raw_content: The raw text content to render as markdown + css_class: CSS class for the wrapper div (e.g., "task-prompt", "task-result") + line_threshold: Number of lines above which content becomes collapsible (default 20) + preview_line_count: Number of lines to show in the preview (default 5) + + Returns: + HTML string with rendered markdown, optionally wrapped in collapsible details + """ + rendered_html = render_markdown(raw_content) + + lines = raw_content.splitlines() + if len(lines) <= line_threshold: + # Short content, show inline + return f'
{rendered_html}
' + + # Long content - make collapsible with rendered preview + preview_lines = lines[:preview_line_count] + preview_text = "\n".join(preview_lines) + if len(lines) > preview_line_count: + preview_text += "\n\n..." + # Render truncated markdown (produces valid HTML with proper tag closure) + preview_html = render_markdown(preview_text) + + collapsible = render_collapsible_code( + preview_html, rendered_html, len(lines), is_markdown=True + ) + return f'
{collapsible}
' + + +def render_file_content_collapsible( + code_content: str, + file_path: str, + css_class: str, + linenostart: int = 1, + line_threshold: int = 12, + preview_line_count: int = 5, + suffix_html: str = "", +) -> str: + """Render file content with syntax highlighting, collapsible if long. + + Highlights code using Pygments and wraps in a collapsible details element + if the content exceeds the line threshold. Uses preview truncation from + already-highlighted HTML to avoid double Pygments calls. + + Args: + code_content: The raw code content to highlight + file_path: File path for syntax detection (extension-based) + css_class: CSS class for the wrapper div (e.g., 'write-tool-content') + linenostart: Starting line number for Pygments (default 1) + line_threshold: Number of lines above which content becomes collapsible + preview_line_count: Number of lines to show in the preview + suffix_html: Optional HTML to append after the code (inside wrapper div) + + Returns: + HTML string with highlighted code, collapsible if >line_threshold lines + """ + # Highlight code with Pygments (single call) + highlighted_html = highlight_code_with_pygments( + code_content, file_path, linenostart=linenostart + ) + + html_parts = [f"
"] + + lines = code_content.split("\n") + if len(lines) > line_threshold: + # Extract preview from already-highlighted HTML (avoids double highlighting) + preview_html = truncate_highlighted_preview( + highlighted_html, preview_line_count + ) + html_parts.append( + render_collapsible_code(preview_html, highlighted_html, len(lines)) + ) + else: + # Show directly without collapsible + html_parts.append(highlighted_html) + + if suffix_html: + html_parts.append(suffix_html) + + html_parts.append("
") + return "".join(html_parts) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index fad5f07e..a56b09cd 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -48,16 +48,14 @@ ) from .cache import get_library_version from .ansi_colors import convert_ansi_to_html -from .renderer_code import ( - highlight_code_with_pygments, - truncate_highlighted_preview, - render_single_diff, -) +from .renderer_code import render_single_diff from .html_renderer import ( css_class_from_message, get_message_emoji, escape_html, - render_markdown, + render_collapsible_code, + render_markdown_collapsible, + render_file_content_collapsible, ) @@ -181,131 +179,6 @@ def format_timestamp(timestamp_str: str | None) -> str: return timestamp_str -def render_collapsible_code( - preview_html: str, - full_html: str, - line_count: int, - is_markdown: bool = False, -) -> str: - """Render a collapsible code/content block with preview. - - Creates a details element with a line count badge and preview content - that expands to show the full content. - - Args: - preview_html: HTML content to show in the collapsed summary - full_html: HTML content to show when expanded - line_count: Number of lines (shown in the badge) - is_markdown: If True, adds 'markdown' class to preview and full content divs - - Returns: - HTML string with collapsible details element - """ - markdown_class = " markdown" if is_markdown else "" - return f"""
- - {line_count} lines -
{preview_html}
-
-
{full_html}
-
""" - - -def render_markdown_collapsible( - raw_content: str, - css_class: str, - line_threshold: int = 20, - preview_line_count: int = 5, -) -> str: - """Render markdown content, making it collapsible if it exceeds a line threshold. - - For long content, creates a collapsible details element with a preview. - For short content, renders inline with the specified CSS class. - - Args: - raw_content: The raw text content to render as markdown - css_class: CSS class for the wrapper div (e.g., "task-prompt", "task-result") - line_threshold: Number of lines above which content becomes collapsible (default 20) - preview_line_count: Number of lines to show in the preview (default 5) - - Returns: - HTML string with rendered markdown, optionally wrapped in collapsible details - """ - rendered_html = render_markdown(raw_content) - - lines = raw_content.splitlines() - if len(lines) <= line_threshold: - # Short content, show inline - return f'
{rendered_html}
' - - # Long content - make collapsible with rendered preview - preview_lines = lines[:preview_line_count] - preview_text = "\n".join(preview_lines) - if len(lines) > preview_line_count: - preview_text += "\n\n..." - # Render truncated markdown (produces valid HTML with proper tag closure) - preview_html = render_markdown(preview_text) - - collapsible = render_collapsible_code( - preview_html, rendered_html, len(lines), is_markdown=True - ) - return f'
{collapsible}
' - - -def render_file_content_collapsible( - code_content: str, - file_path: str, - css_class: str, - linenostart: int = 1, - line_threshold: int = 12, - preview_line_count: int = 5, - suffix_html: str = "", -) -> str: - """Render file content with syntax highlighting, collapsible if long. - - Highlights code using Pygments and wraps in a collapsible details element - if the content exceeds the line threshold. Uses preview truncation from - already-highlighted HTML to avoid double Pygments calls. - - Args: - code_content: The raw code content to highlight - file_path: File path for syntax detection (extension-based) - css_class: CSS class for the wrapper div (e.g., 'write-tool-content') - linenostart: Starting line number for Pygments (default 1) - line_threshold: Number of lines above which content becomes collapsible - preview_line_count: Number of lines to show in the preview - suffix_html: Optional HTML to append after the code (inside wrapper div) - - Returns: - HTML string with highlighted code, collapsible if >line_threshold lines - """ - # Highlight code with Pygments (single call) - highlighted_html = highlight_code_with_pygments( - code_content, file_path, linenostart=linenostart - ) - - html_parts = [f"
"] - - lines = code_content.split("\n") - if len(lines) > line_threshold: - # Extract preview from already-highlighted HTML (avoids double highlighting) - preview_html = truncate_highlighted_preview( - highlighted_html, preview_line_count - ) - html_parts.append( - render_collapsible_code(preview_html, highlighted_html, len(lines)) - ) - else: - # Show directly without collapsible - html_parts.append(highlighted_html) - - if suffix_html: - html_parts.append(suffix_html) - - html_parts.append("
") - return "".join(html_parts) - - def extract_command_info(text_content: str) -> tuple[str, str, str]: """Extract command info from system message with command tags.""" import re From fca9c613fbe006987c4743ad07f2d9883bb21a04 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 8 Dec 2025 19:23:55 +0100 Subject: [PATCH 036/102] Move template environment to html_renderer.py (Phase 13, Step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move get_template_environment and starts_with_emoji functions from renderer.py to html_renderer.py with a new section header. Also remove unused jinja2 imports from renderer.py. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html_renderer.py | 53 ++++++++++++++++++++++++++++++++ claude_code_log/renderer.py | 46 ++------------------------- 2 files changed, 56 insertions(+), 43 deletions(-) diff --git a/claude_code_log/html_renderer.py b/claude_code_log/html_renderer.py index 89a667fa..acbe3d88 100644 --- a/claude_code_log/html_renderer.py +++ b/claude_code_log/html_renderer.py @@ -14,9 +14,11 @@ """ import html +from pathlib import Path from typing import Any, Optional, TYPE_CHECKING import mistune +from jinja2 import Environment, FileSystemLoader, select_autoescape from .renderer_code import highlight_code_with_pygments, truncate_highlighted_preview from .renderer_timings import timing_stat @@ -297,3 +299,54 @@ def render_file_content_collapsible( html_parts.append("
") return "".join(html_parts) + + +# -- Template Environment ----------------------------------------------------- + + +def starts_with_emoji(text: str) -> bool: + """Check if a string starts with an emoji character. + + Checks common emoji Unicode ranges: + - Emoticons: U+1F600 - U+1F64F + - Misc Symbols and Pictographs: U+1F300 - U+1F5FF + - Transport and Map Symbols: U+1F680 - U+1F6FF + - Supplemental Symbols: U+1F900 - U+1F9FF + - Misc Symbols: U+2600 - U+26FF + - Dingbats: U+2700 - U+27BF + """ + if not text: + return False + + first_char = text[0] + code_point = ord(first_char) + + return ( + 0x1F600 <= code_point <= 0x1F64F # Emoticons + or 0x1F300 <= code_point <= 0x1F5FF # Misc Symbols and Pictographs + or 0x1F680 <= code_point <= 0x1F6FF # Transport and Map Symbols + or 0x1F900 <= code_point <= 0x1F9FF # Supplemental Symbols + or 0x2600 <= code_point <= 0x26FF # Misc Symbols + or 0x2700 <= code_point <= 0x27BF # Dingbats + ) + + +def get_template_environment() -> Environment: + """Get Jinja2 template environment for HTML rendering. + + Creates a Jinja2 environment configured with: + - Template loading from the templates directory + - HTML auto-escaping + - Custom template filters/functions (starts_with_emoji) + + Returns: + Configured Jinja2 Environment + """ + templates_dir = Path(__file__).parent / "templates" + env = Environment( + loader=FileSystemLoader(templates_dir), + autoescape=select_autoescape(["html", "xml"]), + ) + # Add custom filters/functions + env.globals["starts_with_emoji"] = starts_with_emoji # type: ignore[index] + return env diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index a56b09cd..edc8c11a 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -12,8 +12,6 @@ from .cache import CacheManager from datetime import datetime import html -from jinja2 import Environment, FileSystemLoader, select_autoescape - from .models import ( MessageModifiers, MessageType, @@ -56,36 +54,10 @@ render_collapsible_code, render_markdown_collapsible, render_file_content_collapsible, + get_template_environment, ) -def starts_with_emoji(text: str) -> bool: - """Check if a string starts with an emoji character. - - Checks common emoji Unicode ranges: - - Emoticons: U+1F600 - U+1F64F - - Misc Symbols and Pictographs: U+1F300 - U+1F5FF - - Transport and Map Symbols: U+1F680 - U+1F6FF - - Supplemental Symbols: U+1F900 - U+1F9FF - - Misc Symbols: U+2600 - U+26FF - - Dingbats: U+2700 - U+27BF - """ - if not text: - return False - - first_char = text[0] - code_point = ord(first_char) - - return ( - 0x1F600 <= code_point <= 0x1F64F # Emoticons - or 0x1F300 <= code_point <= 0x1F5FF # Misc Symbols and Pictographs - or 0x1F680 <= code_point <= 0x1F6FF # Transport and Map Symbols - or 0x1F900 <= code_point <= 0x1F9FF # Supplemental Symbols - or 0x2600 <= code_point <= 0x26FF # Misc Symbols - or 0x2700 <= code_point <= 0x27BF # Dingbats - ) - - def get_project_display_name( project_dir_name: str, working_directories: Optional[List[str]] = None ) -> str: @@ -1251,18 +1223,6 @@ def render_message_content(content: List[ContentItem], message_type: str) -> str return "\n".join(rendered_parts) -def _get_template_environment() -> Environment: - """Get Jinja2 template environment.""" - templates_dir = Path(__file__).parent / "templates" - env = Environment( - loader=FileSystemLoader(templates_dir), - autoescape=select_autoescape(["html", "xml"]), - ) - # Add custom filters/functions - env.globals["starts_with_emoji"] = starts_with_emoji # type: ignore[index] - return env - - def _format_type_counts(type_counts: dict[str, int]) -> str: """Format type counts into human-readable label. @@ -2924,7 +2884,7 @@ def generate_html( # Render template with log_timing("Template environment setup", t_start): - env = _get_template_environment() + env = get_template_environment() template = env.get_template("transcript.html") with log_timing(lambda: f"Template rendering ({len(html_output)} chars)", t_start): @@ -3640,7 +3600,7 @@ def generate_projects_index_html( template_summary = TemplateSummary(project_summaries) # Render template - env = _get_template_environment() + env = get_template_environment() template = env.get_template("index.html") return str( template.render( From bfa1d4cea2fe8d99228dd8baa47eaab53b9f7b67 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 8 Dec 2025 19:46:08 +0100 Subject: [PATCH 037/102] Extract tool formatters to html_tool_renderers.py (Phase 13, Step 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move HTML tool formatting functions from renderer.py to new html_tool_renderers.py module: - AskUserQuestion formatters (format_askuserquestion_content/result) - ExitPlanMode formatters (format_exitplanmode_content/result) - TodoWrite formatter (format_todowrite_content) - File tools (format_read_tool_content, format_write_tool_content) - Edit tools (format_edit_tool_content, format_multiedit_tool_content) - Bash tool (format_bash_tool_content) - Task tool (format_task_tool_content) - Generic utilities (render_params_table, format_tool_use_content) Update renderer.py imports to only include the 4 functions actually used. Update test imports to use new module location. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html_tool_renderers.py | 507 +++++++++++++++++++++++++ claude_code_log/renderer.py | 435 +-------------------- test/test_askuserquestion_rendering.py | 2 +- test/test_exitplanmode_rendering.py | 2 +- test/test_todowrite_rendering.py | 4 +- 5 files changed, 520 insertions(+), 430 deletions(-) create mode 100644 claude_code_log/html_tool_renderers.py diff --git a/claude_code_log/html_tool_renderers.py b/claude_code_log/html_tool_renderers.py new file mode 100644 index 00000000..639c38c6 --- /dev/null +++ b/claude_code_log/html_tool_renderers.py @@ -0,0 +1,507 @@ +"""HTML rendering functions for tool use and tool result content. + +This module contains all HTML formatters for specific tools: +- AskUserQuestion tool (input + result) +- ExitPlanMode tool (input + result) +- TodoWrite tool +- Read/Write/Edit/Multiedit tools +- Bash tool +- Task tool +- Generic parameter table rendering +- Tool use content dispatcher + +These formatters take tool-specific input/output data and generate +HTML for display in transcripts. +""" + +import json +import re +from typing import Any, Dict, List + +from .html_renderer import ( + escape_html, + render_file_content_collapsible, + render_markdown_collapsible, +) +from .models import ToolUseContent +from .renderer_code import render_single_diff + + +# -- AskUserQuestion Tool ----------------------------------------------------- + + +def format_askuserquestion_content(tool_use: ToolUseContent) -> str: + """Format AskUserQuestion tool use content with prominent question display. + + Handles multiple questions in a single tool use, each with optional header, + options (with label and description), and multiSelect flag. + """ + questions_data = tool_use.input.get("questions", []) + # Also handle single question format for backwards compatibility + if not questions_data: + single_question = tool_use.input.get("question", "") + if single_question: + questions_data = [{"question": single_question}] + + if not questions_data: + return render_params_table(tool_use.input) + + # Build HTML for all questions + html_parts: List[str] = ['
'] + + for q_data in questions_data: + try: + question_text = escape_html(str(q_data.get("question", ""))) + header = q_data.get("header", "") + options = q_data.get("options", []) + multi_select = q_data.get("multiSelect", False) + + # Question container + html_parts.append('
') + + # Header (if present) + if header: + escaped_header = escape_html(str(header)) + html_parts.append( + f'
{escaped_header}
' + ) + + # Question text with icon + html_parts.append(f'
❓ {question_text}
') + + # Options (if present) + if options: + select_hint = "(select multiple)" if multi_select else "(select one)" + html_parts.append( + f'
{select_hint}
' + ) + html_parts.append('
    ') + for opt in options: + label = escape_html(str(opt.get("label", ""))) + desc = opt.get("description", "") + if desc: + desc_html = f' — {escape_html(str(desc))}' + else: + desc_html = "" + html_parts.append( + f'
  • {label}{desc_html}
  • ' + ) + html_parts.append("
") + + html_parts.append("
") # Close question-block + except (AttributeError, TypeError): + # Fallback for unexpected format + html_parts.append( + f'
❓ {escape_html(str(q_data))}
' + ) + + html_parts.append("
") # Close askuserquestion-content + return "".join(html_parts) + + +def format_askuserquestion_result(content: str) -> str: + """Format AskUserQuestion tool result with styled question/answer pairs. + + Parses the result format: + 'User has answered your questions: "Q1"="A1", "Q2"="A2". You can now continue...' + + Returns HTML with styled Q&A blocks matching the input styling. + """ + # Check if this is a successful answer + if not content.startswith("User has answered your question"): + # Return as-is for errors or unexpected format + return "" + + # Extract the Q&A portion between the colon and the final sentence + # Pattern: 'User has answered your questions: "Q"="A", "Q"="A". You can now...' + match = re.match( + r"User has answered your questions?: (.+)\. You can now continue", + content, + re.DOTALL, + ) + if not match: + return "" + + qa_portion = match.group(1) + + # Parse "Question"="Answer" pairs + # Pattern: "question text"="answer text" + qa_pattern = re.compile(r'"([^"]+)"="([^"]+)"') + pairs = qa_pattern.findall(qa_portion) + + if not pairs: + return "" + + # Build styled HTML + html_parts: List[str] = [ + '
' + ] + + for question, answer in pairs: + escaped_q = escape_html(question) + escaped_a = escape_html(answer) + html_parts.append('
') + html_parts.append(f'
❓ {escaped_q}
') + html_parts.append(f'
✅ {escaped_a}
') + html_parts.append("
") + + html_parts.append("
") + return "".join(html_parts) + + +# -- ExitPlanMode Tool -------------------------------------------------------- + + +def format_exitplanmode_content(tool_use: ToolUseContent) -> str: + """Format ExitPlanMode tool use content with collapsible plan markdown. + + Renders the plan markdown in a collapsible section, similar to Task tool results. + """ + plan = tool_use.input.get("plan", "") + + if not plan: + # No plan, show parameters table as fallback + return render_params_table(tool_use.input) + + return render_markdown_collapsible(plan, "plan-content") + + +def format_exitplanmode_result(content: str) -> str: + """Format ExitPlanMode tool result, truncating the redundant plan echo. + + When a plan is approved, the result contains: + 1. A confirmation message + 2. Path to saved plan file + 3. "## Approved Plan:" followed by full plan text (redundant) + + We truncate everything after "## Approved Plan:" to avoid duplication. + For error results (plan not approved), we keep the full content. + """ + # Check if this is a successful approval + if "User has approved your plan" in content: + # Truncate at "## Approved Plan:" + marker = "## Approved Plan:" + marker_pos = content.find(marker) + if marker_pos > 0: + # Keep everything before the marker, strip trailing whitespace + return content[:marker_pos].rstrip() + + # For errors or other cases, return as-is + return content + + +# -- TodoWrite Tool ----------------------------------------------------------- + + +def format_todowrite_content(tool_use: ToolUseContent) -> str: + """Format TodoWrite tool use content as a todo list.""" + # Parse todos from input + todos_data = tool_use.input.get("todos", []) + if not todos_data: + return """ +
+

No todos found

+
+ """ + + # Status emojis + status_emojis = {"pending": "⏳", "in_progress": "🔄", "completed": "✅"} + + # Build todo list HTML + todo_items: List[str] = [] + for todo in todos_data: + try: + todo_id = escape_html(str(todo.get("id", ""))) + content = escape_html(str(todo.get("content", ""))) + status = str(todo.get("status", "pending")).lower() + priority = str(todo.get("priority", "medium")).lower() + status_emoji = status_emojis.get(status, "⏳") + + # CSS class for styling + item_class = f"todo-item {status} {priority}" + + todo_items.append(f""" +
+ {status_emoji} + {content} + #{todo_id} +
+ """) + except AttributeError: + escaped_fallback = escape_html(str(todo)) + todo_items.append(f""" +
+ + {escaped_fallback} +
+ """) + + todos_html = "".join(todo_items) + + return f""" +
+ {todos_html} +
+ """ + + +# -- File Tools (Read/Write) -------------------------------------------------- + + +def format_read_tool_content(tool_use: ToolUseContent) -> str: # noqa: ARG001 + """Format Read tool use content showing file path. + + Note: File path is now shown in the header, so we skip content here. + """ + # File path is now shown in header, so no content needed + # Don't show offset/limit parameters as they'll be visible in the result + return "" + + +def format_write_tool_content(tool_use: ToolUseContent) -> str: + """Format Write tool use content with Pygments syntax highlighting. + + Note: File path is now shown in the header, so we skip it here. + """ + file_path = tool_use.input.get("file_path", "") + content = tool_use.input.get("content", "") + + return render_file_content_collapsible(content, file_path, "write-tool-content") + + +# -- Edit Tools (Edit/Multiedit) ---------------------------------------------- + + +def format_edit_tool_content(tool_use: ToolUseContent) -> str: + """Format Edit tool use content as a diff view with intra-line highlighting. + + Note: File path is now shown in the header, so we skip it here. + """ + old_string = tool_use.input.get("old_string", "") + new_string = tool_use.input.get("new_string", "") + replace_all = tool_use.input.get("replace_all", False) + + html_parts = ["
"] + + # File path is now shown in header, so we skip it here + + if replace_all: + html_parts.append( + "
🔄 Replace all occurrences
" + ) + + # Use shared diff rendering helper + html_parts.append(render_single_diff(old_string, new_string)) + html_parts.append("
") + + return "".join(html_parts) + + +def format_multiedit_tool_content(tool_use: ToolUseContent) -> str: + """Format Multiedit tool use content showing multiple diffs.""" + file_path = tool_use.input.get("file_path", "") + edits = tool_use.input.get("edits", []) + + escaped_path = escape_html(file_path) + + html_parts = ["
"] + + # File path header + html_parts.append(f"
📝 {escaped_path}
") + html_parts.append(f"
Applying {len(edits)} edits
") + + # Render each edit as a diff + for idx, edit in enumerate(edits, 1): + old_string = edit.get("old_string", "") + new_string = edit.get("new_string", "") + + html_parts.append( + f"
Edit #{idx}
" + ) + html_parts.append(render_single_diff(old_string, new_string)) + html_parts.append("
") + + html_parts.append("
") + return "".join(html_parts) + + +# -- Bash Tool ---------------------------------------------------------------- + + +def format_bash_tool_content(tool_use: ToolUseContent) -> str: + """Format Bash tool use content in VS Code extension style. + + Note: Description is now shown in the header, so we skip it here. + """ + command = tool_use.input.get("command", "") + + escaped_command = escape_html(command) + + html_parts = ["
"] + + # Description is now shown in header, so we skip it here + + # Add command in preformatted block + html_parts.append(f"
{escaped_command}
") + html_parts.append("
") + + return "".join(html_parts) + + +# -- Task Tool ---------------------------------------------------------------- + + +def format_task_tool_content(tool_use: ToolUseContent) -> str: + """Format Task tool content with markdown-rendered prompt. + + Task tool spawns sub-agents. We render the prompt as the main content. + The sidechain user message (which would duplicate this prompt) is skipped. + + For long prompts (>20 lines), the content is made collapsible with a + preview of the first few lines to keep the transcript vertically compact. + """ + prompt = tool_use.input.get("prompt", "") + + if not prompt: + # No prompt, show parameters table as fallback + return render_params_table(tool_use.input) + + return render_markdown_collapsible(prompt, "task-prompt") + + +# -- Generic Parameter Table -------------------------------------------------- + + +def render_params_table(params: Dict[str, Any]) -> str: + """Render a dictionary of parameters as an HTML table. + + Reusable for tool parameters, diagnostic objects, etc. + """ + if not params: + return "
No parameters
" + + html_parts = [""] + + for key, value in params.items(): + escaped_key = escape_html(str(key)) + + # If value is structured (dict/list), render as JSON + if isinstance(value, (dict, list)): + try: + formatted_value = json.dumps(value, indent=2, ensure_ascii=False) # type: ignore[arg-type] + escaped_value = escape_html(formatted_value) + + # Make long structured values collapsible + if len(formatted_value) > 200: + preview = escape_html(formatted_value[:100]) + "..." + value_html = f""" +
+ {preview} +
{escaped_value}
+
+ """ + else: + value_html = ( + f"
{escaped_value}
" + ) + except (TypeError, ValueError): + escaped_value = escape_html(str(value)) # type: ignore[arg-type] + value_html = escaped_value + else: + # Simple value, render as-is (or collapsible if long) + escaped_value = escape_html(str(value)) + + # Make long string values collapsible + if len(str(value)) > 100: + preview = escape_html(str(value)[:80]) + "..." + value_html = f""" +
+ {preview} +
{escaped_value}
+
+ """ + else: + value_html = escaped_value + + html_parts.append(f""" + + + + + """) + + html_parts.append("
{escaped_key}{value_html}
") + return "".join(html_parts) + + +# -- Tool Use Dispatcher ------------------------------------------------------ + + +def format_tool_use_content(tool_use: ToolUseContent) -> str: + """Format tool use content as HTML.""" + # Special handling for TodoWrite + if tool_use.name == "TodoWrite": + return format_todowrite_content(tool_use) + + # Special handling for Bash + if tool_use.name == "Bash": + return format_bash_tool_content(tool_use) + + # Special handling for Edit + if tool_use.name == "Edit": + return format_edit_tool_content(tool_use) + + # Special handling for Multiedit + if tool_use.name == "Multiedit": + return format_multiedit_tool_content(tool_use) + + # Special handling for Read + if tool_use.name == "Read": + return format_read_tool_content(tool_use) + + # Special handling for Write + if tool_use.name == "Write": + return format_write_tool_content(tool_use) + + # Special handling for Task (agent spawning) + if tool_use.name == "Task": + return format_task_tool_content(tool_use) + + # Special handling for AskUserQuestion + if tool_use.name == "AskUserQuestion": + return format_askuserquestion_content(tool_use) + + # Special handling for ExitPlanMode + if tool_use.name == "ExitPlanMode": + return format_exitplanmode_content(tool_use) + + # Default: render as key/value table using shared renderer + return render_params_table(tool_use.input) + + +# -- Public Exports ----------------------------------------------------------- + +__all__ = [ + # AskUserQuestion + "format_askuserquestion_content", + "format_askuserquestion_result", + # ExitPlanMode + "format_exitplanmode_content", + "format_exitplanmode_result", + # TodoWrite + "format_todowrite_content", + # File tools + "format_read_tool_content", + "format_write_tool_content", + # Edit tools + "format_edit_tool_content", + "format_multiedit_tool_content", + # Bash + "format_bash_tool_content", + # Task + "format_task_tool_content", + # Generic + "render_params_table", + # Dispatcher + "format_tool_use_content", +] diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index edc8c11a..b8444060 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -46,7 +46,7 @@ ) from .cache import get_library_version from .ansi_colors import convert_ansi_to_html -from .renderer_code import render_single_diff + from .html_renderer import ( css_class_from_message, get_message_emoji, @@ -56,6 +56,12 @@ render_file_content_collapsible, get_template_environment, ) +from .html_tool_renderers import ( + format_askuserquestion_result, + format_exitplanmode_result, + render_params_table, + format_tool_use_content, +) def get_project_display_name( @@ -191,389 +197,8 @@ def extract_command_info(text_content: str) -> tuple[str, str, str]: return command_name, command_args, command_contents -def format_askuserquestion_content(tool_use: ToolUseContent) -> str: - """Format AskUserQuestion tool use content with prominent question display. - - Handles multiple questions in a single tool use, each with optional header, - options (with label and description), and multiSelect flag. - """ - questions_data = tool_use.input.get("questions", []) - # Also handle single question format for backwards compatibility - if not questions_data: - single_question = tool_use.input.get("question", "") - if single_question: - questions_data = [{"question": single_question}] - - if not questions_data: - return render_params_table(tool_use.input) - - # Build HTML for all questions - html_parts: List[str] = ['
'] - - for q_data in questions_data: - try: - question_text = escape_html(str(q_data.get("question", ""))) - header = q_data.get("header", "") - options = q_data.get("options", []) - multi_select = q_data.get("multiSelect", False) - - # Question container - html_parts.append('
') - - # Header (if present) - if header: - escaped_header = escape_html(str(header)) - html_parts.append( - f'
{escaped_header}
' - ) - - # Question text with icon - html_parts.append(f'
❓ {question_text}
') - - # Options (if present) - if options: - select_hint = "(select multiple)" if multi_select else "(select one)" - html_parts.append( - f'
{select_hint}
' - ) - html_parts.append('
    ') - for opt in options: - label = escape_html(str(opt.get("label", ""))) - desc = opt.get("description", "") - if desc: - desc_html = f' — {escape_html(str(desc))}' - else: - desc_html = "" - html_parts.append( - f'
  • {label}{desc_html}
  • ' - ) - html_parts.append("
") - - html_parts.append("
") # Close question-block - except (AttributeError, TypeError): - # Fallback for unexpected format - html_parts.append( - f'
❓ {escape_html(str(q_data))}
' - ) - - html_parts.append("
") # Close askuserquestion-content - return "".join(html_parts) - - -def format_askuserquestion_result(content: str) -> str: - """Format AskUserQuestion tool result with styled question/answer pairs. - - Parses the result format: - 'User has answered your questions: "Q1"="A1", "Q2"="A2". You can now continue...' - - Returns HTML with styled Q&A blocks matching the input styling. - """ - import re - - # Check if this is a successful answer - if not content.startswith("User has answered your question"): - # Return as-is for errors or unexpected format - return "" - - # Extract the Q&A portion between the colon and the final sentence - # Pattern: 'User has answered your questions: "Q"="A", "Q"="A". You can now...' - match = re.match( - r"User has answered your questions?: (.+)\. You can now continue", - content, - re.DOTALL, - ) - if not match: - return "" - - qa_portion = match.group(1) - - # Parse "Question"="Answer" pairs - # Pattern: "question text"="answer text" - qa_pattern = re.compile(r'"([^"]+)"="([^"]+)"') - pairs = qa_pattern.findall(qa_portion) - - if not pairs: - return "" - - # Build styled HTML - html_parts: List[str] = [ - '
' - ] - - for question, answer in pairs: - escaped_q = escape_html(question) - escaped_a = escape_html(answer) - html_parts.append('
') - html_parts.append(f'
❓ {escaped_q}
') - html_parts.append(f'
✅ {escaped_a}
') - html_parts.append("
") - - html_parts.append("
") - return "".join(html_parts) - - -def format_exitplanmode_content(tool_use: ToolUseContent) -> str: - """Format ExitPlanMode tool use content with collapsible plan markdown. - - Renders the plan markdown in a collapsible section, similar to Task tool results. - """ - plan = tool_use.input.get("plan", "") - - if not plan: - # No plan, show parameters table as fallback - return render_params_table(tool_use.input) - - return render_markdown_collapsible(plan, "plan-content") - - -def format_exitplanmode_result(content: str) -> str: - """Format ExitPlanMode tool result, truncating the redundant plan echo. - - When a plan is approved, the result contains: - 1. A confirmation message - 2. Path to saved plan file - 3. "## Approved Plan:" followed by full plan text (redundant) - - We truncate everything after "## Approved Plan:" to avoid duplication. - For error results (plan not approved), we keep the full content. - """ - # Check if this is a successful approval - if "User has approved your plan" in content: - # Truncate at "## Approved Plan:" - marker = "## Approved Plan:" - marker_pos = content.find(marker) - if marker_pos > 0: - # Keep everything before the marker, strip trailing whitespace - return content[:marker_pos].rstrip() - - # For errors or other cases, return as-is - return content - - -def format_todowrite_content(tool_use: ToolUseContent) -> str: - """Format TodoWrite tool use content as a todo list.""" - # Parse todos from input - todos_data = tool_use.input.get("todos", []) - if not todos_data: - return """ -
-

No todos found

-
- """ - - # Status emojis - status_emojis = {"pending": "⏳", "in_progress": "🔄", "completed": "✅"} - - # Build todo list HTML - todo_items: List[str] = [] - for todo in todos_data: - try: - todo_id = escape_html(str(todo.get("id", ""))) - content = escape_html(str(todo.get("content", ""))) - status = str(todo.get("status", "pending")).lower() - priority = str(todo.get("priority", "medium")).lower() - status_emoji = status_emojis.get(status, "⏳") - - # CSS class for styling - item_class = f"todo-item {status} {priority}" - - todo_items.append(f""" -
- {status_emoji} - {content} - #{todo_id} -
- """) - except AttributeError: - escaped_fallback = escape_html(str(todo)) - todo_items.append(f""" -
- - {escaped_fallback} -
- """) - - todos_html = "".join(todo_items) - - return f""" -
- {todos_html} -
- """ - - -def format_read_tool_content(tool_use: ToolUseContent) -> str: # noqa: ARG001 - """Format Read tool use content showing file path. - - Note: File path is now shown in the header, so we skip content here. - """ - # File path is now shown in header, so no content needed - # Don't show offset/limit parameters as they'll be visible in the result - return "" - - -def format_write_tool_content(tool_use: ToolUseContent) -> str: - """Format Write tool use content with Pygments syntax highlighting. - - Note: File path is now shown in the header, so we skip it here. - """ - file_path = tool_use.input.get("file_path", "") - content = tool_use.input.get("content", "") - - return render_file_content_collapsible(content, file_path, "write-tool-content") - - -def format_bash_tool_content(tool_use: ToolUseContent) -> str: - """Format Bash tool use content in VS Code extension style. - - Note: Description is now shown in the header, so we skip it here. - """ - command = tool_use.input.get("command", "") - - escaped_command = escape_html(command) - - html_parts = ["
"] - - # Description is now shown in header, so we skip it here - - # Add command in preformatted block - html_parts.append(f"
{escaped_command}
") - html_parts.append("
") - - return "".join(html_parts) - - -def render_params_table(params: Dict[str, Any]) -> str: - """Render a dictionary of parameters as an HTML table. - - Reusable for tool parameters, diagnostic objects, etc. - """ - if not params: - return "
No parameters
" - - html_parts = [""] - - for key, value in params.items(): - escaped_key = escape_html(str(key)) - - # If value is structured (dict/list), render as JSON - if isinstance(value, (dict, list)): - try: - formatted_value = json.dumps(value, indent=2, ensure_ascii=False) # type: ignore[arg-type] - escaped_value = escape_html(formatted_value) - - # Make long structured values collapsible - if len(formatted_value) > 200: - preview = escape_html(formatted_value[:100]) + "..." - value_html = f""" -
- {preview} -
{escaped_value}
-
- """ - else: - value_html = ( - f"
{escaped_value}
" - ) - except (TypeError, ValueError): - escaped_value = escape_html(str(value)) # type: ignore[arg-type] - value_html = escaped_value - else: - # Simple value, render as-is (or collapsible if long) - escaped_value = escape_html(str(value)) - - # Make long string values collapsible - if len(str(value)) > 100: - preview = escape_html(str(value)[:80]) + "..." - value_html = f""" -
- {preview} -
{escaped_value}
-
- """ - else: - value_html = escaped_value - - html_parts.append(f""" - - - - - """) - - html_parts.append("
{escaped_key}{value_html}
") - return "".join(html_parts) - - -def format_multiedit_tool_content(tool_use: ToolUseContent) -> str: - """Format Multiedit tool use content showing multiple diffs.""" - file_path = tool_use.input.get("file_path", "") - edits = tool_use.input.get("edits", []) - - escaped_path = escape_html(file_path) - - html_parts = ["
"] - - # File path header - html_parts.append(f"
📝 {escaped_path}
") - html_parts.append(f"
Applying {len(edits)} edits
") - - # Render each edit as a diff - for idx, edit in enumerate(edits, 1): - old_string = edit.get("old_string", "") - new_string = edit.get("new_string", "") - - html_parts.append( - f"
Edit #{idx}
" - ) - html_parts.append(render_single_diff(old_string, new_string)) - html_parts.append("
") - - html_parts.append("
") - return "".join(html_parts) - - -def format_edit_tool_content(tool_use: ToolUseContent) -> str: - """Format Edit tool use content as a diff view with intra-line highlighting. - - Note: File path is now shown in the header, so we skip it here. - """ - old_string = tool_use.input.get("old_string", "") - new_string = tool_use.input.get("new_string", "") - replace_all = tool_use.input.get("replace_all", False) - - html_parts = ["
"] - - # File path is now shown in header, so we skip it here - - if replace_all: - html_parts.append( - "
🔄 Replace all occurrences
" - ) - - # Use shared diff rendering helper - html_parts.append(render_single_diff(old_string, new_string)) - html_parts.append("
") - - return "".join(html_parts) - - -def format_task_tool_content(tool_use: ToolUseContent) -> str: - """Format Task tool content with markdown-rendered prompt. - - Task tool spawns sub-agents. We render the prompt as the main content. - The sidechain user message (which would duplicate this prompt) is skipped. - - For long prompts (>20 lines), the content is made collapsible with a - preview of the first few lines to keep the transcript vertically compact. - """ - prompt = tool_use.input.get("prompt", "") - - if not prompt: - # No prompt, show parameters table as fallback - return render_params_table(tool_use.input) - - return render_markdown_collapsible(prompt, "task-prompt") +# NOTE: Tool formatters have been moved to html_tool_renderers.py +# Imports added at the top of the file. def get_tool_summary(tool_use: ToolUseContent) -> Optional[str]: @@ -605,48 +230,6 @@ def get_tool_summary(tool_use: ToolUseContent) -> Optional[str]: return None -def format_tool_use_content(tool_use: ToolUseContent) -> str: - """Format tool use content as HTML.""" - # Special handling for TodoWrite - if tool_use.name == "TodoWrite": - return format_todowrite_content(tool_use) - - # Special handling for Bash - if tool_use.name == "Bash": - return format_bash_tool_content(tool_use) - - # Special handling for Edit - if tool_use.name == "Edit": - return format_edit_tool_content(tool_use) - - # Special handling for Multiedit - if tool_use.name == "Multiedit": - return format_multiedit_tool_content(tool_use) - - # Special handling for Read - if tool_use.name == "Read": - return format_read_tool_content(tool_use) - - # Special handling for Write - if tool_use.name == "Write": - return format_write_tool_content(tool_use) - - # Special handling for Task (agent spawning) - if tool_use.name == "Task": - return format_task_tool_content(tool_use) - - # Special handling for AskUserQuestion - if tool_use.name == "AskUserQuestion": - return format_askuserquestion_content(tool_use) - - # Special handling for ExitPlanMode - if tool_use.name == "ExitPlanMode": - return format_exitplanmode_content(tool_use) - - # Default: render as key/value table using shared renderer - return render_params_table(tool_use.input) - - def _parse_cat_n_snippet( lines: List[str], start_idx: int = 0 ) -> Optional[tuple[str, Optional[str], int]]: diff --git a/test/test_askuserquestion_rendering.py b/test/test_askuserquestion_rendering.py index a2c85d36..fec41481 100644 --- a/test/test_askuserquestion_rendering.py +++ b/test/test_askuserquestion_rendering.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Test cases for AskUserQuestion tool rendering.""" -from claude_code_log.renderer import ( +from claude_code_log.html_tool_renderers import ( format_askuserquestion_content, format_askuserquestion_result, ) diff --git a/test/test_exitplanmode_rendering.py b/test/test_exitplanmode_rendering.py index c733d741..3b0edb82 100644 --- a/test/test_exitplanmode_rendering.py +++ b/test/test_exitplanmode_rendering.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Test cases for ExitPlanMode tool rendering.""" -from claude_code_log.renderer import ( +from claude_code_log.html_tool_renderers import ( format_exitplanmode_content, format_exitplanmode_result, ) diff --git a/test/test_todowrite_rendering.py b/test/test_todowrite_rendering.py index 1a3b60a9..8bdd6adf 100644 --- a/test/test_todowrite_rendering.py +++ b/test/test_todowrite_rendering.py @@ -6,7 +6,7 @@ from pathlib import Path import pytest from claude_code_log.converter import convert_jsonl_to_html -from claude_code_log.renderer import format_todowrite_content +from claude_code_log.html_tool_renderers import format_todowrite_content from claude_code_log.models import ToolUseContent @@ -247,7 +247,7 @@ def test_todowrite_vs_regular_tool_use(self): ) # Test both through the main format function - from claude_code_log.renderer import format_tool_use_content + from claude_code_log.html_tool_renderers import format_tool_use_content regular_html = format_tool_use_content(regular_tool) todowrite_html = format_tool_use_content(todowrite_tool) From 569f29f662beca984c82ac3b5c3dcc458e9fcf70 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 8 Dec 2025 19:48:37 +0100 Subject: [PATCH 038/102] Update MESSAGE_REFACTORING.md with Phase 12 implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add detailed 11-step implementation plan for Phase 12 (renderer decomposition): - Steps 1-5 marked complete (html_renderer.py, html_tool_renderers.py) - Steps 6-11 pending (further splitting and reorganization) - Updated target architecture showing new module structure - Summary of completed changes (~420 lines moved) - Mark Phase 12 as in progress in Next Steps section 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- dev-docs/MESSAGE_REFACTORING.md | 49 +++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/dev-docs/MESSAGE_REFACTORING.md b/dev-docs/MESSAGE_REFACTORING.md index 0f9f8add..c27a0985 100755 --- a/dev-docs/MESSAGE_REFACTORING.md +++ b/dev-docs/MESSAGE_REFACTORING.md @@ -329,7 +329,7 @@ code that wants to use them, independent of the format-neutral refactoring. **Goal**: Separate format-neutral logic from HTML-specific generation -**Current Architecture**: +**Current Architecture** (before Phase 12): ``` renderer.py (3853 lines) ├── Message processing (format-neutral) @@ -346,29 +346,54 @@ renderer.py (3853 lines) **Target Architecture**: ``` -message_processor.py (format-neutral) -├── MessageProcessor class -├── Pairing logic -├── Hierarchy building +renderer.py (format-neutral orchestration) +├── Message processing +├── Pairing & hierarchy logic └── Token aggregation -html_renderer.py (HTML-specific) +html_renderer.py (HTML utilities) ├── CSS class computation -├── Template rendering -└── Tool HTML formatters +├── Markdown rendering +├── Collapsible content +└── Template environment + +html_tool_renderers.py (tool HTML formatters) +├── Tool use formatters +└── Tool result formatters text_renderer.py (future - golergka's work) ├── Text/markdown output └── Chat format ``` +**Implementation Steps** (Phase 13 plan): + +| Step | Description | Status | +|------|-------------|--------| +| 1 | Organize html_renderer.py with thematic sections | ✅ Complete | +| 2 | Move pure HTML utilities (escape_html, render_markdown, _create_pygments_plugin) | ✅ Complete | +| 3 | Move collapsible rendering functions | ✅ Complete | +| 4 | Move template environment (get_template_environment, starts_with_emoji) | ✅ Complete | +| 5 | Create html_tool_renderers.py with tool formatters | ✅ Complete | +| 6 | Split tool formatters (two-stage: parse + render) | Pending | +| 7 | Split message content renderers | Pending | +| 8 | Split _process_* message functions | Pending | +| 9 | Move generate_projects_index_html | Pending | +| 10 | Reorganize renderer.py with thematic sections | Pending | +| 11 | Finalize html_renderer.py structure | Pending | + +**Completed Changes**: +- `html_renderer.py`: CSS class computation, markdown rendering, collapsible content, template env +- `html_tool_renderers.py`: 13 tool formatters moved from renderer.py (~420 lines) +- `renderer.py`: Reduced from 3853 to ~3400 lines, imports from new modules + **Dependencies**: -- Requires Phase 9 (type safety) for clean interfaces -- Benefits from Phase 10 (parser simplification) +- Requires Phase 9 (type safety) for clean interfaces ✅ +- Benefits from Phase 10 (parser simplification) ✅ - Enables golergka's multi-format integration **Risk**: High - requires careful refactoring -**Priority**: Medium-term goal +**Priority**: In progress ## Recommended Execution Order @@ -386,7 +411,7 @@ For maximum impact with minimum risk: ### Next Steps 8. ✅ **Phase 10 (Parser)** - Simplified extract_text_content() with isinstance checks 9. ✅ **Phase 11 (Tool Models)** - Added typed input models for 9 common tools -10. **Phase 12 (Format Neutral)** - Long-term goal, enables multi-format output +10. 🔄 **Phase 12 (Format Neutral)** - In progress (Steps 1-5 complete, 6-11 pending) **Tree Refactoring Integration:** - Tree building (TEMPLATE_MESSAGE_CHILDREN.md Phase 1-2) is complete and non-blocking From fb1617a8f57345dd88d0c33f08ea2fae2376bb19 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 8 Dec 2025 20:10:59 +0100 Subject: [PATCH 039/102] Complete Phase 13: add thematic sections and update docs (Steps 10-11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 10: Reorganize renderer.py with 9 thematic section headers: - Utility Functions - Tool Summary and Result Parsing - Content Formatters - Template Classes - Message Processing Functions - Message Pairing and Hierarchy - Deduplication - High-Level HTML Generation - Project Index Generation Step 11: Update MESSAGE_REFACTORING.md with: - Phase 13 completion status - Deferred steps rationale (6-9) - Final metrics (renderer.py: 3219 lines, html_renderer.py: 352 lines, html_tool_renderers.py: 506 lines) - Updated references section 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/renderer.py | 28 ++++++++++++++++++++-- dev-docs/MESSAGE_REFACTORING.md | 41 ++++++++++++++++++++------------- 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index b8444060..b2be6868 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -64,6 +64,9 @@ ) +# -- Utility Functions -------------------------------------------------------- + + def get_project_display_name( project_dir_name: str, working_directories: Optional[List[str]] = None ) -> str: @@ -197,8 +200,8 @@ def extract_command_info(text_content: str) -> tuple[str, str, str]: return command_name, command_args, command_contents -# NOTE: Tool formatters have been moved to html_tool_renderers.py -# Imports added at the top of the file. +# -- Tool Summary and Result Parsing ------------------------------------------ +# NOTE: Tool content formatters have been moved to html_tool_renderers.py def get_tool_summary(tool_use: ToolUseContent) -> Optional[str]: @@ -540,6 +543,9 @@ def _looks_like_bash_output(content: str) -> bool: return False +# -- Content Formatters ------------------------------------------------------- + + def format_thinking_content(thinking: ThinkingContent) -> str: """Format thinking content as HTML with markdown rendering.""" thinking_text = thinking.thinking.strip() @@ -882,6 +888,9 @@ def _format_type_counts(type_counts: dict[str, int]) -> str: return f"{parts[0]}, {parts[1]}, {remaining} more" +# -- Template Classes --------------------------------------------------------- + + class TemplateMessage: """Structured message data for template rendering.""" @@ -1131,6 +1140,9 @@ def __init__(self, project_summaries: List[Dict[str, Any]]): self.token_summary = " | ".join(token_parts) +# -- Message Processing Functions --------------------------------------------- + + def _render_hook_summary(message: "SystemTranscriptEntry") -> str: """Render a hook summary as collapsible details. @@ -1724,6 +1736,9 @@ def _get_combined_transcript_link(cache_manager: "CacheManager") -> Optional[str return None +# -- Message Pairing and Hierarchy -------------------------------------------- + + @dataclass class PairingIndices: """Indices for efficient message pairing lookups. @@ -2230,6 +2245,9 @@ def _build_message_tree(messages: List[TemplateMessage]) -> List[TemplateMessage return root_messages +# -- Deduplication ------------------------------------------------------------ + + def deduplicate_messages(messages: List[TranscriptEntry]) -> List[TranscriptEntry]: """Remove duplicate messages based on (type, timestamp, sessionId, content_key). @@ -2301,6 +2319,9 @@ def deduplicate_messages(messages: List[TranscriptEntry]) -> List[TranscriptEntr return deduplicated +# -- High-Level HTML Generation ----------------------------------------------- + + def generate_html( messages: List[TranscriptEntry], title: Optional[str] = None, @@ -3114,6 +3135,9 @@ def _process_messages_loop( ) +# -- Project Index Generation ------------------------------------------------- + + def generate_projects_index_html( project_summaries: List[Dict[str, Any]], from_date: Optional[str] = None, diff --git a/dev-docs/MESSAGE_REFACTORING.md b/dev-docs/MESSAGE_REFACTORING.md index c27a0985..39d99fe2 100755 --- a/dev-docs/MESSAGE_REFACTORING.md +++ b/dev-docs/MESSAGE_REFACTORING.md @@ -375,17 +375,24 @@ text_renderer.py (future - golergka's work) | 3 | Move collapsible rendering functions | ✅ Complete | | 4 | Move template environment (get_template_environment, starts_with_emoji) | ✅ Complete | | 5 | Create html_tool_renderers.py with tool formatters | ✅ Complete | -| 6 | Split tool formatters (two-stage: parse + render) | Pending | -| 7 | Split message content renderers | Pending | -| 8 | Split _process_* message functions | Pending | -| 9 | Move generate_projects_index_html | Pending | -| 10 | Reorganize renderer.py with thematic sections | Pending | -| 11 | Finalize html_renderer.py structure | Pending | +| 6 | Split tool formatters (two-stage: parse + render) | ⏸️ Deferred | +| 7 | Split message content renderers | ⏸️ Deferred | +| 8 | Split _process_* message functions | ⏸️ Deferred | +| 9 | Move generate_projects_index_html | ⏸️ Deferred | +| 10 | Reorganize renderer.py with thematic sections | ✅ Complete | +| 11 | Finalize html_renderer.py structure and update docs | ✅ Complete | + +**Deferred Steps (6-9) Rationale**: +- **Steps 6-8 (Two-stage splits)**: Typed input models already exist in `models.py` (BashInput, EditInput, etc.) via `parse_tool_input()`. The two-stage split would add complexity without clear benefit since there's no immediate need for alternative renderers (text/markdown). Current formatters are well-organized in `html_tool_renderers.py`. +- **Step 9 (generate_projects_index_html)**: Function depends on TemplateProject/TemplateSummary classes and utility functions in renderer.py. Moving would create circular dependencies or require significant restructuring. Not pure HTML generation - mixes data preparation with rendering. **Completed Changes**: -- `html_renderer.py`: CSS class computation, markdown rendering, collapsible content, template env -- `html_tool_renderers.py`: 13 tool formatters moved from renderer.py (~420 lines) -- `renderer.py`: Reduced from 3853 to ~3400 lines, imports from new modules +- `html_renderer.py` (352 lines): CSS class computation, markdown rendering, collapsible content, template env +- `html_tool_renderers.py` (506 lines): 13 tool formatters moved from renderer.py +- `renderer.py` (3219 lines): Reduced from 3853 lines, organized with 9 thematic section headers: + - Utility Functions, Tool Summary and Result Parsing, Content Formatters + - Template Classes, Message Processing Functions, Message Pairing and Hierarchy + - Deduplication, High-Level HTML Generation, Project Index Generation **Dependencies**: - Requires Phase 9 (type safety) for clean interfaces ✅ @@ -393,7 +400,7 @@ text_renderer.py (future - golergka's work) - Enables golergka's multi-format integration **Risk**: High - requires careful refactoring -**Priority**: In progress +**Status**: ✅ COMPLETE (Steps 1-5, 10-11 done; Steps 6-9 deferred) ## Recommended Execution Order @@ -411,7 +418,7 @@ For maximum impact with minimum risk: ### Next Steps 8. ✅ **Phase 10 (Parser)** - Simplified extract_text_content() with isinstance checks 9. ✅ **Phase 11 (Tool Models)** - Added typed input models for 9 common tools -10. 🔄 **Phase 12 (Format Neutral)** - In progress (Steps 1-5 complete, 6-11 pending) +10. ✅ **Phase 12 (Format Neutral)** - Complete (Steps 1-5, 10-11 done; Steps 6-9 deferred) **Tree Refactoring Integration:** - Tree building (TEMPLATE_MESSAGE_CHILDREN.md Phase 1-2) is complete and non-blocking @@ -425,17 +432,17 @@ For maximum impact with minimum risk: ## Metrics to Track -| Metric | Baseline (v0.9) | Current (Phase 11 done) | Target | +| Metric | Baseline (v0.9) | Current (Phase 12 done) | Target | |--------|-----------------|-------------------------|--------| -| renderer.py lines | 4246 | 3853 | <3000 | +| renderer.py lines | 4246 | 3219 | <3000 | | Largest function | ~687 lines | ~460 lines | <100 lines | | `_identify_message_pairs()` | ~120 lines | ~37 lines | - | | `extract_text_content()` | ~17 lines | ~13 lines | - | | Typed tool input models | 0 | 9 | - | -| Module count | 3 (renderer, timings, models) | 5 (+ansi_colors, +renderer_code) | 6-7 | +| Module count | 3 (renderer, timings, models) | 7 (+ansi_colors, +renderer_code, +html_renderer, +html_tool_renderers) | 6-7 | | Test coverage | ~78% | ~78% | >85% | -**Progress**: Main loop reduced by 33% (687 → 460 lines). Pairing function reduced by 69% (120 → 37 lines). MessageType enum and type guards added. Parser simplified with isinstance checks (Phase 10). 9 typed tool input models added (Phase 11). +**Progress**: Main loop reduced by 33% (687 → 460 lines). Pairing function reduced by 69% (120 → 37 lines). MessageType enum and type guards added. Parser simplified with isinstance checks (Phase 10). 9 typed tool input models added (Phase 11). Phase 12 complete: renderer.py reduced by 634 lines (3853 → 3219), HTML utilities and tool formatters extracted to html_renderer.py (352 lines) and html_tool_renderers.py (506 lines). ## Quality Gates @@ -457,7 +464,9 @@ Before merging any phase: ## References ### Code Modules -- [renderer.py](../claude_code_log/renderer.py) - Main rendering module (3853 lines) +- [renderer.py](../claude_code_log/renderer.py) - Main rendering module (3219 lines) +- [html_renderer.py](../claude_code_log/html_renderer.py) - HTML utilities, CSS, markdown, collapsibles (352 lines) - Phase 12 +- [html_tool_renderers.py](../claude_code_log/html_tool_renderers.py) - Tool HTML formatters (506 lines) - Phase 12 - [ansi_colors.py](../claude_code_log/ansi_colors.py) - ANSI color conversion (261 lines) - Phase 3 - [renderer_code.py](../claude_code_log/renderer_code.py) - Code highlighting & diffs (330 lines) - Phase 4 - [renderer_timings.py](../claude_code_log/renderer_timings.py) - Timing utilities From dc68c702e4c9970cc9798af3da599b946f94a85c Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 8 Dec 2025 23:10:42 +0100 Subject: [PATCH 040/102] Move lenient parsing to model layer for early typed parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add lenient parsing helpers to models.py (TOOL_LENIENT_PARSERS dict) - Update ToolUseContent.parsed_input to use lenient parsing when strict fails - Remove duplicate lenient parsers from html_tool_renderers.py (~85 lines) - Simplify format_tool_use_content dispatcher to use isinstance checks - Update tests to pass typed models directly to formatters This follows the parsing philosophy: if we know the tool name, parse as that typed model immediately using lenient parsing if strict validation fails. Parsing now happens in the model layer, so HTML formatters always receive typed models. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html_tool_renderers.py | 189 ++++++++++----------- claude_code_log/models.py | 135 ++++++++++++++- claude_code_log/renderer.py | 64 +++---- test/__snapshots__/test_snapshot_html.ambr | 84 ++++----- test/test_todowrite_rendering.py | 114 ++++++------- 5 files changed, 325 insertions(+), 261 deletions(-) diff --git a/claude_code_log/html_tool_renderers.py b/claude_code_log/html_tool_renderers.py index 639c38c6..27287248 100644 --- a/claude_code_log/html_tool_renderers.py +++ b/claude_code_log/html_tool_renderers.py @@ -23,7 +23,15 @@ render_file_content_collapsible, render_markdown_collapsible, ) -from .models import ToolUseContent +from .models import ( + BashInput, + EditInput, + MultiEditInput, + TaskInput, + TodoWriteInput, + ToolUseContent, + WriteInput, +) from .renderer_code import render_single_diff @@ -193,11 +201,13 @@ def format_exitplanmode_result(content: str) -> str: # -- TodoWrite Tool ----------------------------------------------------------- -def format_todowrite_content(tool_use: ToolUseContent) -> str: - """Format TodoWrite tool use content as a todo list.""" - # Parse todos from input - todos_data = tool_use.input.get("todos", []) - if not todos_data: +def format_todowrite_content(todo_input: TodoWriteInput) -> str: + """Format TodoWrite tool use content as a todo list. + + Args: + todo_input: Typed TodoWriteInput with list of todo items. + """ + if not todo_input.todos: return """

No todos found

@@ -207,34 +217,26 @@ def format_todowrite_content(tool_use: ToolUseContent) -> str: # Status emojis status_emojis = {"pending": "⏳", "in_progress": "🔄", "completed": "✅"} - # Build todo list HTML + # Build todo list HTML - todos are typed TodoWriteItem objects todo_items: List[str] = [] - for todo in todos_data: - try: - todo_id = escape_html(str(todo.get("id", ""))) - content = escape_html(str(todo.get("content", ""))) - status = str(todo.get("status", "pending")).lower() - priority = str(todo.get("priority", "medium")).lower() - status_emoji = status_emojis.get(status, "⏳") - - # CSS class for styling - item_class = f"todo-item {status} {priority}" - - todo_items.append(f""" -
- {status_emoji} - {content} - #{todo_id} -
- """) - except AttributeError: - escaped_fallback = escape_html(str(todo)) - todo_items.append(f""" -
- - {escaped_fallback} -
- """) + for todo in todo_input.todos: + todo_id = escape_html(todo.id) if todo.id else "" + content = escape_html(todo.content) if todo.content else "" + status = todo.status or "pending" + priority = todo.priority or "medium" + status_emoji = status_emojis.get(status, "⏳") + + # CSS class for styling + item_class = f"todo-item {status} {priority}" + + id_html = f'#{todo_id}' if todo.id else "" + todo_items.append(f""" +
+ {status_emoji} + {content} + {id_html} +
+ """) todos_html = "".join(todo_items) @@ -258,67 +260,64 @@ def format_read_tool_content(tool_use: ToolUseContent) -> str: # noqa: ARG001 return "" -def format_write_tool_content(tool_use: ToolUseContent) -> str: +def format_write_tool_content(write_input: WriteInput) -> str: """Format Write tool use content with Pygments syntax highlighting. + Args: + write_input: Typed WriteInput with file_path and content. Note: File path is now shown in the header, so we skip it here. """ - file_path = tool_use.input.get("file_path", "") - content = tool_use.input.get("content", "") - - return render_file_content_collapsible(content, file_path, "write-tool-content") + return render_file_content_collapsible( + write_input.content, write_input.file_path, "write-tool-content" + ) # -- Edit Tools (Edit/Multiedit) ---------------------------------------------- -def format_edit_tool_content(tool_use: ToolUseContent) -> str: +def format_edit_tool_content(edit_input: EditInput) -> str: """Format Edit tool use content as a diff view with intra-line highlighting. + Args: + edit_input: Typed EditInput with old_string, new_string, replace_all. Note: File path is now shown in the header, so we skip it here. """ - old_string = tool_use.input.get("old_string", "") - new_string = tool_use.input.get("new_string", "") - replace_all = tool_use.input.get("replace_all", False) - html_parts = ["
"] - # File path is now shown in header, so we skip it here - - if replace_all: + if edit_input.replace_all: html_parts.append( "
🔄 Replace all occurrences
" ) # Use shared diff rendering helper - html_parts.append(render_single_diff(old_string, new_string)) + html_parts.append(render_single_diff(edit_input.old_string, edit_input.new_string)) html_parts.append("
") return "".join(html_parts) -def format_multiedit_tool_content(tool_use: ToolUseContent) -> str: - """Format Multiedit tool use content showing multiple diffs.""" - file_path = tool_use.input.get("file_path", "") - edits = tool_use.input.get("edits", []) +def format_multiedit_tool_content(multiedit_input: MultiEditInput) -> str: + """Format Multiedit tool use content showing multiple diffs. - escaped_path = escape_html(file_path) + Args: + multiedit_input: Typed MultiEditInput with file_path and list of edits. + """ + escaped_path = escape_html(multiedit_input.file_path) html_parts = ["
"] # File path header html_parts.append(f"
📝 {escaped_path}
") - html_parts.append(f"
Applying {len(edits)} edits
") - - # Render each edit as a diff - for idx, edit in enumerate(edits, 1): - old_string = edit.get("old_string", "") - new_string = edit.get("new_string", "") + html_parts.append( + f"
Applying {len(multiedit_input.edits)} edits
" + ) + # Render each edit as a diff - edits are typed EditItem objects + for idx, edit in enumerate(multiedit_input.edits, 1): html_parts.append( f"
Edit #{idx}
" ) - html_parts.append(render_single_diff(old_string, new_string)) + html_parts.append(render_single_diff(edit.old_string, edit.new_string)) html_parts.append("
") html_parts.append("
") @@ -328,20 +327,16 @@ def format_multiedit_tool_content(tool_use: ToolUseContent) -> str: # -- Bash Tool ---------------------------------------------------------------- -def format_bash_tool_content(tool_use: ToolUseContent) -> str: +def format_bash_tool_content(bash_input: BashInput) -> str: """Format Bash tool use content in VS Code extension style. + Args: + bash_input: Typed BashInput with command, description, timeout, etc. Note: Description is now shown in the header, so we skip it here. """ - command = tool_use.input.get("command", "") - - escaped_command = escape_html(command) + escaped_command = escape_html(bash_input.command) html_parts = ["
"] - - # Description is now shown in header, so we skip it here - - # Add command in preformatted block html_parts.append(f"
{escaped_command}
") html_parts.append("
") @@ -351,22 +346,19 @@ def format_bash_tool_content(tool_use: ToolUseContent) -> str: # -- Task Tool ---------------------------------------------------------------- -def format_task_tool_content(tool_use: ToolUseContent) -> str: +def format_task_tool_content(task_input: TaskInput) -> str: """Format Task tool content with markdown-rendered prompt. + Args: + task_input: Typed TaskInput with prompt, subagent_type, etc. + Task tool spawns sub-agents. We render the prompt as the main content. The sidechain user message (which would duplicate this prompt) is skipped. For long prompts (>20 lines), the content is made collapsible with a preview of the first few lines to keep the transcript vertically compact. """ - prompt = tool_use.input.get("prompt", "") - - if not prompt: - # No prompt, show parameters table as fallback - return render_params_table(tool_use.input) - - return render_markdown_collapsible(prompt, "task-prompt") + return render_markdown_collapsible(task_input.prompt, "task-prompt") # -- Generic Parameter Table -------------------------------------------------- @@ -438,40 +430,39 @@ def render_params_table(params: Dict[str, Any]) -> str: def format_tool_use_content(tool_use: ToolUseContent) -> str: - """Format tool use content as HTML.""" - # Special handling for TodoWrite - if tool_use.name == "TodoWrite": - return format_todowrite_content(tool_use) + """Format tool use content as HTML. - # Special handling for Bash - if tool_use.name == "Bash": - return format_bash_tool_content(tool_use) + Uses parsed_input which handles lenient parsing at the model layer, + then dispatches to specialized formatters based on type. + """ + parsed = tool_use.parsed_input - # Special handling for Edit - if tool_use.name == "Edit": - return format_edit_tool_content(tool_use) + # Dispatch based on parsed type (lenient parsing happens in parsed_input) + if isinstance(parsed, TodoWriteInput): + return format_todowrite_content(parsed) - # Special handling for Multiedit - if tool_use.name == "Multiedit": - return format_multiedit_tool_content(tool_use) + if isinstance(parsed, BashInput): + return format_bash_tool_content(parsed) - # Special handling for Read - if tool_use.name == "Read": - return format_read_tool_content(tool_use) + if isinstance(parsed, EditInput): + return format_edit_tool_content(parsed) + + if isinstance(parsed, MultiEditInput): + return format_multiedit_tool_content(parsed) - # Special handling for Write - if tool_use.name == "Write": - return format_write_tool_content(tool_use) + if isinstance(parsed, WriteInput): + return format_write_tool_content(parsed) - # Special handling for Task (agent spawning) - if tool_use.name == "Task": - return format_task_tool_content(tool_use) + if isinstance(parsed, TaskInput): + return format_task_tool_content(parsed) + + # Tools that still accept ToolUseContent directly (no typed model yet) + if tool_use.name == "Read": + return format_read_tool_content(tool_use) - # Special handling for AskUserQuestion if tool_use.name == "AskUserQuestion": return format_askuserquestion_content(tool_use) - # Special handling for ExitPlanMode if tool_use.name == "ExitPlanMode": return format_exitplanmode_content(tool_use) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 5598c9c0..185d6390 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -173,11 +173,16 @@ class TaskInput(BaseModel): class TodoWriteItem(BaseModel): - """Single todo item for TodoWrite tool (input format).""" + """Single todo item for TodoWrite tool (input format). - content: str - status: Literal["pending", "in_progress", "completed"] - activeForm: str + All fields have defaults for lenient parsing of legacy/malformed data. + """ + + content: str = "" + status: str = "pending" # Allow any string, not just Literal, for flexibility + activeForm: str = "" + id: Optional[str] = None + priority: Optional[str] = None # Allow any string for flexibility class TodoWriteInput(BaseModel): @@ -214,8 +219,94 @@ class TodoWriteInput(BaseModel): } +# -- Lenient Parsing Helpers -------------------------------------------------- +# These functions create typed models even when strict validation fails. +# They use defaults for missing fields and skip invalid nested items. + + +def _parse_todowrite_lenient(data: Dict[str, Any]) -> TodoWriteInput: + """Parse TodoWrite input leniently, handling malformed data.""" + todos_raw = data.get("todos", []) + valid_todos: List[TodoWriteItem] = [] + for item in todos_raw: + if isinstance(item, dict): + try: + valid_todos.append(TodoWriteItem.model_validate(item)) + except Exception: + pass + elif isinstance(item, str): + valid_todos.append(TodoWriteItem(content=item)) + return TodoWriteInput(todos=valid_todos) + + +def _parse_bash_lenient(data: Dict[str, Any]) -> BashInput: + """Parse Bash input leniently.""" + return BashInput( + command=data.get("command", ""), + description=data.get("description"), + timeout=data.get("timeout"), + run_in_background=data.get("run_in_background"), + ) + + +def _parse_write_lenient(data: Dict[str, Any]) -> WriteInput: + """Parse Write input leniently.""" + return WriteInput( + file_path=data.get("file_path", ""), + content=data.get("content", ""), + ) + + +def _parse_edit_lenient(data: Dict[str, Any]) -> EditInput: + """Parse Edit input leniently.""" + return EditInput( + file_path=data.get("file_path", ""), + old_string=data.get("old_string", ""), + new_string=data.get("new_string", ""), + replace_all=data.get("replace_all"), + ) + + +def _parse_multiedit_lenient(data: Dict[str, Any]) -> MultiEditInput: + """Parse Multiedit input leniently.""" + edits_raw = data.get("edits", []) + valid_edits: List[EditItem] = [] + for edit in edits_raw: + if isinstance(edit, dict): + try: + valid_edits.append(EditItem.model_validate(edit)) + except Exception: + pass + return MultiEditInput(file_path=data.get("file_path", ""), edits=valid_edits) + + +def _parse_task_lenient(data: Dict[str, Any]) -> TaskInput: + """Parse Task input leniently.""" + return TaskInput( + prompt=data.get("prompt", ""), + subagent_type=data.get("subagent_type", ""), + description=data.get("description", ""), + model=data.get("model"), + run_in_background=data.get("run_in_background"), + resume=data.get("resume"), + ) + + +# Mapping of tool names to their lenient parsers +TOOL_LENIENT_PARSERS: Dict[str, Any] = { + "Bash": _parse_bash_lenient, + "Write": _parse_write_lenient, + "Edit": _parse_edit_lenient, + "MultiEdit": _parse_multiedit_lenient, + "Task": _parse_task_lenient, + "TodoWrite": _parse_todowrite_lenient, +} + + def parse_tool_input(tool_name: str, input_data: Dict[str, Any]) -> ToolInput: - """Parse tool input dictionary into a typed model if available. + """Parse tool input dictionary into a typed model. + + Uses strict validation first, then lenient parsing if available. Args: tool_name: The name of the tool (e.g., "Bash", "Read") @@ -229,7 +320,10 @@ def parse_tool_input(tool_name: str, input_data: Dict[str, Any]) -> ToolInput: try: return cast(ToolInput, model_class.model_validate(input_data)) except Exception: - # Fall back to raw dict if validation fails + # Try lenient parsing if available + lenient_parser = TOOL_LENIENT_PARSERS.get(tool_name) + if lenient_parser is not None: + return cast(ToolInput, lenient_parser(input_data)) return input_data return input_data @@ -282,6 +376,35 @@ class ToolUseContent(BaseModel): id: str name: str input: Dict[str, Any] + _parsed_input: Optional["ToolInput"] = None # Cached parsed input + + @property + def parsed_input(self) -> "ToolInput": + """Get typed input model if available, otherwise return raw dict. + + Lazily parses the input dict into a typed model. + Uses strict validation first, then lenient parsing if available. + Result is cached for subsequent accesses. + """ + if self._parsed_input is None: + model_class = TOOL_INPUT_MODELS.get(self.name) + if model_class is not None: + try: + object.__setattr__( + self, "_parsed_input", model_class.model_validate(self.input) + ) + except Exception: + # Try lenient parsing if available + lenient_parser = TOOL_LENIENT_PARSERS.get(self.name) + if lenient_parser is not None: + object.__setattr__( + self, "_parsed_input", lenient_parser(self.input) + ) + else: + object.__setattr__(self, "_parsed_input", self.input) + else: + object.__setattr__(self, "_parsed_input", self.input) + return self._parsed_input # type: ignore[return-value] class ToolResultContent(BaseModel): diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index b2be6868..d04e9b0e 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -27,6 +27,9 @@ ToolUseContent, ThinkingContent, ImageContent, + TaskInput, + is_user_entry, + is_assistant_entry, ) from .parser import extract_text_content from .utils import ( @@ -1561,8 +1564,13 @@ def _process_tool_use_item( tool_message_title = "📝 Todo List" elif tool_use.name == "Task": # Special handling for Task tool: show subagent_type and description - subagent_type = tool_use.input.get("subagent_type", "") - description = tool_use.input.get("description", "") + parsed = tool_use.parsed_input + if isinstance(parsed, TaskInput): + subagent_type = parsed.subagent_type + description = parsed.description + else: + subagent_type = tool_use.input.get("subagent_type", "") + description = tool_use.input.get("description", "") escaped_subagent = escape_html(subagent_type) if subagent_type else "" if description and subagent_type: @@ -2844,12 +2852,7 @@ def _process_messages_loop( # Get first user message content for preview first_user_message = "" - if ( - message_type == MessageType.USER - and not isinstance(message, QueueOperationTranscriptEntry) - and hasattr(message, "message") - and should_use_as_session_starter(text_content) - ): + if is_user_entry(message) and should_use_as_session_starter(text_content): content = extract_text_content(message.message.content) first_user_message = create_session_preview(content) @@ -2892,18 +2895,12 @@ def _process_messages_loop( template_messages.append(session_header) # Update first user message if this is a user message and we don't have one yet - elif ( - message_type == MessageType.USER - and not sessions[session_id]["first_user_message"] - ): - if not isinstance(message, QueueOperationTranscriptEntry) and hasattr( - message, "message" - ): - first_user_content = extract_text_content(message.message.content) - if should_use_as_session_starter(first_user_content): - sessions[session_id]["first_user_message"] = create_session_preview( - first_user_content - ) + elif is_user_entry(message) and not sessions[session_id]["first_user_message"]: + first_user_content = extract_text_content(message.message.content) + if should_use_as_session_starter(first_user_content): + sessions[session_id]["first_user_message"] = create_session_preview( + first_user_content + ) sessions[session_id]["message_count"] += 1 @@ -2914,14 +2911,13 @@ def _process_messages_loop( # Extract and accumulate token usage for assistant messages # Only count tokens for the first message with each requestId to avoid duplicates - if message_type == MessageType.ASSISTANT and hasattr(message, "message"): - assistant_message = getattr(message, "message") - request_id = getattr(message, "requestId", None) - message_uuid = getattr(message, "uuid", "") + if is_assistant_entry(message): + assistant_message = message.message + request_id = message.requestId + message_uuid = message.uuid if ( - hasattr(assistant_message, "usage") - and assistant_message.usage + assistant_message.usage and request_id and request_id not in seen_request_ids ): @@ -2943,23 +2939,17 @@ def _process_messages_loop( ) # Get timestamp (only for non-summary messages) - timestamp = ( - getattr(message, "timestamp", "") if hasattr(message, "timestamp") else "" - ) + timestamp = getattr(message, "timestamp", "") formatted_timestamp = format_timestamp(timestamp) if timestamp else "" # Extract token usage for assistant messages # Only show token usage for the first message with each requestId to avoid duplicates token_usage_str: Optional[str] = None - if message_type == MessageType.ASSISTANT and hasattr(message, "message"): - assistant_message = getattr(message, "message") - message_uuid = getattr(message, "uuid", "") + if is_assistant_entry(message): + assistant_message = message.message + message_uuid = message.uuid - if ( - hasattr(assistant_message, "usage") - and assistant_message.usage - and message_uuid in show_tokens_for_message - ): + if assistant_message.usage and message_uuid in show_tokens_for_message: # Only show token usage for messages marked as first occurrence of requestId usage = assistant_message.usage token_parts = [ diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 890fa90e..c6366f77 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -9805,30 +9805,7 @@
-
- - - - - - - - - -
file_path/tmp/complex_example.py
edits -
- [ - { - "old_string": "", - "new_string": "#!/usr/bin/env python3\n\"\"\"\nComplex example with ... -
[
-    {
-      "old_string": "",
-      "new_string": "#!/usr/bin/env python3\n\"\"\"\nComplex example with multiple operations.\n\"\"\"\n\nimport json\nimport sys\nfrom typing import List, Dict, Any\n\ndef process_data(items: List[Dict[str, Any]]) -> None:\n    \"\"\"\n    Process a list of data items.\n    \"\"\"\n    for item in items:\n        print(f\"Processing: {item['name']}\")\n        if item.get('active', False):\n            print(f\"  Status: Active\")\n        else:\n            print(f\"  Status: Inactive\")\n\nif __name__ == \"__main__\":\n    sample_data = [\n        {\"name\": \"Item 1\", \"active\": True},\n        {\"name\": \"Item 2\", \"active\": False},\n        {\"name\": \"Item 3\", \"active\": True}\n    ]\n    process_data(sample_data)"
-    }
-  ]
-
-
+
📝 /tmp/complex_example.py
Applying 1 edits
Edit #1
+#!/usr/bin/env python3
+"""
+Complex example with multiple operations.
+"""
+
+import json
+import sys
+from typing import List, Dict, Any
+
+def process_data(items: List[Dict[str, Any]]) -> None:
+ """
+ Process a list of data items.
+ """
+ for item in items:
+ print(f"Processing: {item['name']}")
+ if item.get('active', False):
+ print(f" Status: Active")
+ else:
+ print(f" Status: Inactive")
+
+if __name__ == "__main__":
+ sample_data = [
+ {"name": "Item 1", "active": True},
+ {"name": "Item 2", "active": False},
+ {"name": "Item 3", "active": True}
+ ]
+ process_data(sample_data)
@@ -9901,35 +9878,36 @@
-
- - broken_todo -
- -
- 🔄 - Implement core functionality - #2 -
- -
- - Add comprehensive tests - #3 -
- -
- - Write user documentation - #4 -
- -
- - Perform code review - #5 -
- +
+ + broken_todo + +
+ +
+ 🔄 + Implement core functionality + #2 +
+ +
+ + Add comprehensive tests + #3 +
+ +
+ + Write user documentation + #4 +
+ +
+ + Perform code review + #5 +
+
diff --git a/test/test_todowrite_rendering.py b/test/test_todowrite_rendering.py index 8bdd6adf..1a192f43 100644 --- a/test/test_todowrite_rendering.py +++ b/test/test_todowrite_rendering.py @@ -7,7 +7,7 @@ import pytest from claude_code_log.converter import convert_jsonl_to_html from claude_code_log.html_tool_renderers import format_todowrite_content -from claude_code_log.models import ToolUseContent +from claude_code_log.models import TodoWriteInput, TodoWriteItem, ToolUseContent class TestTodoWriteRendering: @@ -15,35 +15,30 @@ class TestTodoWriteRendering: def test_format_todowrite_basic(self): """Test basic TodoWrite formatting with mixed statuses and priorities.""" - tool_use = ToolUseContent( - type="tool_use", - id="toolu_01test123", - name="TodoWrite", - input={ - "todos": [ - { - "id": "1", - "content": "Implement user authentication", - "status": "completed", - "priority": "high", - }, - { - "id": "2", - "content": "Add error handling", - "status": "in_progress", - "priority": "medium", - }, - { - "id": "3", - "content": "Write documentation", - "status": "pending", - "priority": "low", - }, - ] - }, + todo_input = TodoWriteInput( + todos=[ + TodoWriteItem( + id="1", + content="Implement user authentication", + status="completed", + priority="high", + ), + TodoWriteItem( + id="2", + content="Add error handling", + status="in_progress", + priority="medium", + ), + TodoWriteItem( + id="3", + content="Write documentation", + status="pending", + priority="low", + ), + ] ) - html = format_todowrite_content(tool_use) + html = format_todowrite_content(todo_input) # Check overall structure (TodoWrite now has streamlined format) assert 'class="todo-list"' in html @@ -72,11 +67,9 @@ def test_format_todowrite_basic(self): def test_format_todowrite_empty(self): """Test TodoWrite formatting with no todos.""" - tool_use = ToolUseContent( - type="tool_use", id="toolu_empty", name="TodoWrite", input={"todos": []} - ) + todo_input = TodoWriteInput(todos=[]) - html = format_todowrite_content(tool_use) + html = format_todowrite_content(todo_input) assert 'class="todo-content"' in html # Title and ID are now in the message header, not in content @@ -84,34 +77,28 @@ def test_format_todowrite_empty(self): def test_format_todowrite_missing_todos(self): """Test TodoWrite formatting with missing todos field.""" - tool_use = ToolUseContent( - type="tool_use", id="toolu_missing", name="TodoWrite", input={} - ) + # TodoWriteInput with default empty list + todo_input = TodoWriteInput(todos=[]) - html = format_todowrite_content(tool_use) + html = format_todowrite_content(todo_input) assert 'class="todo-content"' in html assert "No todos found" in html def test_format_todowrite_html_escaping(self): """Test that TodoWrite content is properly HTML escaped.""" - tool_use = ToolUseContent( - type="tool_use", - id="toolu_escape", - name="TodoWrite", - input={ - "todos": [ - { - "id": "1", - "content": "Fix & \"quotes\"", - "status": "pending", - "priority": "high", - } - ] - }, + todo_input = TodoWriteInput( + todos=[ + TodoWriteItem( + id="1", + content="Fix & \"quotes\"", + status="pending", + priority="high", + ) + ] ) - html = format_todowrite_content(tool_use) + html = format_todowrite_content(todo_input) # Check that HTML is escaped assert "<script>" in html @@ -122,23 +109,18 @@ def test_format_todowrite_html_escaping(self): def test_format_todowrite_invalid_status_priority(self): """Test TodoWrite formatting with invalid status/priority values.""" - tool_use = ToolUseContent( - type="tool_use", - id="toolu_invalid", - name="TodoWrite", - input={ - "todos": [ - { - "id": "1", - "content": "Test invalid values", - "status": "unknown_status", - "priority": "unknown_priority", - } - ] - }, + todo_input = TodoWriteInput( + todos=[ + TodoWriteItem( + id="1", + content="Test invalid values", + status="unknown_status", + priority="unknown_priority", + ) + ] ) - html = format_todowrite_content(tool_use) + html = format_todowrite_content(todo_input) # Should use default emojis for unknown values assert "⏳" in html # default status emoji From d38c211c984ad0b0bd6292394c19c794ac4ef399 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 8 Dec 2025 23:16:47 +0100 Subject: [PATCH 041/102] Refactor: eliminate duplication between parse_tool_input and parsed_input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parsed_input property now calls parse_tool_input() instead of duplicating its logic. This removes ~15 lines of duplicated code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/models.py | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 185d6390..fec1f952 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -387,23 +387,9 @@ def parsed_input(self) -> "ToolInput": Result is cached for subsequent accesses. """ if self._parsed_input is None: - model_class = TOOL_INPUT_MODELS.get(self.name) - if model_class is not None: - try: - object.__setattr__( - self, "_parsed_input", model_class.model_validate(self.input) - ) - except Exception: - # Try lenient parsing if available - lenient_parser = TOOL_LENIENT_PARSERS.get(self.name) - if lenient_parser is not None: - object.__setattr__( - self, "_parsed_input", lenient_parser(self.input) - ) - else: - object.__setattr__(self, "_parsed_input", self.input) - else: - object.__setattr__(self, "_parsed_input", self.input) + object.__setattr__( + self, "_parsed_input", parse_tool_input(self.name, self.input) + ) return self._parsed_input # type: ignore[return-value] From 1f44b03842ab392dea54650c780f8dbf2a41e6f7 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 8 Dec 2025 23:28:51 +0100 Subject: [PATCH 042/102] Move tool title generation to html_tool_renderers.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract tool title rendering logic from renderer.py to html_tool_renderers.py: - Add get_tool_summary() using typed models and isinstance checks - Add format_tool_use_title() for generating message header HTML - Replace ~55 lines of inline title generation with single function call - Remove unused TaskInput import from renderer.py This continues the separation of HTML-specific code from the main renderer, grouping title formatters alongside content formatters by tool. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html_tool_renderers.py | 90 ++++++++++++++++++++++++- claude_code_log/renderer.py | 91 +------------------------- 2 files changed, 92 insertions(+), 89 deletions(-) diff --git a/claude_code_log/html_tool_renderers.py b/claude_code_log/html_tool_renderers.py index 27287248..b196275d 100644 --- a/claude_code_log/html_tool_renderers.py +++ b/claude_code_log/html_tool_renderers.py @@ -16,7 +16,7 @@ import json import re -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from .html_renderer import ( escape_html, @@ -27,6 +27,7 @@ BashInput, EditInput, MultiEditInput, + ReadInput, TaskInput, TodoWriteInput, ToolUseContent, @@ -361,6 +362,90 @@ def format_task_tool_content(task_input: TaskInput) -> str: return render_markdown_collapsible(task_input.prompt, "task-prompt") +# -- Tool Summary and Title --------------------------------------------------- + + +def get_tool_summary(tool_use: ToolUseContent) -> Optional[str]: + """Extract a one-line summary from tool parameters for display in header. + + Returns a brief description or filename that can be shown in the message header + to save vertical space. Uses parsed_input for type-safe access. + """ + parsed = tool_use.parsed_input + + if isinstance(parsed, BashInput): + return parsed.description + + if isinstance(parsed, (ReadInput, EditInput, WriteInput)): + return parsed.file_path if parsed.file_path else None + + if isinstance(parsed, TaskInput): + return parsed.description if parsed.description else None + + # No summary for other tools + return None + + +def format_tool_use_title(tool_use: ToolUseContent) -> str: + """Generate the title HTML for a tool use message. + + Returns HTML string for the message header, with tool name, icon, + and optional summary/metadata. Uses parsed_input for type-safe access. + """ + escaped_name = escape_html(tool_use.name) + parsed = tool_use.parsed_input + summary = get_tool_summary(tool_use) + + # TodoWrite: fixed title + if tool_use.name == "TodoWrite": + return "📝 Todo List" + + # Task: show subagent_type and description + if isinstance(parsed, TaskInput): + escaped_subagent = ( + escape_html(parsed.subagent_type) if parsed.subagent_type else "" + ) + description = parsed.description + + if description and parsed.subagent_type: + escaped_desc = escape_html(description) + return f"🔧 {escaped_name} {escaped_desc} ({escaped_subagent})" + elif description: + escaped_desc = escape_html(description) + return f"🔧 {escaped_name} {escaped_desc}" + elif parsed.subagent_type: + return f"🔧 {escaped_name} ({escaped_subagent})" + else: + return f"🔧 {escaped_name}" + + # Edit/Write: use 📝 icon + if isinstance(parsed, (EditInput, WriteInput)): + if summary: + escaped_summary = escape_html(summary) + return ( + f"📝 {escaped_name} {escaped_summary}" + ) + else: + return f"📝 {escaped_name}" + + # Read: use 📄 icon + if isinstance(parsed, ReadInput): + if summary: + escaped_summary = escape_html(summary) + return ( + f"📄 {escaped_name} {escaped_summary}" + ) + else: + return f"📄 {escaped_name}" + + # Other tools: append summary if present + if summary: + escaped_summary = escape_html(summary) + return f"{escaped_name} {escaped_summary}" + + return escaped_name + + # -- Generic Parameter Table -------------------------------------------------- @@ -491,6 +576,9 @@ def format_tool_use_content(tool_use: ToolUseContent) -> str: "format_bash_tool_content", # Task "format_task_tool_content", + # Tool summary and title + "get_tool_summary", + "format_tool_use_title", # Generic "render_params_table", # Dispatcher diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index d04e9b0e..718bded1 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -27,7 +27,6 @@ ToolUseContent, ThinkingContent, ImageContent, - TaskInput, is_user_entry, is_assistant_entry, ) @@ -62,8 +61,9 @@ from .html_tool_renderers import ( format_askuserquestion_result, format_exitplanmode_result, - render_params_table, format_tool_use_content, + format_tool_use_title, + render_params_table, ) @@ -207,35 +207,6 @@ def extract_command_info(text_content: str) -> tuple[str, str, str]: # NOTE: Tool content formatters have been moved to html_tool_renderers.py -def get_tool_summary(tool_use: ToolUseContent) -> Optional[str]: - """Extract a one-line summary from tool parameters for display in header. - - Returns a brief description or filename that can be shown in the message header - to save vertical space. - """ - tool_name = tool_use.name - params = tool_use.input - - if tool_name == "Bash": - # Return description if present - return params.get("description") - - elif tool_name in ("Read", "Edit", "Write"): - # Return file path (without icon - caller adds it) - file_path = params.get("file_path") - if file_path: - return file_path - - elif tool_name == "Task": - # Return description if present - description = params.get("description") - if description: - return description - - # No summary for other tools - return None - - def _parse_cat_n_snippet( lines: List[str], start_idx: int = 0 ) -> Optional[tuple[str, Optional[str], int]]: @@ -1548,7 +1519,7 @@ def _process_tool_use_item( tool_use = tool_item tool_content_html = format_tool_use_content(tool_use) - escaped_name = escape_html(tool_use.name) + tool_message_title = format_tool_use_title(tool_use) escaped_id = escape_html(tool_use.id) item_tool_use_id = tool_use.id tool_title_hint = f"ID: {escaped_id}" @@ -1556,62 +1527,6 @@ def _process_tool_use_item( # Populate tool_use_context for later use when processing tool results tool_use_context[item_tool_use_id] = tool_use - # Get summary for header (description or filepath) - summary = get_tool_summary(tool_use) - - # Set message_type (for CSS/logic) and message_title (for display) - if tool_use.name == "TodoWrite": - tool_message_title = "📝 Todo List" - elif tool_use.name == "Task": - # Special handling for Task tool: show subagent_type and description - parsed = tool_use.parsed_input - if isinstance(parsed, TaskInput): - subagent_type = parsed.subagent_type - description = parsed.description - else: - subagent_type = tool_use.input.get("subagent_type", "") - description = tool_use.input.get("description", "") - escaped_subagent = escape_html(subagent_type) if subagent_type else "" - - if description and subagent_type: - escaped_desc = escape_html(description) - tool_message_title = f"🔧 {escaped_name} {escaped_desc} ({escaped_subagent})" - elif description: - escaped_desc = escape_html(description) - tool_message_title = ( - f"🔧 {escaped_name} {escaped_desc}" - ) - elif subagent_type: - tool_message_title = f"🔧 {escaped_name} ({escaped_subagent})" - else: - tool_message_title = f"🔧 {escaped_name}" - elif tool_use.name in ("Edit", "Write"): - # Use 📝 icon for Edit/Write - if summary: - escaped_summary = escape_html(summary) - tool_message_title = ( - f"📝 {escaped_name} {escaped_summary}" - ) - else: - tool_message_title = f"📝 {escaped_name}" - elif tool_use.name == "Read": - # Use 📄 icon for Read - if summary: - escaped_summary = escape_html(summary) - tool_message_title = ( - f"📄 {escaped_name} {escaped_summary}" - ) - else: - tool_message_title = f"📄 {escaped_name}" - elif summary: - # For other tools (like Bash), append summary - escaped_summary = escape_html(summary) - tool_message_title = ( - f"{escaped_name} {escaped_summary}" - ) - else: - tool_message_title = escaped_name - return ToolItemResult( message_type="tool_use", content_html=tool_content_html, From fb5011b5d555b29e0ef78fe34c894a5582b80a38 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 8 Dec 2025 23:52:41 +0100 Subject: [PATCH 043/102] Add typed inputs for AskUserQuestion, ExitPlanMode, and Read tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AskUserQuestionOption, AskUserQuestionItem, AskUserQuestionInput models with lenient parsing (defaults for all fields) - Add ExitPlanModeInput model with plan, launchSwarm, teammateCount fields - Add lenient parsers for Read, AskUserQuestion, and ExitPlanMode - Support legacy "ask_user_question" tool name mapping to AskUserQuestionInput - Update format_askuserquestion_content to take AskUserQuestionInput - Update format_exitplanmode_content to take ExitPlanModeInput - Update format_read_tool_content to take ReadInput - Refactor format_askuserquestion_content with _render_question_item helper - Use isinstance checks in dispatcher for type-safe dispatching - Update test files to use typed input models directly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html_tool_renderers.py | 138 ++++++++------- claude_code_log/models.py | 102 ++++++++++++ test/test_askuserquestion_rendering.py | 222 +++++++++++-------------- test/test_exitplanmode_rendering.py | 38 ++--- 4 files changed, 282 insertions(+), 218 deletions(-) diff --git a/claude_code_log/html_tool_renderers.py b/claude_code_log/html_tool_renderers.py index b196275d..f51fac27 100644 --- a/claude_code_log/html_tool_renderers.py +++ b/claude_code_log/html_tool_renderers.py @@ -24,8 +24,11 @@ render_markdown_collapsible, ) from .models import ( + AskUserQuestionInput, + AskUserQuestionItem, BashInput, EditInput, + ExitPlanModeInput, MultiEditInput, ReadInput, TaskInput, @@ -39,71 +42,62 @@ # -- AskUserQuestion Tool ----------------------------------------------------- -def format_askuserquestion_content(tool_use: ToolUseContent) -> str: +def _render_question_item(q: AskUserQuestionItem) -> str: + """Render a single question item to HTML.""" + html_parts: List[str] = ['
'] + + # Header (if present) + if q.header: + escaped_header = escape_html(q.header) + html_parts.append(f'
{escaped_header}
') + + # Question text with icon + question_text = escape_html(q.question) + html_parts.append(f'
❓ {question_text}
') + + # Options (if present) + if q.options: + select_hint = "(select multiple)" if q.multiSelect else "(select one)" + html_parts.append(f'
{select_hint}
') + html_parts.append('
    ') + for opt in q.options: + label = escape_html(opt.label) + if opt.description: + desc_html = f' — {escape_html(opt.description)}' + else: + desc_html = "" + html_parts.append( + f'
  • {label}{desc_html}
  • ' + ) + html_parts.append("
") + + html_parts.append("
") # Close question-block + return "".join(html_parts) + + +def format_askuserquestion_content(ask_input: AskUserQuestionInput) -> str: """Format AskUserQuestion tool use content with prominent question display. + Args: + ask_input: Typed AskUserQuestionInput with questions list and/or single question. + Handles multiple questions in a single tool use, each with optional header, options (with label and description), and multiSelect flag. """ - questions_data = tool_use.input.get("questions", []) - # Also handle single question format for backwards compatibility - if not questions_data: - single_question = tool_use.input.get("question", "") - if single_question: - questions_data = [{"question": single_question}] + # Build list of questions from both formats + questions: List[AskUserQuestionItem] = list(ask_input.questions) - if not questions_data: - return render_params_table(tool_use.input) + # Handle single question format (legacy) + if not questions and ask_input.question: + questions.append(AskUserQuestionItem(question=ask_input.question)) + + if not questions: + return '
No question
' # Build HTML for all questions html_parts: List[str] = ['
'] - - for q_data in questions_data: - try: - question_text = escape_html(str(q_data.get("question", ""))) - header = q_data.get("header", "") - options = q_data.get("options", []) - multi_select = q_data.get("multiSelect", False) - - # Question container - html_parts.append('
') - - # Header (if present) - if header: - escaped_header = escape_html(str(header)) - html_parts.append( - f'
{escaped_header}
' - ) - - # Question text with icon - html_parts.append(f'
❓ {question_text}
') - - # Options (if present) - if options: - select_hint = "(select multiple)" if multi_select else "(select one)" - html_parts.append( - f'
{select_hint}
' - ) - html_parts.append('
    ') - for opt in options: - label = escape_html(str(opt.get("label", ""))) - desc = opt.get("description", "") - if desc: - desc_html = f' — {escape_html(str(desc))}' - else: - desc_html = "" - html_parts.append( - f'
  • {label}{desc_html}
  • ' - ) - html_parts.append("
") - - html_parts.append("
") # Close question-block - except (AttributeError, TypeError): - # Fallback for unexpected format - html_parts.append( - f'
❓ {escape_html(str(q_data))}
' - ) - + for q in questions: + html_parts.append(_render_question_item(q)) html_parts.append("
") # Close askuserquestion-content return "".join(html_parts) @@ -161,18 +155,18 @@ def format_askuserquestion_result(content: str) -> str: # -- ExitPlanMode Tool -------------------------------------------------------- -def format_exitplanmode_content(tool_use: ToolUseContent) -> str: +def format_exitplanmode_content(exit_input: ExitPlanModeInput) -> str: """Format ExitPlanMode tool use content with collapsible plan markdown. + Args: + exit_input: Typed ExitPlanModeInput with plan content. + Renders the plan markdown in a collapsible section, similar to Task tool results. """ - plan = tool_use.input.get("plan", "") - - if not plan: - # No plan, show parameters table as fallback - return render_params_table(tool_use.input) + if not exit_input.plan: + return '
No plan
' - return render_markdown_collapsible(plan, "plan-content") + return render_markdown_collapsible(exit_input.plan, "plan-content") def format_exitplanmode_result(content: str) -> str: @@ -251,9 +245,12 @@ def format_todowrite_content(todo_input: TodoWriteInput) -> str: # -- File Tools (Read/Write) -------------------------------------------------- -def format_read_tool_content(tool_use: ToolUseContent) -> str: # noqa: ARG001 +def format_read_tool_content(read_input: ReadInput) -> str: # noqa: ARG001 """Format Read tool use content showing file path. + Args: + read_input: Typed ReadInput with file_path, offset, and limit. + Note: File path is now shown in the header, so we skip content here. """ # File path is now shown in header, so no content needed @@ -541,15 +538,14 @@ def format_tool_use_content(tool_use: ToolUseContent) -> str: if isinstance(parsed, TaskInput): return format_task_tool_content(parsed) - # Tools that still accept ToolUseContent directly (no typed model yet) - if tool_use.name == "Read": - return format_read_tool_content(tool_use) + if isinstance(parsed, ReadInput): + return format_read_tool_content(parsed) - if tool_use.name == "AskUserQuestion": - return format_askuserquestion_content(tool_use) + if isinstance(parsed, AskUserQuestionInput): + return format_askuserquestion_content(parsed) - if tool_use.name == "ExitPlanMode": - return format_exitplanmode_content(tool_use) + if isinstance(parsed, ExitPlanModeInput): + return format_exitplanmode_content(parsed) # Default: render as key/value table using shared renderer return render_params_table(tool_use.input) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index fec1f952..c740c8e2 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -191,6 +191,46 @@ class TodoWriteInput(BaseModel): todos: List[TodoWriteItem] +class AskUserQuestionOption(BaseModel): + """Option for an AskUserQuestion question. + + All fields have defaults for lenient parsing. + """ + + label: str = "" + description: Optional[str] = None + + +class AskUserQuestionItem(BaseModel): + """Single question in AskUserQuestion input. + + All fields have defaults for lenient parsing. + """ + + question: str = "" + header: Optional[str] = None + options: List[AskUserQuestionOption] = [] + multiSelect: bool = False + + +class AskUserQuestionInput(BaseModel): + """Input parameters for the AskUserQuestion tool. + + Supports both modern format (questions list) and legacy format (single question). + """ + + questions: List[AskUserQuestionItem] = [] + question: Optional[str] = None # Legacy single question format + + +class ExitPlanModeInput(BaseModel): + """Input parameters for the ExitPlanMode tool.""" + + plan: str = "" + launchSwarm: Optional[bool] = None + teammateCount: Optional[int] = None + + # Union of all typed tool inputs ToolInput = Union[ BashInput, @@ -202,6 +242,8 @@ class TodoWriteInput(BaseModel): GrepInput, TaskInput, TodoWriteInput, + AskUserQuestionInput, + ExitPlanModeInput, Dict[str, Any], # Fallback for unknown tools ] @@ -216,6 +258,9 @@ class TodoWriteInput(BaseModel): "Grep": GrepInput, "Task": TaskInput, "TodoWrite": TodoWriteInput, + "AskUserQuestion": AskUserQuestionInput, + "ask_user_question": AskUserQuestionInput, # Legacy tool name + "ExitPlanMode": ExitPlanModeInput, } @@ -292,6 +337,59 @@ def _parse_task_lenient(data: Dict[str, Any]) -> TaskInput: ) +def _parse_read_lenient(data: Dict[str, Any]) -> ReadInput: + """Parse Read input leniently.""" + return ReadInput( + file_path=data.get("file_path", ""), + offset=data.get("offset"), + limit=data.get("limit"), + ) + + +def _parse_askuserquestion_lenient(data: Dict[str, Any]) -> AskUserQuestionInput: + """Parse AskUserQuestion input leniently, handling malformed data.""" + questions_raw = data.get("questions", []) + valid_questions: List[AskUserQuestionItem] = [] + for q in questions_raw: + if isinstance(q, dict): + q_dict = cast(Dict[str, Any], q) + try: + # Parse options leniently + options_raw = q_dict.get("options", []) + valid_options: List[AskUserQuestionOption] = [] + for opt in options_raw: + if isinstance(opt, dict): + try: + valid_options.append( + AskUserQuestionOption.model_validate(opt) + ) + except Exception: + pass + valid_questions.append( + AskUserQuestionItem( + question=str(q_dict.get("question", "")), + header=q_dict.get("header"), + options=valid_options, + multiSelect=bool(q_dict.get("multiSelect", False)), + ) + ) + except Exception: + pass + return AskUserQuestionInput( + questions=valid_questions, + question=data.get("question"), + ) + + +def _parse_exitplanmode_lenient(data: Dict[str, Any]) -> ExitPlanModeInput: + """Parse ExitPlanMode input leniently.""" + return ExitPlanModeInput( + plan=data.get("plan", ""), + launchSwarm=data.get("launchSwarm"), + teammateCount=data.get("teammateCount"), + ) + + # Mapping of tool names to their lenient parsers TOOL_LENIENT_PARSERS: Dict[str, Any] = { "Bash": _parse_bash_lenient, @@ -300,6 +398,10 @@ def _parse_task_lenient(data: Dict[str, Any]) -> TaskInput: "MultiEdit": _parse_multiedit_lenient, "Task": _parse_task_lenient, "TodoWrite": _parse_todowrite_lenient, + "Read": _parse_read_lenient, + "AskUserQuestion": _parse_askuserquestion_lenient, + "ask_user_question": _parse_askuserquestion_lenient, # Legacy tool name + "ExitPlanMode": _parse_exitplanmode_lenient, } diff --git a/test/test_askuserquestion_rendering.py b/test/test_askuserquestion_rendering.py index fec41481..1a2cd61a 100644 --- a/test/test_askuserquestion_rendering.py +++ b/test/test_askuserquestion_rendering.py @@ -5,7 +5,11 @@ format_askuserquestion_content, format_askuserquestion_result, ) -from claude_code_log.models import ToolUseContent +from claude_code_log.models import ( + AskUserQuestionInput, + AskUserQuestionItem, + AskUserQuestionOption, +) class TestAskUserQuestionRendering: @@ -13,44 +17,42 @@ class TestAskUserQuestionRendering: def test_format_askuserquestion_multiple_questions(self): """Test AskUserQuestion formatting with multiple questions.""" - tool_use = ToolUseContent( - type="tool_use", - id="toolu_01test123", - name="AskUserQuestion", - input={ - "questions": [ - { - "question": "Should tar archives be processed recursively?", - "header": "Filesystem tar", - "options": [ - { - "label": "Yes, both filesystem and embedded", - "description": "Treat .tar/.tar.gz like .zip", - }, - { - "label": "Only embedded tar archives", - "description": "Only process tar archives inside ZIP files", - }, - ], - "multiSelect": False, - }, - { - "question": "Which tar formats should be supported?", - "header": "Tar formats", - "options": [ - { - "label": ".tar and .tar.gz only", - "description": "Most common", - }, - {"label": "Also .tgz", "description": "Include .tgz alias"}, - ], - "multiSelect": False, - }, - ] - }, + ask_input = AskUserQuestionInput( + questions=[ + AskUserQuestionItem( + question="Should tar archives be processed recursively?", + header="Filesystem tar", + options=[ + AskUserQuestionOption( + label="Yes, both filesystem and embedded", + description="Treat .tar/.tar.gz like .zip", + ), + AskUserQuestionOption( + label="Only embedded tar archives", + description="Only process tar archives inside ZIP files", + ), + ], + multiSelect=False, + ), + AskUserQuestionItem( + question="Which tar formats should be supported?", + header="Tar formats", + options=[ + AskUserQuestionOption( + label=".tar and .tar.gz only", + description="Most common", + ), + AskUserQuestionOption( + label="Also .tgz", + description="Include .tgz alias", + ), + ], + multiSelect=False, + ), + ] ) - html = format_askuserquestion_content(tool_use) + html = format_askuserquestion_content(ask_input) # Check overall structure assert 'class="askuserquestion-content"' in html @@ -81,27 +83,28 @@ def test_format_askuserquestion_multiple_questions(self): def test_format_askuserquestion_single_question(self): """Test AskUserQuestion formatting with a single question.""" - tool_use = ToolUseContent( - type="tool_use", - id="toolu_01test456", - name="AskUserQuestion", - input={ - "questions": [ - { - "question": "How should errors be reported?", - "header": "Error format", - "options": [ - {"label": "Option A", "description": "Comment line"}, - {"label": "Option B", "description": "Marker entry"}, - {"label": "Option C", "description": "Extra field"}, - ], - "multiSelect": False, - } - ] - }, + ask_input = AskUserQuestionInput( + questions=[ + AskUserQuestionItem( + question="How should errors be reported?", + header="Error format", + options=[ + AskUserQuestionOption( + label="Option A", description="Comment line" + ), + AskUserQuestionOption( + label="Option B", description="Marker entry" + ), + AskUserQuestionOption( + label="Option C", description="Extra field" + ), + ], + multiSelect=False, + ) + ] ) - html = format_askuserquestion_content(tool_use) + html = format_askuserquestion_content(ask_input) # Check structure assert 'class="askuserquestion-content"' in html @@ -115,26 +118,21 @@ def test_format_askuserquestion_single_question(self): def test_format_askuserquestion_multiselect(self): """Test AskUserQuestion formatting with multiSelect enabled.""" - tool_use = ToolUseContent( - type="tool_use", - id="toolu_01test789", - name="AskUserQuestion", - input={ - "questions": [ - { - "question": "Which features should be enabled?", - "options": [ - {"label": "Feature A"}, - {"label": "Feature B"}, - {"label": "Feature C"}, - ], - "multiSelect": True, - } - ] - }, + ask_input = AskUserQuestionInput( + questions=[ + AskUserQuestionItem( + question="Which features should be enabled?", + options=[ + AskUserQuestionOption(label="Feature A"), + AskUserQuestionOption(label="Feature B"), + AskUserQuestionOption(label="Feature C"), + ], + multiSelect=True, + ) + ] ) - html = format_askuserquestion_content(tool_use) + html = format_askuserquestion_content(ask_input) # Check multi-select hint assert "(select multiple)" in html @@ -144,14 +142,9 @@ def test_format_askuserquestion_multiselect(self): def test_format_askuserquestion_legacy_single_question(self): """Test backwards compatibility with single 'question' key format.""" - tool_use = ToolUseContent( - type="tool_use", - id="toolu_01testlegacy", - name="AskUserQuestion", - input={"question": "What is your preference?"}, - ) + ask_input = AskUserQuestionInput(question="What is your preference?") - html = format_askuserquestion_content(tool_use) + html = format_askuserquestion_content(ask_input) # Should still render the question assert 'class="askuserquestion-content"' in html @@ -160,21 +153,16 @@ def test_format_askuserquestion_legacy_single_question(self): def test_format_askuserquestion_no_options(self): """Test AskUserQuestion formatting without options.""" - tool_use = ToolUseContent( - type="tool_use", - id="toolu_01testnoopts", - name="AskUserQuestion", - input={ - "questions": [ - { - "question": "Please describe the issue in detail.", - "header": "Issue", - } - ] - }, + ask_input = AskUserQuestionInput( + questions=[ + AskUserQuestionItem( + question="Please describe the issue in detail.", + header="Issue", + ) + ] ) - html = format_askuserquestion_content(tool_use) + html = format_askuserquestion_content(ask_input) # Should render without options list assert "Please describe the issue in detail." in html @@ -184,40 +172,32 @@ def test_format_askuserquestion_no_options(self): assert "(select" not in html def test_format_askuserquestion_empty_input(self): - """Test AskUserQuestion with empty questions falls back to params table.""" - tool_use = ToolUseContent( - type="tool_use", - id="toolu_01testempty", - name="AskUserQuestion", - input={"other_field": "value"}, - ) + """Test AskUserQuestion with empty questions returns 'No question' message.""" + ask_input = AskUserQuestionInput() # Empty questions list - html = format_askuserquestion_content(tool_use) + html = format_askuserquestion_content(ask_input) - # Should fall back to params table rendering - assert "askuserquestion-content" not in html - assert "other_field" in html + # Should show 'No question' message + assert "askuserquestion-content" in html + assert "No question" in html def test_format_askuserquestion_escapes_html(self): """Test that HTML special characters are escaped.""" - tool_use = ToolUseContent( - type="tool_use", - id="toolu_01testesc", - name="AskUserQuestion", - input={ - "questions": [ - { - "question": "Use ") + + html = format_user_memory_content(content) + + assert "<script>" in html + assert "