From 9d76ab816f9c2f443c776259b7a0288bad0df240 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 25 Nov 2025 22:43:50 +0100 Subject: [PATCH 01/20] Foldable messages (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement hierarchical conversation structure with margin-based indentation Reorganized message margins to create clear visual hierarchy: RIGHT-ALIGNED (user-initiated, left: 33%, right: 0): - User messages (not compacted) - System commands - System errors LEFT-ALIGNED (assistant-generated, progressive indentation): - Assistant/Thinking: left: 0, right: 8em - Tool use/result: left: 2em, right: 6em - System warning/info: left: 2em, right: 6em - Sidechain user: left: 4em, right: 4em - Sidechain assistant: left: 4em, right: 4em - Sidechain tools: left: 6em, right: 2em Changes: - Grouped all margin rules in dedicated "CONVERSATION STRUCTURE" section - Removed scattered margin-left declarations from individual selectors - Clear visual separation between user (right) and assistant (left) sides - Progressive 2em indentation for nested assistant operations - Sidechain (sub-assistant) hierarchy clearly distinguished This creates a "conversation tree" visual where: - User speaks from the right - Assistant responds from the left with tools indented - Sub-assistants (via Task tool) are further indented - Each level uses 2em indentation increment πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Adjust sub-assistant prompt indentation to match Task level Move sidechain user (sub-assistant prompt) from 4em to 2em indentation, placing it at the same level as the Task tool that invokes it. Previous hierarchy: - Task tool: 2em - Sub-assistant prompt: 4em (nested below) - Sub-assistant: 4em - Sub-tools: 6em New hierarchy: - Task tool: 2em - Sub-assistant prompt: 2em (sibling, not child) - Sub-assistant: 4em (nested below prompt) - Sub-tools: 6em (nested below sub-assistant) This better reflects the conversation flow where the Task and its prompt are siblings in the dialogue, with the sub-assistant's response being the nested continuation. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Implement hierarchical fold/unfold functionality for conversation structure Added interactive collapsing/expanding of messages based on hierarchical conversation structure: Backend (renderer.py): - Added message_id, ancestry, and has_children fields to TemplateMessage - Implemented _get_message_hierarchy_level() to determine nesting levels: * Level 0 (0em): User, Assistant, Thinking, System * Level 1 (2em): Tool use/result, Sidechain user (sub-assistant prompt) * Level 2 (4em): Sidechain assistant/thinking * Level 3 (6em): Sidechain tools - Implemented _update_hierarchy_stack() to track message ancestry - Added hierarchy tracking to all TemplateMessage creations (system, main, tool messages) - Implemented _mark_messages_with_children() to identify messages with descendants - Reset hierarchy stack at session boundaries Frontend (transcript.html): - Added ancestry classes to message divs (e.g., "d-10 d-23 d-42") - Added data-message-id attribute to each message - Added fold-toggle button for messages with children - Implemented JavaScript fold/unfold logic: * Toggle button changes between β–Ύ (unfolded) and β–Έ (folded) * Clicking folds/unfolds all descendants by hiding/showing messages with parent ID in their class list Styling (message_styles.css): - Added .fold-toggle button styles - Positioned button absolutely on the left edge of messages - Added hover and folded states This creates a tree-like conversation view where users can collapse/expand branches of the conversation hierarchy (e.g., fold all tools under an assistant message, or fold an entire sub-assistant chain). πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix hierarchy level logic for correct message nesting Corrected _get_message_hierarchy_level() to properly reflect logical nesting: Before (incorrect): - Level 0: User, Assistant, Thinking, System - Level 1: Tools, System warnings/info, Sidechain user - Level 2: Sidechain assistant/thinking - Level 3: Sidechain tools After (correct): - Level 0: User (triggers everything), System - Level 1: Assistant, Thinking, System warnings/info (nested under user) - Level 2: Tool use/result (nested under assistant), Sidechain user - Level 3: Sidechain assistant/thinking (nested under sidechain user) - Level 4: Sidechain tools (nested under sidechain assistant) This matches the logical conversation flow where: - User messages trigger assistant responses - Assistant responses contain tools - Tools can spawn sub-assistants (sidechains) - Sub-assistants have their own tool calls πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Make tool_result one level deeper than tool_use for proper folding Tool results should be children of their corresponding tool_use messages for folding purposes. When a tool_use is folded, its result should be hidden as well. Added special case in hierarchy tracking: tool_result messages get level + 1, making them nested under their tool_use parent. This ensures that folding a tool_use will hide both the tool call and its result. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix tool pair folding and add debug hierarchy display Fixed two critical issues with hierarchical folding: 1. Tool pairs now fold as a unit: - tool_result reuses its paired tool_use's message ID - This makes tool_use + tool_result a single foldable unit - Folding affects what comes AFTER the pair, not the pair itself 2. Assistant/Thinking messages now always created when they have tools: - Previously skipped if no text content - Now created with empty content when tools present - Ensures fold buttons appear on Assistant/Thinking with tools 3. Added debug hierarchy display: - Shows message ID and ancestry in message header - Format: 'd-42 ← [d-10, d-23]' (ID ← ancestors) - Helps troubleshoot hierarchy structure This implements the correct folding logic where: - User can fold everything they trigger - Assistant/Thinking can fold their tools - Tool pairs (use+result) fold as a unit - Sub-assistant chains fold under the Task tool that spawned them πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix spurious empty assistant messages in hierarchy Remove logic that created empty assistant container messages when assistant entries only contained tools. This was causing: - Spurious empty assistant messages before each tool pair - Incorrect hierarchy stack management - Message IDs being incremented for non-existent messages Now only creates assistant/thinking messages when they have text content. Tools become direct children of the current hierarchy level (typically the user message) when assistant has no text. This ensures folding behavior works correctly: folding a user message folds ALL subsequent lower-level messages until the next user message. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Remove debug ancestry display from message headers The ancestry information (message_id and ancestor chain) was useful for debugging the hierarchy implementation but is not needed in production. This debug feature can be restored by reverting this commit if needed for troubleshooting hierarchy issues. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Implement horizontal fold bar UI with default folded state This commit introduces a new fold/unfold UI based on horizontal bars below messages, replacing the small button approach with a more accessible design. **Key Features:** - Horizontal fold bars below messages with children - Two-zone interaction: fold one level (left) vs fold all (right) - Visual feedback: counts, descriptive labels, double-line when folded - Border colors match parent message type - Default folded state: all messages start collapsed except sessions - Fast animations (200ms transitions) - Efficient O(n) descendant counting during hierarchy building **Implementation:** - Enhanced TemplateMessage with immediate_children_count and total_descendants_count - Optimized _mark_messages_with_children() for O(n) counting - New fold-bar HTML structure with two clickable sections - CSS styling with border-color matching and gradient backgrounds - JavaScript handlers for fold-one and fold-all actions - Auto-fold on page load (top-level messages only) **UI Design:** Folded: ═══════════════ ↓ 2 messages ↓↓ 125 total Unfolded: β–³ fold 2 β–³β–³ fold all πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Refine fold UI: fix initial state, styling, and session support Address all feedback from initial fold UI implementation: 1. Fix initial state synchronization - Remove default 'folded' class - messages start visible - Use expanded icons (β–Ό/β–Όβ–Ό) by default - Remove auto-folding on page load - First click now works correctly 2. Improve visual integration - Set message padding-bottom to 0 when fold-bar present - Use CSS :has() selector for clean integration - Fold bar now looks like natural extension of message 3. Add session header folding support - Sessions now have fold bars like regular messages - Session headers become fold parents via message_id - All session messages can be folded/expanded - Added session-header border color 4. Optimize fold bar layout - Hide "fold all" button when counts match - Show single full-width button for single-level folds - Better use of horizontal space 5. Update fold icons for better aesthetics - 1-level folded: β–Ά, expanded: β–Ό - All levels folded: β–Άβ–Ά, expanded: β–Όβ–Ό - Cleaner, more professional appearance 6. Fix test compatibility - Update todowrite test for new HTML structure - Allow for ancestor IDs in class attribute Files changed: - renderer.py: Session headers as fold parents, type-aware counting - transcript.html: Conditional rendering, updated icons, removed auto-fold - message_styles.css: Padding fix, full-width button support, new colors - global_styles.css: Added message type color variables - test_todowrite_rendering.py: More flexible class matching πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix Pyright type errors in _format_type_counts function Added explicit type annotation `parts: list[str] = []` to resolve type inference issues at lines 1440, 1443, 1444, and 1445. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix message hierarchy structure for proper fold organization Updated hierarchy levels to properly reflect logical nesting: - Level 0: Session headers (unchanged) - Level 1: User messages (was 0) - Level 2: System messages, Assistant, Thinking (was 0-1) - Level 3: Tools, Sidechain user (was 2) - Level 4: Sidechain assistant/thinking (was 3) - Level 5: Sidechain tools (was 4) This ensures: - User messages fold under sessions - System messages fold under user messages (not siblings) - All messages have proper parent-child relationships Also fixed test assertions to be flexible about ancestor IDs in CSS class attributes. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Keep images inline for user messages, extract for assistant messages Images in user messages are now rendered inline on the right side with the text content, while images in assistant messages are still extracted as separate message items. This provides better visual grouping - user-uploaded images appear together with the user's text, matching the natural flow of "here's my question and screenshot". πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix session fold-one-level button to recognize session IDs Updated handleFoldOne JavaScript to recognize both regular message IDs (d-XXX) and session IDs (session-XXX) when filtering for ancestor classes. Previously, clicking "fold one level" on a session did nothing because the code only looked for ancestor IDs starting with 'd-', missing session IDs that start with 'session-'. Now both fold buttons work correctly for sessions. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Add fold button coordination and intelligent initial state Implemented coordination between fold-one and fold-all buttons: - Folding immediate children also folds all descendants (sync β–Ά/β–Άβ–Ά) - Unfolding all descendants also unfolds immediate children (sync β–Ό/β–Όβ–Ό) - Folding all descendants also folds immediate children (sync β–Ά/β–Άβ–Ά) - Unfolding immediate children keeps fold-all at β–Άβ–Ά (independent) Initial page load state: - Sessions: unfolded at first level (β–Ό/β–Άβ–Ά) - shows user messages - User messages: unfolded at first level (β–Ό/β–Άβ–Ά) - shows assistant/system - Assistant/system/thinking/tools: fully folded (β–Ά/β–Άβ–Ά) - hidden This provides a clean initial view showing the conversation structure while hiding implementation details until explicitly expanded. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Small stylistic improvement: less visually intrusive fold bar * Fixup a9cd857: remove 'test_project_fold_ui.html' * Improve fold UI: three-state behavior, styling fixes, and initial states This commit implements six key improvements to the horizontal fold bar UI: 1. **Sub-assistant fold-bar styling**: Sidechain (sub-assistant) fold bars now use dashed borders matching the message's left border style, instead of solid black borders. 2. **Sub-assistant initial visibility**: Fixed bug where sub-assistant messages were incorrectly shown initially even when parent assistant was folded. Newly revealed children are now properly initialized to folded state. 3. **User messages with only tools**: User messages that have only tool use/result pairs as children (no assistant/system/thinking) now start in fully folded state for cleaner initial view. 4. **Tool pair display**: Fixed count display to show "N tool_pairs" instead of "N tools, N results" when tool_use and tool_result counts match, even when other message types are present. 5. **System info indentation**: System and system-info messages now align at level 2 (same as assistant messages) with proper right margins (8em for system, 10em for system-info). 6. **Three-state fold button behavior**: Implemented new state machine where the "fold all" button (β–Όβ–Όβ†’β–Άβ–Ά) goes back to first level visible (State B) instead of fully folded (State A), creating a natural exploration pattern: nothing β†’ all levels β†’ first level β†’ nothing. Files changed: - claude_code_log/renderer.py: Modified _format_type_counts() to combine tool pairs - claude_code_log/templates/transcript.html: Updated fold button handlers and initial state logic - claude_code_log/templates/components/message_styles.css: Fixed sidechain and system message styling - FOLD_STATE_DIAGRAM.md: Added comprehensive state diagram documenting the three-state behavior πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * OK, keep FOLD_STATE_DIAGRAM.md but out of the way. * Add message structure example to FOLD_STATE_DIAGRAM.md * Fix critical performance issue in fold one level operation The "Fold (to all levels) β–Ό" operation was taking ~60 seconds for sessions with ~5000 messages due to O(nΒ²) complexity from recursive DOM queries. Problem: The hideMessageAndDescendants() function recursively called document.querySelectorAll() for each message in the hierarchy, resulting in potentially thousands of DOM queries for large sessions. Solution: Replaced recursive approach with single querySelectorAll() call to get ALL descendants at once, then hide them with a simple forEach loop. This matches the efficient pattern used by handleFoldAll(). Changes: - Removed obsolete hideMessageAndDescendants() function - Modified handleFoldOne() to use single DOM query for all descendants - Performance improved from ~60s to ~1-2s (on par with other fold operations) Performance impact: - Before: O(nΒ²) or worse - recursive DOM queries per message - After: O(n) - single DOM query, simple iteration - Real-world: 60s β†’ 1-2s for 5000 message session πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix fold bar styling for compacted user messages Compacted user messages (generated when a session runs out of context) have css_class "user compacted" instead of just "user", which caused the fold bar border colors to not match the CSS selectors. Changes: - Added "user compacted" variant to fold bar border color selector - This ensures compacted user messages have orange fold bars like regular user messages Note: The padding-bottom issue mentioned was due to mistune's HTML structure placing the fold-bar div as a non-immediate child of the message div, causing the :has(.fold-bar) selector to not apply. This will be addressed separately when we add mistune HTML cleanup/validation. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Optimize setInitialFoldState() to eliminate O(nΒ²) performance issue Replace repeated querySelectorAll() calls with cached hierarchy lookups: - Build messagesByParentId and allDescendantsByParentId maps in single O(n) pass - Use cached lookups instead of DOM queries inside message processing loop - Reduces complexity from O(nΒ²) to O(n) for n messages For transcripts with 5000 messages, this reduces ~15,000 DOM queries to ~10,000 in-memory operations, significantly improving page load time. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Add dynamic tooltips to fold controls showing action intent Implement context-aware tooltips that change based on fold button state: - β–Ά (folded, fold-one): "Unfold (1st level)..." - β–Ό (unfolded, fold-one): "Fold (all levels)..." - β–Άβ–Ά (folded, fold-all): "Unfold (all levels)..." - β–Όβ–Ό (unfolded, fold-all): "Fold (to 1st level)..." Implementation uses data-driven approach: - Store both tooltip variants as data attributes (data-title-folded/unfolded) - Single lightweight updateTooltip() function reads current state - Called only when fold state changes (no performance impact) - Zero code duplication, minimal changes to existing code Benefits: - Users see what WILL happen when clicking (not current state) - < 1ms per update (just attribute reads/writes) - Maintainable: tooltip text lives in template near generation logic πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix template rendering tests for fold UI ancestry classes Update CSS class assertions to handle ancestry tracking classes added by fold UI. Tests now check for "class='message user" (substring) instead of "class='message user'" (exact match). Messages now include ancestry classes like: - class='message user session-test_session' - class='message assistant session-test_session d-0' πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Enhance system message rendering with memory input, command pairing, and CSS variables This commit improves the handling and display of system messages, user memory inputs, and slash command interactions in the transcript viewer. **Memory Input Support:** - Detect and extract user memory inputs (messages starting with '#' in Claude Code) - Display with "πŸ’­ Memory" title instead of "🀷 User" - Extract content from tags and hide wrapper **System Command Message Pairing:** - Implement UUID-based pairing for parent-child system messages - Extract command names from tags (e.g., /memory, /init) - Extract output from tags - Pair command invocations with their responses using parentUuid relationships - Visual pairing similar to tool_use/tool_result pairs **CSS Class Logic:** - User-initiated commands (with ): "system" class (right-aligned, orange border) - Command output responses (with ): "system system-info" class (blue border) - Paired system-info messages override default left-alignment to stay with parent command **CSS Variable Standardization:** - Replace hardcoded color values (#d98100, #2196f3, #f44336, #4caf50) with CSS variables - Use var(--system-color), var(--system-warning-color), var(--system-error-color), var(--tool-use-color) - Applied in both message_styles.css and filter_styles.css for consistency **Technical Implementation:** - Added uuid and parent_uuid fields to TemplateMessage class - Enhanced _identify_message_pairs() with UUID-based index for system messages - Modified render_user_message_content() to return (content_html, is_compacted, is_memory_input) - Added uuid_to_msg_id mapping for parent-child hierarchy tracking - Hierarchy adjustments based on parentUuid relationships πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix test for render_user_message_content return value change Updated test_render_user_message_with_multi_item_content to handle the new three-value return from render_user_message_content (content_html, is_compacted, is_memory_input). Added assertion to verify is_memory_input is False for non-memory messages. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix pyright type errors in renderer.py - Add explicit type annotation for memory_content_list to fix List[ContentItem] type error - Rename loop variable from 'i' to 'idx' to avoid shadowing outer loop variable - Correct hierarchy_stack tuple unpacking order: (stack_level, stack_msg_id) instead of (stack_id, stack_level) - Add explicit type annotation for current_level: int All pyright errors resolved: 0 errors, 0 warnings, 0 informations πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Ignore uv's pinned Python version * Improve message counting to treat paired messages as single visual units When counting immediate children and descendants, skip the second message in pairs (pair_last) since pairs visually appear as a single two-sided element. This provides more accurate counts that match the visual presentation and simplifies the counting logic. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix missing import in simplified deduplication logic Add AssistantTranscriptEntry import alongside SystemTranscriptEntry since it's still referenced elsewhere in the code (lines 2534, 2711). The simplified deduplication logic uses (message_type, timestamp) as the key, treating messages with identical timestamps as duplicates regardless of content differences (e.g., IDE selection tags). πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Remove unused import * Fix data-border-color CSS to handle multi-class attributes The data-border-color attribute now contains space-separated CSS classes (e.g., "user sidechain", "tool_result error"), but the CSS selectors were only matching single-class values. This caused fold-bar borders to not display with the correct colors for messages with multiple classes. Changes: - Add CSS rules for all multi-class combinations (sidechain, compacted, error) - Add missing CSS rules for new message types (image, unknown, bash-input, bash-output) - Add .markdown to font-family rule for consistency πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Improve sidechain/agent support with automatic file loading and enhanced Task tool rendering This commit adds comprehensive support for Claude Code's agent/sidechain system, including automatic loading of agent files and improved rendering of Task tool calls and their results. Agent File Loading: - Automatically detect agentId references in UserTranscriptEntry messages - Load corresponding agent-{agentId}.jsonl files from the same directory - Insert agent messages at their point of use (preserving hierarchy) - Handle recursive agent loading (agents that spawn sub-agents) - Prevent infinite recursion with cycle detection Models (claude_code_log/models.py): - Add agentId field to ToolResultContent for agent references - Add agentId field to UserTranscriptEntry to preserve agent associations Parser (claude_code_log/parser.py): - Extract agentId from toolUseResult before Pydantic parsing - Load agent files recursively when agentId references are found - Build agent message map and insert at point of use - Add _loaded_files parameter to prevent infinite recursion Renderer (claude_code_log/renderer.py): - Add format_task_tool_content() to render Task prompts as markdown - Add get_tool_summary() support for Task tool description - Add format_tool_result_content() support for Task results as markdown - Add base_type property to TemplateMessage for CSS class extraction - Add Task tool title formatting with subagent_type and description - Track last_task_tool_id for sidechain prompt deduplication - Skip redundant sidechain user prompts that duplicate Task prompts The result is a seamless viewing experience where agent conversations appear inline at their logical point in the main transcript, maintaining the hierarchical structure of agent spawning and execution. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Implement bidirectional Task result deduplication for sidechain assistants Add intelligent deduplication to eliminate redundant content between Task tool results and sub-agent assistant messages. When a Task tool result and a sidechain assistant message contain identical content, the assistant message is replaced with a forward link to the Task result. Key implementation details: - Use unified content_map to track both Task results and sidechain assistants - Support bidirectional matching (either can appear first in message stream) - Extract text content consistently from both message types - Replace duplicate assistant content with HTML forward link - Preserve parent-child relationship in fold structure This reduces visual redundancy when viewing sub-agent transcripts while maintaining full traceability through clickable links. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix forward link targets and add Pygments highlighting to Task content Two bug fixes for Task result deduplication feature: 1. Forward link targets: Add id attributes to message elements - Added id='msg-{{ message.message_id }}' to both session headers and regular messages - Links like #msg-d-3172 now have valid anchor targets - Previously only had data-message-id attributes which aren't valid link targets 2. Pygments syntax highlighting: Use render_markdown() for Task content - Replaced manual mistune instances with render_markdown() function - Applied to both Task tool input (prompt) and tool result (output) - Ensures consistent syntax highlighting across all markdown content - Simplified code by using shared rendering function πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix sidechain agent tests and add comprehensive test data - Fixed all 5 sidechain agent tests (test_agent_insertion, test_deduplication_task_result_vs_sidechain, test_no_deduplication_when_content_different, test_agent_messages_marked_as_sidechain, test_multiple_agent_invocations) - Created proper test data files from real examples with truncated strings: - test/test_data/sidechain_main.jsonl (3 main transcript messages) - test/test_data/sidechain_agent.jsonl (3 agent messages) - test/test_data/dedup_main.jsonl (deduplication scenario - main) - test/test_data/dedup_agent.jsonl (deduplication scenario - agent) - Fixed imports from converter to parser/renderer modules - Removed agent_dir parameter (agent files auto-discovered by load_transcript) - Updated test assertions to use correct agentId (e1c84ba5) and UUIDs from real data - Added Pydantic-compatible inline test data for multiple agent invocations test - All 253 unit tests now passing πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix duplicate HTML IDs for paired messages Paired messages (tool_use/tool_result, thinking/assistant, etc.) now have unique IDs with -first/-last suffixes instead of sharing the same ID. Changes: - Template: Add pair_role suffix to message IDs (e.g., msg-d-5-first, msg-d-5-last) - Template: Remove duplicate id attribute from session headers - Renderer: Update deduplication links to point to -last suffix for tool results - All 253 unit tests passing Fixes invalid HTML where both messages in a pair had identical id attributes. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Improve deduplication message phrasing Changed from technical "Content duplicates Task tool result above" to more user-friendly "Task summary β€” already displayed in Task tool result above" Changes: - Updated both deduplication link messages (above/below cases) - Updated test assertions to match new phrasing - All 253 unit tests passing πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Refactor tool message ID generation to remove brittle pairing logic Remove redundant pairing logic during message creation in favor of the robust _identify_message_pairs() function. This clarifies the architecture by separating message creation (which generates unique IDs) from pairing logic (which marks relationships between messages). Changes: - Reuse tool_is_sidechain variable instead of calling getattr() twice - Remove brittle "look back 10 messages" logic (lines 3210-3222) - Always generate unique message IDs during creation - Add comment clarifying that pairing is handled later The early pairing logic was attempting to make tool_use/tool_result pairs share message IDs for folding, but this was: 1. Brittle - only checked last 10 messages 2. Redundant - _identify_message_pairs() does this robustly with no limits 3. Unnecessary - template now uses -first/-last ID suffixes anyway All sidechain agent tests pass with this cleaner architecture. * Update FOLD_STATE_DIAGRAM.md to reflect implemented behavior Sync documentation with actual implementation before branch merge: - Improve hierarchy diagram with visual pairing notation - Add notes on paired messages, sidechain nesting, and deduplication - Change "Proposed Behavior" to "Fold Bar Behavior" (now implemented) - Fix "four states" to "three states" (A, B, C only) - Add Dynamic Tooltips section documenting context-aware tooltips - Add Implementation Notes on performance and type-aware labels πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Remove unused .jsonl test data (noted by the CodeRabbit review) --------- Co-authored-by: Claude --- .gitignore | 1 + claude_code_log/models.py | 2 + claude_code_log/parser.py | 78 +- claude_code_log/renderer.py | 665 +++++++++++++++--- .../templates/components/filter_styles.css | 4 +- .../templates/components/global_styles.css | 11 + .../templates/components/message_styles.css | 258 ++++++- claude_code_log/templates/transcript.html | 360 +++++++++- dev-docs/FOLD_STATE_DIAGRAM.md | 156 ++++ test/test_command_handling.py | 8 +- test/test_data/dedup_agent.jsonl | 4 + test/test_data/dedup_main.jsonl | 3 + test/test_data/sidechain_agent.jsonl | 3 + test/test_data/sidechain_main.jsonl | 3 + test/test_ide_tags.py | 6 +- test/test_sidechain_agents.py | 179 +++++ test/test_template_rendering.py | 8 +- test/test_todowrite_rendering.py | 3 +- 18 files changed, 1619 insertions(+), 133 deletions(-) create mode 100644 dev-docs/FOLD_STATE_DIAGRAM.md create mode 100644 test/test_data/dedup_agent.jsonl create mode 100644 test/test_data/dedup_main.jsonl create mode 100644 test/test_data/sidechain_agent.jsonl create mode 100644 test/test_data/sidechain_main.jsonl create mode 100644 test/test_sidechain_agents.py diff --git a/.gitignore b/.gitignore index 67b13f93..afe4dd29 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ # Distribution / packaging .Python +.python-version build/ develop-eggs/ dist/ diff --git a/claude_code_log/models.py b/claude_code_log/models.py index c0cc169a..888fa8c9 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -74,6 +74,7 @@ class ToolResultContent(BaseModel): tool_use_id: str content: Union[str, List[Dict[str, Any]]] is_error: Optional[bool] = None + agentId: Optional[str] = None # Reference to agent file for sub-agent messages class ThinkingContent(BaseModel): @@ -202,6 +203,7 @@ class UserTranscriptEntry(BaseTranscriptEntry): type: Literal["user"] message: UserMessage toolUseResult: Optional[ToolUseResult] = None + agentId: Optional[str] = None # From toolUseResult when present class AssistantTranscriptEntry(BaseTranscriptEntry): diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py index b76e61b8..80d3888c 100644 --- a/claude_code_log/parser.py +++ b/claude_code_log/parser.py @@ -10,6 +10,7 @@ from .models import ( TranscriptEntry, + UserTranscriptEntry, SummaryTranscriptEntry, parse_transcript_entry, ContentItem, @@ -120,8 +121,22 @@ def load_transcript( from_date: Optional[str] = None, to_date: Optional[str] = None, silent: bool = False, + _loaded_files: Optional[set[Path]] = None, ) -> List[TranscriptEntry]: - """Load and parse JSONL transcript file, using cache if available.""" + """Load and parse JSONL transcript file, using cache if available. + + Args: + _loaded_files: Internal parameter to track loaded files and prevent infinite recursion. + """ + # Initialize loaded files set on first call + if _loaded_files is None: + _loaded_files = set() + + # Prevent infinite recursion by checking if this file is already being loaded + if jsonl_path in _loaded_files: + return [] + + _loaded_files.add(jsonl_path) # Try to load from cache first if cache_manager is not None: # Use filtered loading if date parameters are provided @@ -139,6 +154,7 @@ def load_transcript( # Parse from source file messages: List[TranscriptEntry] = [] + agent_ids: set[str] = set() # Collect agentId references while parsing with open(jsonl_path, "r", encoding="utf-8", errors="replace") as f: if not silent: @@ -154,6 +170,25 @@ def load_transcript( ) continue + # Check for agentId BEFORE Pydantic parsing + # agentId can be at top level OR nested in toolUseResult + # For UserTranscriptEntry, we need to copy it to top level so Pydantic preserves it + if "agentId" in entry_dict: + agent_id = entry_dict.get("agentId") + if agent_id: + agent_ids.add(agent_id) + elif "toolUseResult" in entry_dict: + tool_use_result = entry_dict.get("toolUseResult") + if ( + isinstance(tool_use_result, dict) + and "agentId" in tool_use_result + ): + agent_id_value = tool_use_result.get("agentId") # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + if isinstance(agent_id_value, str): + agent_ids.add(agent_id_value) + # Copy agentId to top level for Pydantic to preserve + entry_dict["agentId"] = agent_id_value + entry_type: str | None = entry_dict.get("type") if entry_type in [ @@ -195,6 +230,47 @@ def load_transcript( "\n{traceback.format_exc()}" ) + # Load agent files if any were referenced + # Build a map of agentId -> agent messages + agent_messages_map: dict[str, List[TranscriptEntry]] = {} + if agent_ids: + parent_dir = jsonl_path.parent + for agent_id in agent_ids: + agent_file = parent_dir / f"agent-{agent_id}.jsonl" + # Skip if the agent file is the same as the current file (self-reference) + if agent_file == jsonl_path: + continue + if agent_file.exists(): + if not silent: + print(f"Loading agent file {agent_file}...") + # Recursively load the agent file (it might reference other agents) + agent_messages = load_transcript( + agent_file, + cache_manager, + from_date, + to_date, + silent=True, + _loaded_files=_loaded_files, + ) + agent_messages_map[agent_id] = agent_messages + + # Insert agent messages at their point of use + if agent_messages_map: + # Iterate through messages and insert agent messages after the message + # that references them (via UserTranscriptEntry.agentId) + result_messages: List[TranscriptEntry] = [] + for message in messages: + result_messages.append(message) + + # Check if this is a UserTranscriptEntry with agentId + if isinstance(message, UserTranscriptEntry) and message.agentId: + agent_id = message.agentId + if agent_id in agent_messages_map: + # Insert agent messages right after this message + result_messages.extend(agent_messages_map[agent_id]) + + messages = result_messages + # Save to cache if cache manager is available if cache_manager is not None: cache_manager.save_cached_entries(jsonl_path, messages) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 1a674919..02e779e0 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -2,6 +2,7 @@ """Render Claude transcript data to HTML format.""" import json +import re from pathlib import Path from typing import List, Optional, Dict, Any, cast, TYPE_CHECKING @@ -19,7 +20,6 @@ from .models import ( TranscriptEntry, SummaryTranscriptEntry, - SystemTranscriptEntry, QueueOperationTranscriptEntry, ContentItem, TextContent, @@ -716,6 +716,24 @@ def _render_line_diff(old_line: str, new_line: str) -> str: return "".join(old_parts) + "".join(new_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 + (like it would appear in the "Sub-assistant prompt" message). + """ + prompt = tool_use.input.get("prompt", "") + + if not prompt: + # No prompt, show parameters table as fallback + return render_params_table(tool_use.input) + + # Render prompt as markdown with Pygments syntax highlighting + rendered_html = render_markdown(prompt) + + return f'
{rendered_html}
' + + def get_tool_summary(tool_use: ToolUseContent) -> Optional[str]: """Extract a one-line summary from tool parameters for display in header. @@ -735,6 +753,12 @@ def get_tool_summary(tool_use: ToolUseContent) -> Optional[str]: 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 @@ -765,6 +789,10 @@ def format_tool_use_content(tool_use: ToolUseContent) -> str: 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) + # Default: render as key/value table using shared renderer return render_params_table(tool_use.input) @@ -889,7 +917,7 @@ def format_tool_result_content( Args: tool_result: The tool result content file_path: Optional file path for context (used for Read/Edit/Write tool rendering) - tool_name: Optional tool name for specialized rendering (e.g., "Write", "Read", "Edit") + tool_name: Optional tool name for specialized rendering (e.g., "Write", "Read", "Edit", "Task") """ # Handle both string and structured content if isinstance(tool_result.content, str): @@ -1031,6 +1059,12 @@ def format_tool_result_content( result_parts.append("") return "".join(result_parts) + # Special handling for Task tool: render result as markdown with Pygments (agent's final message) + # Deduplication is now handled retroactively by replacing the sub-assistant content + if tool_name == "Task" and not has_images: + rendered_html = render_markdown(raw_content) + return f'
{rendered_html}
' + # 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): @@ -1255,11 +1289,13 @@ def extract_ide_notifications(text: str) -> tuple[List[str], str]: return notifications, remaining_text.strip() -def render_user_message_content(content_list: List[ContentItem]) -> tuple[str, bool]: +def render_user_message_content( + content_list: List[ContentItem], +) -> tuple[str, bool, bool]: """Render user message content with IDE tag extraction and compacted summary handling. Returns: - A tuple of (content_html, is_compacted) + A tuple of (content_html, is_compacted, is_memory_input) """ # Check first text item if content_list and hasattr(content_list[0], "text"): @@ -1270,7 +1306,22 @@ def render_user_message_content(content_list: List[ContentItem]) -> tuple[str, b # Render entire content as markdown for compacted summaries # Use "assistant" to trigger markdown rendering instead of pre-formatted text content_html = render_message_content(content_list, "assistant") - return content_html, True + return content_html, True, False + + # Check for user memory input + memory_match = re.search( + r"(.*?)", + first_text, + re.DOTALL, + ) + if memory_match: + memory_content = memory_match.group(1).strip() + # Render the memory content as user message + memory_content_list: List[ContentItem] = [ + TextContent(type="text", text=memory_content) + ] + content_html = render_message_content(memory_content_list, "user") + return content_html, False, True # Extract IDE notifications from first text item ide_notifications_html, remaining_text = extract_ide_notifications(first_text) @@ -1293,7 +1344,7 @@ def render_user_message_content(content_list: List[ContentItem]) -> tuple[str, b # No text in first item or empty list, render normally content_html = render_message_content(content_list, "user") - return content_html, False + return content_html, False, False def render_message_content(content: List[ContentItem], message_type: str) -> str: @@ -1391,6 +1442,82 @@ def _get_template_environment() -> Environment: return env +def _format_type_counts(type_counts: dict[str, int]) -> str: + """Format type counts into human-readable label. + + Args: + type_counts: Dictionary of message type to count + + Returns: + Human-readable label like "3 assistant, 4 tools" or "8 messages" + + Examples: + {"assistant": 3, "tool_use": 4} -> "3 assistant, 4 tools" + {"tool_use": 2, "tool_result": 2} -> "2 tool pairs" + {"assistant": 1} -> "1 assistant" + {"thinking": 3} -> "3 thoughts" + """ + if not type_counts: + return "0 messages" + + # Type name mapping for better readability + type_labels = { + "assistant": ("assistant", "assistants"), + "user": ("user", "users"), + "tool_use": ("tool", "tools"), + "tool_result": ("result", "results"), + "thinking": ("thought", "thoughts"), + "system": ("system", "systems"), + "system-warning": ("warning", "warnings"), + "system-error": ("error", "errors"), + "system-info": ("info", "infos"), + "sidechain": ("task", "tasks"), + } + + # Handle special case: tool_use and tool_result together = "tool pairs" + # Create a modified counts dict that combines tool pairs + modified_counts = dict(type_counts) + if ( + "tool_use" in modified_counts + and "tool_result" in modified_counts + and modified_counts["tool_use"] == modified_counts["tool_result"] + ): + # Replace tool_use and tool_result with tool_pair + pair_count = modified_counts["tool_use"] + del modified_counts["tool_use"] + del modified_counts["tool_result"] + modified_counts["tool_pair"] = pair_count + + # Add tool_pair label + type_labels_with_pairs = { + **type_labels, + "tool_pair": ("tool pair", "tool pairs"), + } + + # Build label parts + parts: list[str] = [] + for msg_type, count in sorted( + modified_counts.items(), key=lambda x: x[1], reverse=True + ): + singular, plural = type_labels_with_pairs.get( + msg_type, (msg_type, f"{msg_type}s") + ) + label = singular if count == 1 else plural + parts.append(f"{count} {label}") + + # Return combined label + if len(parts) == 1: + return parts[0] + elif len(parts) == 2: + return f"{parts[0]}, {parts[1]}" + else: + # For 3+ types, show top 2 and "X more" + remaining = sum(type_counts.values()) - sum( + type_counts[t] for t in list(type_counts.keys())[:2] + ) + return f"{parts[0]}, {parts[1]}, {remaining} more" + + class TemplateMessage: """Structured message data for template rendering.""" @@ -1409,6 +1536,11 @@ def __init__( title_hint: Optional[str] = None, has_markdown: bool = False, message_title: Optional[str] = None, + message_id: Optional[str] = None, + ancestry: Optional[List[str]] = None, + has_children: bool = False, + uuid: Optional[str] = None, + parent_uuid: Optional[str] = None, ): self.type = message_type self.content_html = content_html @@ -1426,12 +1558,33 @@ def __init__( self.token_usage = token_usage self.tool_use_id = tool_use_id self.title_hint = title_hint + self.message_id = message_id + self.ancestry = ancestry or [] + self.has_children = has_children self.has_markdown = has_markdown + self.uuid = uuid + self.parent_uuid = parent_uuid + # Fold/unfold counts + self.immediate_children_count = 0 # Direct children only + self.total_descendants_count = 0 # All descendants recursively + # Type-aware counting for smarter labels + self.immediate_children_by_type: dict[ + str, int + ] = {} # {"assistant": 2, "tool_use": 3} + self.total_descendants_by_type: dict[str, int] = {} # All descendants by type # Pairing metadata 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 + def get_immediate_children_label(self) -> str: + """Generate human-readable label for immediate children.""" + return _format_type_counts(self.immediate_children_by_type) + + def get_total_descendants_label(self) -> str: + """Generate human-readable label for all descendants.""" + return _format_type_counts(self.total_descendants_by_type) + class TemplateProject: """Structured project data for template rendering.""" @@ -1986,11 +2139,16 @@ def _process_regular_message( if is_sidechain: content_html = render_message_content(text_only_content, "assistant") is_compacted = False + is_memory_input = False else: - content_html, is_compacted = render_user_message_content(text_only_content) + content_html, is_compacted, is_memory_input = render_user_message_content( + text_only_content + ) if is_compacted: css_class = f"{message_type} compacted" message_title = "User (compacted conversation)" + elif is_memory_input: + message_title = "Memory" else: # Non-user messages: render directly content_html = render_message_content(text_only_content, message_type) @@ -2027,12 +2185,14 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None: Uses a two-pass algorithm: 1. First pass: Build index of tool_use_id -> message index for tool_use and tool_result + Build index of uuid -> message index for parent-child system messages 2. Second pass: Sequential scan for adjacent pairs (system+output, bash, thinking+assistant) - and match tool_use/tool_result using the index + and match tool_use/tool_result and uuid-based pairs using the index """ # Pass 1: Build index of tool_use messages and tool_result messages by tool_use_id tool_use_index: Dict[str, int] = {} # tool_use_id -> message index tool_result_index: Dict[str, int] = {} # tool_use_id -> message index + uuid_index: Dict[str, int] = {} # uuid -> message index for parent-child pairing for i, msg in enumerate(messages): if msg.tool_use_id: @@ -2040,6 +2200,9 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None: tool_use_index[msg.tool_use_id] = i elif "tool_result" in msg.css_class: tool_result_index[msg.tool_use_id] = i + # Build UUID index for system messages (both parent and child) + if msg.uuid and "system" in msg.css_class: + uuid_index[msg.uuid] = i # Pass 2: Sequential scan to identify pairs i = 0 @@ -2072,6 +2235,16 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None: result_msg.is_paired = True result_msg.pair_role = "pair_last" + # Check for UUID-based parent-child system message pair (no distance limit) + if "system" in current.css_class and current.parent_uuid: + if current.parent_uuid in uuid_index: + parent_idx = uuid_index[current.parent_uuid] + parent_msg = messages[parent_idx] + parent_msg.is_paired = True + parent_msg.pair_role = "pair_first" + current.is_paired = True + current.pair_role = "pair_last" + # Check for bash-input + bash-output pair (adjacent only) if current.css_class == "bash-input" and i + 1 < len(messages): next_msg = messages[i + 1] @@ -2200,6 +2373,144 @@ 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. + + Correct hierarchy based on logical nesting: + - Level 0: Session headers + - Level 1: User messages + - Level 2: System messages, Assistant, Thinking + - Level 3: Tool use/result (nested under assistant), Sidechain user (sub-assistant prompt) + - Level 4: Sidechain assistant/thinking (nested under sidechain user) + - Level 5: Sidechain tools (nested under sidechain assistant) + + Returns: + Integer hierarchy level (1-5, session headers are 0) + """ + # User messages at level 1 (under session) + if "user" in css_class and not is_sidechain: + return 1 + + # System messages at level 2 (siblings to assistant, under user) + if "system" in css_class and not is_sidechain: + return 2 + + # Sidechain user (sub-assistant prompt) at level 3 (conceptually under Tool use that spawned it) + if is_sidechain and "user" in css_class: + return 3 + + # Sidechain assistant/thinking at level 4 + if is_sidechain and ("assistant" in css_class or "thinking" in css_class): + return 4 + + # Sidechain tools at level 5 + if is_sidechain and ("tool" in css_class): + return 5 + + # Main assistant/thinking at level 2 (nested under user) + if "assistant" in css_class or "thinking" in css_class: + return 2 + + # Main tools at level 3 (nested under assistant) + if "tool" in css_class: + return 3 + + # Default to level 1 + return 1 + + +def _update_hierarchy_stack( + hierarchy_stack: List[tuple[int, str]], + current_level: int, + message_id_counter: int, +) -> tuple[str, List[str], int]: + """Update the hierarchy stack and return message ID and ancestry. + + Args: + hierarchy_stack: Current stack of (level, message_id) tuples + current_level: Hierarchy level of the current message + message_id_counter: Current message ID counter + + Returns: + Tuple of (message_id, ancestry, updated_counter) + - message_id: Unique ID for this message (e.g., "d-42") + - ancestry: List of ancestor message IDs (e.g., ["d-10", "d-23", "d-35"]) + - updated_counter: Incremented message ID counter + """ + # Pop stack until we find the appropriate parent level + # The parent is the last message at a level strictly less than current_level + while hierarchy_stack and hierarchy_stack[-1][0] >= current_level: + hierarchy_stack.pop() + + # Build ancestry from remaining stack + ancestry = [msg_id for _, msg_id in hierarchy_stack] + + # Generate new message ID + message_id = f"d-{message_id_counter}" + message_id_counter += 1 + + # Push current message onto stack (it could be a parent for future messages) + hierarchy_stack.append((current_level, message_id)) + + return (message_id, ancestry, message_id_counter) + + +def _mark_messages_with_children(messages: List[TemplateMessage]) -> None: + """Mark messages that have children and calculate descendant counts. + + Efficiently calculates: + - has_children: Whether message has any children + - immediate_children_count: Count of direct children only + - total_descendants_count: Count of all descendants recursively + + Time complexity: O(n) where n is the number of messages. + + Args: + messages: List of template messages to process + """ + # 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 + + # Process each message and update counts for ancestors + for message in messages: + if not message.ancestry: + continue # Top-level message, no parents + + # Skip counting pair_last messages (second in a pair) + # Pairs are visually presented as a single unit, so we only count the first + if message.is_paired and message.pair_role == "pair_last": + continue + + # Get immediate parent (last in ancestry list) + immediate_parent_id = message.ancestry[-1] + + # Get message type for categorization + msg_type = message.css_class or message.type + + # Increment immediate parent's child count + if immediate_parent_id in message_by_id: + parent = message_by_id[immediate_parent_id] + parent.immediate_children_count += 1 + parent.has_children = True + # Track by type + parent.immediate_children_by_type[msg_type] = ( + parent.immediate_children_by_type.get(msg_type, 0) + 1 + ) + + # Increment descendant count for ALL ancestors + for ancestor_id in message.ancestry: + if ancestor_id in message_by_id: + ancestor = message_by_id[ancestor_id] + ancestor.total_descendants_count += 1 + # Track by type + ancestor.total_descendants_by_type[msg_type] = ( + ancestor.total_descendants_by_type.get(msg_type, 0) + 1 + ) + + def generate_html( messages: List[TranscriptEntry], title: Optional[str] = None, @@ -2209,82 +2520,33 @@ def generate_html( if not title: title = "Claude Transcript" - # Deduplicate messages caused by Claude Code version upgrade during session - # Only deduplicate when same message.id appears with DIFFERENT versions - # Streaming fragments (same message.id, same version) are kept as separate messages - from claude_code_log.models import AssistantTranscriptEntry, UserTranscriptEntry - from packaging.version import parse as parse_version - from collections import defaultdict - - # Group messages by their unique identifier - message_groups: Dict[str, List[tuple[int, str, TranscriptEntry]]] = defaultdict( - list - ) - - for idx, message in enumerate(messages): - unique_id = None - version_str = getattr(message, "version", "0.0.0") - - # Determine unique identifier based on message type - if isinstance(message, AssistantTranscriptEntry): - # Assistant messages: use message.id - if hasattr(message.message, "id"): - unique_id = f"msg:{message.message.id}" # type: ignore - - elif isinstance(message, UserTranscriptEntry): - # User messages (tool results): use tool_use_id - if hasattr(message, "message") and message.message.content: - for item in message.message.content: - if hasattr(item, "tool_use_id"): - unique_id = f"tool:{item.tool_use_id}" # type: ignore - break - - if unique_id: - message_groups[unique_id].append((idx, version_str, message)) + # Deduplicate messages by (message_type, timestamp) + # Messages with the exact same timestamp are duplicates by definition - + # the differences (like IDE selection tags) are just logging artifacts + from claude_code_log.models import AssistantTranscriptEntry, SystemTranscriptEntry - # Determine which indices to keep - indices_to_keep: set[int] = set() + # Track seen (message_type, timestamp) pairs + seen: set[tuple[str, str]] = set() + deduplicated_messages: List[TranscriptEntry] = [] - for unique_id, group in message_groups.items(): - if len(group) == 1: - # Single message, always keep - indices_to_keep.add(group[0][0]) - else: - # Multiple messages with same ID - check if they have different versions - versions = {version_str for _, version_str, _ in group} + for message in messages: + # Get basic message type + message_type = getattr(message, "type", "unknown") - if len(versions) == 1: - # All same version = streaming fragments, keep ALL of them - for idx, _, _ in group: - indices_to_keep.add(idx) - else: - # Different versions = version duplicates, keep only highest version - try: - # Sort by semantic version, keep highest - sorted_group = sorted( - group, key=lambda x: parse_version(x[1]), reverse=True - ) - indices_to_keep.add(sorted_group[0][0]) - except Exception: - # If version parsing fails, keep first occurrence - indices_to_keep.add(group[0][0]) + # For system messages, include level to differentiate info/warning/error + if isinstance(message, SystemTranscriptEntry): + level = getattr(message, "level", "info") + message_type = f"system-{level}" - # Build deduplicated list - deduplicated_messages: List[TranscriptEntry] = [] + # Get timestamp + timestamp = getattr(message, "timestamp", "") - for idx, message in enumerate(messages): - # Check if this message has a unique ID - has_unique_id = False - if isinstance(message, AssistantTranscriptEntry): - has_unique_id = hasattr(message.message, "id") - elif isinstance(message, UserTranscriptEntry): - if hasattr(message, "message") and message.message.content: - has_unique_id = any( - hasattr(item, "tool_use_id") for item in message.message.content - ) + # Create deduplication key + dedup_key = (message_type, timestamp) - # Keep message if: no unique ID (e.g., queue-operation) OR in keep set - if not has_unique_id or idx in indices_to_keep: + # Keep only first occurrence + if dedup_key not in seen: + seen.add(dedup_key) deduplicated_messages.append(message) messages = deduplicated_messages @@ -2351,14 +2613,35 @@ def generate_html( tool_name = getattr(item, "name", "") # type: ignore[reportUnknownArgumentType] tool_input = getattr(item, "input", {}) # type: ignore[reportUnknownArgumentType] if tool_id: - tool_use_context[tool_id] = { + tool_ctx: Dict[str, Any] = { "name": tool_name, "input": tool_input, } + # For Task tools, store the prompt for comparison + if tool_name == "Task" and isinstance(tool_input, dict): + prompt_value = tool_input.get("prompt", "") # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + tool_ctx["prompt"] = ( + prompt_value + if isinstance(prompt_value, str) + else "" + ) + tool_use_context[tool_id] = tool_ctx # Process messages into template-friendly format template_messages: List[TemplateMessage] = [] + # Hierarchy tracking for message folding + # Stack of (level, message_id) tuples representing current nesting + hierarchy_stack: List[tuple[int, str]] = [] + message_id_counter = 0 + + # UUID to message ID mapping for parent-child relationships + uuid_to_msg_id: Dict[str, str] = {} + + # Track Task results and sidechain assistants for deduplication + # Maps raw content -> (template_messages index, message_id, type: "task" or "assistant") + content_map: Dict[str, tuple[int, str, str]] = {} + for message in messages: message_type = message.type @@ -2376,14 +2659,83 @@ def generate_html( timestamp = getattr(message, "timestamp", "") formatted_timestamp = format_timestamp(timestamp) if timestamp 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}" - # 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}" + # 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}" + + # Check if this message has a parent (for pairing system-info messages) + parent_uuid = getattr(message, "parentUuid", None) + is_sidechain = getattr(message, "isSidechain", False) + + # Determine hierarchy: use parentUuid if available, otherwise use stack + if parent_uuid and parent_uuid in uuid_to_msg_id: + # This is a child message (e.g., command output following command invocation) + parent_msg_id = uuid_to_msg_id[parent_uuid] + # Find the parent's level in the stack + current_level: int + for idx, (stack_level, stack_msg_id) in enumerate(hierarchy_stack): + if stack_msg_id == parent_msg_id: + # Child is one level deeper than parent + current_level = stack_level + 1 + # Update stack: keep parent, add child + hierarchy_stack = hierarchy_stack[: idx + 1] + break + else: + # Parent not found in stack, use default + current_level = _get_message_hierarchy_level( + level_css, is_sidechain + ) + + msg_id, ancestry, message_id_counter = _update_hierarchy_stack( + hierarchy_stack, current_level, message_id_counter + ) + else: + # No parent, use normal hierarchy determination + current_level = _get_message_hierarchy_level(level_css, is_sidechain) + msg_id, ancestry, message_id_counter = _update_hierarchy_stack( + hierarchy_stack, current_level, message_id_counter + ) + + # Track this message's UUID for potential children + uuid_to_msg_id[message.uuid] = msg_id system_template_message = TemplateMessage( message_type="system", @@ -2393,6 +2745,10 @@ def generate_html( raw_timestamp=timestamp, session_id=session_id, message_title=f"System {level.title()}", + message_id=msg_id, + ancestry=ancestry, + uuid=message.uuid, # Store UUID for pairing + parent_uuid=parent_uuid, # Store parent UUID for pairing ) template_messages.append(system_template_message) continue @@ -2403,6 +2759,7 @@ def generate_html( text_content = extract_text_content(message_content) # Separate tool/thinking/image content from text content + # Images in user messages stay inline, images in assistant messages are separate tool_items: List[ContentItem] = [] text_only_content: List[ContentItem] = [] @@ -2411,12 +2768,16 @@ def generate_html( for item in message_content: # Check for both custom types and Anthropic types item_type = getattr(item, "type", None) + is_image = isinstance(item, ImageContent) or item_type == "image" is_tool_item = isinstance( item, - (ToolUseContent, ToolResultContent, ThinkingContent, ImageContent), - ) or item_type in ("tool_use", "tool_result", "thinking", "image") + (ToolUseContent, ToolResultContent, ThinkingContent), + ) or item_type in ("tool_use", "tool_result", "thinking") - if is_tool_item: + # Keep images inline for user messages, extract for assistant messages + if is_image and message_type == "user": + text_only_items.append(item) + elif is_tool_item or is_image: tool_items.append(item) else: text_only_items.append(item) @@ -2484,6 +2845,11 @@ def generate_html( else session_id[:8] ) + # Reset hierarchy stack for new session + hierarchy_stack.clear() + + # Create session header with unique message ID so it can be a fold parent + session_message_id = f"session-{session_id}" session_header = TemplateMessage( message_type="session_header", content_html=session_title, @@ -2493,9 +2859,14 @@ def generate_html( session_summary=current_session_summary, session_id=session_id, is_session_header=True, + message_id=session_message_id, + ancestry=[], # Session headers are top-level ) template_messages.append(session_header) + # Session header becomes the parent for all messages in this session + hierarchy_stack.append((0, session_message_id)) + # 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"]: if hasattr(message, "message"): @@ -2600,8 +2971,17 @@ def generate_html( ) ) - # Create main message (if it has text content) + # Only create main message if it has text content + # For assistant/thinking with only tools (no text), we don't create a container message + # The tools will be direct children of the current hierarchy level if text_only_content: + # Determine hierarchy level and update stack + is_sidechain = getattr(message, "isSidechain", False) + current_level = _get_message_hierarchy_level(css_class, is_sidechain) + msg_id, ancestry, message_id_counter = _update_hierarchy_stack( + hierarchy_stack, current_level, message_id_counter + ) + template_message = TemplateMessage( message_type=message_type, content_html=content_html, @@ -2612,9 +2992,31 @@ def generate_html( session_id=session_id, token_usage=token_usage_str, message_title=message_title, + message_id=msg_id, + ancestry=ancestry, ) template_messages.append(template_message) + # Track sidechain assistant messages for deduplication + if message_type == "assistant" and is_sidechain and text_content.strip(): + template_msg_index = len(template_messages) - 1 + content_key = text_content.strip() + + # Check if we already have a Task result with this content + if content_key in content_map: + existing_index, existing_id, existing_type = content_map[ + content_key + ] + if existing_type == "task": + # Found matching Task result - deduplicate this assistant message + forward_link_html = f'

(Task summary β€” already displayed in Task tool result above)

' + template_messages[ + template_msg_index + ].content_html = forward_link_html + else: + # Track this assistant in case we see a matching Task result later + content_map[content_key] = (template_msg_index, msg_id, "assistant") + # Create separate messages for each tool/thinking/image item for tool_item in tool_items: tool_timestamp = getattr(message, "timestamp", "") @@ -2626,6 +3028,9 @@ def generate_html( item_type = getattr(tool_item, "type", None) item_tool_use_id: Optional[str] = None tool_title_hint: Optional[str] = None + pending_dedup: Optional[str] = ( + None # Holds task result content for deduplication + ) if isinstance(tool_item, ToolUseContent) or item_type == "tool_use": # Convert Anthropic type to our format if necessary @@ -2652,6 +3057,24 @@ def generate_html( tool_message_type = "tool_use" if tool_use_converted.name == "TodoWrite": tool_message_title = "πŸ“ Todo List" + elif tool_use_converted.name == "Task": + # Special handling for Task tool: show subagent_type and description + subagent_type = tool_use_converted.input.get("subagent_type", "") + description = tool_use_converted.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_converted.name in ("Edit", "Write"): # Use πŸ“ icon for Edit/Write if summary: @@ -2699,8 +3122,32 @@ def generate_html( result_file_path = tool_ctx["input"]["file_path"] tool_content_html = format_tool_result_content( - tool_result_converted, result_file_path, result_tool_name + tool_result_converted, + result_file_path, + result_tool_name, ) + + # Retroactive deduplication: if Task result matches a sidechain assistant, replace that assistant with a forward link + if result_tool_name == "Task": + # Extract text content from tool result + # Note: tool_result.content can be str or List[Dict[str, Any]] (not List[ContentItem]) + if isinstance(tool_result_converted.content, str): + task_result_content = tool_result_converted.content.strip() + else: + # Handle list of dicts (tool result format) + content_parts: list[str] = [] + for item in tool_result_converted.content: + # tool_result_converted.content is List[Dict[str, Any]] + text_val = item.get("text", "") + if isinstance(text_val, str): + content_parts.append(text_val) + task_result_content = "\n".join(content_parts).strip() + + # Store for deduplication - we'll check/update after we have the message_id + pending_dedup = task_result_content if task_result_content else None + else: + pending_dedup = None + escaped_id = escape_html(tool_result_converted.tool_use_id) item_tool_use_id = tool_result_converted.tool_use_id tool_title_hint = f"ID: {escaped_id}" @@ -2748,9 +3195,17 @@ def generate_html( tool_css_class = "unknown" # Preserve sidechain context for tool/thinking/image content within sidechain messages - if getattr(message, "isSidechain", False): + tool_is_sidechain = getattr(message, "isSidechain", False) + if tool_is_sidechain: tool_css_class += " sidechain" + # Determine hierarchy level and generate unique message ID + # Note: Pairing logic is handled later by _identify_message_pairs() + tool_level = _get_message_hierarchy_level(tool_css_class, tool_is_sidechain) + tool_msg_id, tool_ancestry, message_id_counter = _update_hierarchy_stack( + hierarchy_stack, tool_level, message_id_counter + ) + tool_template_message = TemplateMessage( message_type=tool_message_type, content_html=tool_content_html, @@ -2762,9 +3217,38 @@ def generate_html( tool_use_id=item_tool_use_id, title_hint=tool_title_hint, message_title=tool_message_title, + message_id=tool_msg_id, + ancestry=tool_ancestry, ) template_messages.append(tool_template_message) + # Track Task results and check for matching assistants + if pending_dedup is not None: + # pending_dedup contains the task result content + task_result_content = pending_dedup + template_msg_index = len(template_messages) - 1 + + # Check if we already have a sidechain assistant with this content + if task_result_content in content_map: + existing_index, existing_id, existing_type = content_map[ + task_result_content + ] + if existing_type == "assistant": + # Found matching assistant - deduplicate it by replacing with forward link + forward_link_html = f'

(Task summary β€” already displayed in Task tool result below)

' + template_messages[ + existing_index + ].content_html = forward_link_html + else: + # Track this Task result in case we see a matching assistant later + content_map[task_result_content] = ( + template_msg_index, + tool_msg_id, + "task", + ) + + pending_dedup = None # Reset for next iteration + # Prepare session navigation data session_nav: List[Dict[str, Any]] = [] for session_id in session_order: @@ -2824,6 +3308,9 @@ def generate_html( # Reorder messages so pairs are adjacent while preserving chronological order template_messages = _reorder_paired_messages(template_messages) + # Mark messages that have children for fold/unfold controls + _mark_messages_with_children(template_messages) + # Render template env = _get_template_environment() template = env.get_template("transcript.html") diff --git a/claude_code_log/templates/components/filter_styles.css b/claude_code_log/templates/components/filter_styles.css index e6bfaa36..570f107c 100644 --- a/claude_code_log/templates/components/filter_styles.css +++ b/claude_code_log/templates/components/filter_styles.css @@ -114,12 +114,12 @@ } .filter-toggle[data-type="system"] { - border-color: #d98100; + border-color: var(--system-color); border-width: 2px; } .filter-toggle[data-type="tool"] { - border-color: #4caf50; + border-color: var(--tool-use-color); border-width: 2px; } diff --git a/claude_code_log/templates/components/global_styles.css b/claude_code_log/templates/components/global_styles.css index 5ee38644..7a55d06f 100644 --- a/claude_code_log/templates/components/global_styles.css +++ b/claude_code_log/templates/components/global_styles.css @@ -29,10 +29,21 @@ /* Slightly transparent variants (55 = ~33% opacity) */ --highlight-light: #e3f2fd55; + /* Solid colors for message types */ + --user-color: #ff9800; + --assistant-color: #9c27b0; + --system-color: #d98100; + --system-warning-color: #2196f3; + --system-error-color: #f44336; + --tool-use-color: #4caf50; + /* Solid colors for text and accents */ --text-muted: #666; --text-secondary: #495057; + /* Layout spacing */ + --message-padding: 1em; + /* Font families */ --font-monospace: 'Fira Code', 'Monaco', 'Consolas', 'SF Mono', 'Inconsolata', 'Droid Sans Mono', 'Source Code Pro', 'Ubuntu Mono', 'Cascadia Code', 'Menlo', monospace; --font-ui: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; diff --git a/claude_code_log/templates/components/message_styles.css b/claude_code_log/templates/components/message_styles.css index e0bfd672..d047b4c6 100644 --- a/claude_code_log/templates/components/message_styles.css +++ b/claude_code_log/templates/components/message_styles.css @@ -1,8 +1,7 @@ /* Message and content styles */ .message { margin-bottom: 1em; - margin-left: 1em; - padding: 1em; + padding: var(--message-padding); border-radius: 8px; border-left: var(--white-dimmed) 2px solid; background-color: var(--highlight-light); @@ -10,8 +9,233 @@ border-top: var(--white-dimmed) 1px solid; border-bottom: #00000017 1px solid; border-right: #00000017 1px solid; + position: relative; +} + +/* Message with fold bar: remove bottom padding */ +.message:has(.fold-bar) { + padding-bottom: 0; +} + +/* Horizontal Fold Bar - integrated into message box */ +.fold-bar { + display: flex; + margin: 1em calc(-1 * var(--message-padding)) 0; + height: 28px; + border-radius: 0 0 8px 8px; + overflow: hidden; + transition: all 0.2s ease; +} + +.fold-bar-section { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.4em; + cursor: pointer; + user-select: none; + font-size: 0.9em; + font-weight: 500; + padding: 0.4em; + transition: all 0.2s ease; + border-bottom: 2px solid; + background: linear-gradient(to bottom, #f8f8f844, #f0f0f0); +} + +/* Double-line effect when folded */ +.fold-bar-section.folded { + border-bottom-style: double; + border-bottom-width: 4px; +} + +.fold-bar-section:hover { + background: linear-gradient(to bottom, #fff, #f5f5f5); + transform: translateY(1px); +} + +.fold-bar-section:active { + transform: translateY(0); +} + +/* Left section: fold one level */ +.fold-one-level { + border-right: 1px solid rgba(0, 0, 0, 0.1); +} + +/* Full-width single button when counts are equal */ +.fold-bar-section.full-width { + border-right: none; +} + +/* Icon styling */ +.fold-icon { + font-size: 1.1em; + line-height: 1; +} + +.fold-count { + font-weight: 600; + min-width: 1.5em; + text-align: center; +} + +.fold-label { + color: var(--text-muted); + 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-bottom-color: var(--user-color); +} + +.fold-bar[data-border-color="assistant"] .fold-bar-section, +.fold-bar[data-border-color="assistant sidechain"] .fold-bar-section { + 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 { + border-bottom-color: var(--system-color); +} + +.fold-bar[data-border-color="system-warning"] .fold-bar-section { + border-bottom-color: var(--system-warning-color); +} + +.fold-bar[data-border-color="system-error"] .fold-bar-section { + border-bottom-color: var(--system-error-color); +} + +.fold-bar[data-border-color="system-info"] .fold-bar-section { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + border-bottom-color: var(--neutral-dimmed); +} + +.fold-bar[data-border-color="bash-input"] .fold-bar-section { + border-bottom-color: var(--tool-use-color); +} + +.fold-bar[data-border-color="bash-output"] .fold-bar-section { + border-bottom-color: var(--success-dimmed); +} + +.fold-bar[data-border-color="session-header"] .fold-bar-section { + border-bottom-color: #2196f3; +} + +/* Sidechain (sub-assistant) fold-bar styling */ +.sidechain .fold-bar-section { + border-bottom-style: dashed; + border-bottom-width: 2px; +} + +.sidechain .fold-bar-section.folded { + border-bottom-style: dashed; + border-bottom-width: 4px; +} + +/* ======================================== + CONVERSATION STRUCTURE - Margin Hierarchy + ======================================== */ + +/* Right-aligned messages (user-initiated, right margin 0, left margin 33%) */ +.user:not(.compacted), +.system { + margin-left: 33%; + margin-right: 0; +} + +/* System error messages (assistant-generated) */ +.system-error { + margin-left: 0; + margin-right: 8em; +} + +/* Left-aligned messages (assistant-generated) with progressive indentation */ +/* Base assistant messages */ +.assistant, +.thinking { + margin-left: 0; + margin-right: 8em; +} + +/* Tool messages (nested under assistant) */ +.tool_use, +.tool_result { + margin-left: 2em; + margin-right: 6em; +} + +/* System warnings/info (assistant-initiated) */ +.system-warning, +.system-info { + margin-left: 0; + margin-right: 10em; +} + +/* Exception: paired system-info messages align right (like user commands) */ +.system.system-info.paired-message { + margin-left: 33%; + margin-right: 0; +} + +/* Sidechain messages (sub-assistant hierarchy) */ +/* Sub-assistant prompt at same level as Task tool (2em) */ +.sidechain.user { + margin-left: 2em; + margin-right: 6em; +} + +/* Sub-assistant response and thinking (nested below prompt) */ +.sidechain.assistant, +.sidechain.thinking { + margin-left: 4em; + margin-right: 4em; +} + +/* Sub-assistant tools (nested below sub-assistant) */ +.sidechain.tool_use, +.sidechain.tool_result { + margin-left: 6em; + margin-right: 2em; +} + +/* ======================================== */ + /* Message header info styling */ .header-info { display: flex; @@ -72,7 +296,6 @@ /* Message type styling */ .user { border-left-color: #ff9800; - margin-left: 0; } .assistant { @@ -85,26 +308,22 @@ } .system { - border-left-color: #d98100; - margin-left: 0; + border-left-color: var(--system-color); } .system-warning { - border-left-color: #2196f3; + border-left-color: var(--system-warning-color); background-color: var(--highlight-semi); - margin-left: 2em; /* Extra indent - assistant-initiated */ } .system-error { - border-left-color: #f44336; + border-left-color: var(--system-error-color); background-color: var(--error-semi); - margin-left: 0; } .system-info { border-left-color: var(--info-dimmed); background-color: var(--highlight-dimmed); - margin-left: 2em; /* Extra indent - assistant-initiated */ font-size: 80%; } @@ -218,12 +437,10 @@ .tool_use { border-left-color: #4caf50; - margin-left: 2em; /* Extra indent - assistant-initiated */ } .tool_result { border-left-color: var(--success-dimmed); - margin-left: 2em; /* Extra indent - assistant-initiated */ } .tool_result.error { @@ -243,20 +460,6 @@ border-left-style: dashed; } -/* Sidechain indentation hierarchy */ -.sidechain.user { - margin-left: 3em; /* Sub-assistant Prompt - nested below Task tool use (2em) */ -} - -.sidechain.assistant { - margin-left: 4em; /* Sub-assistant - nested below prompt (3em) */ -} - -.sidechain.tool_use, -.sidechain.tool_result { - margin-left: 5em; /* Sub-assistant tools - nested below assistant (4em) */ -} - .sidechain .sidechain-indicator { color: var(--text-muted); font-size: 0.9em; @@ -356,7 +559,8 @@ /* Assistant and Thinking content styling */ .assistant .content, .thinking-text, -.user.compacted .content { +.user.compacted .content, +.markdown { font-family: var(--font-ui); } diff --git a/claude_code_log/templates/transcript.html b/claude_code_log/templates/transcript.html index 31186794..3f209d25 100644 --- a/claude_code_log/templates/transcript.html +++ b/claude_code_log/templates/transcript.html @@ -72,20 +72,41 @@

πŸ” Search & Filter

{% for message in messages %} {% if message.is_session_header %}
-
+
Session: {{ message.content_html }}
{% if message.session_subtitle %}
{{ message.session_subtitle }} ({{message.session_subtitle.session_id}})
{% endif %} - + {% if message.has_children %} +
+ {% if message.immediate_children_count == message.total_descendants_count %} + {# Same count = only one level, show single full-width button #} +
+ β–Ό + {{ message.get_immediate_children_label() }} +
+ {% else %} + {# Multiple levels, show both buttons #} +
+ β–Ό + {{ message.get_immediate_children_label() }} +
+
+ β–Όβ–Ό + {{ message.get_total_descendants_label() }} total +
+ {% endif %} +
+ {% endif %}
{% else %} {% set markdown = message.css_class in ['assistant', 'thinking', 'sidechain'] or (message.css_class and 'compacted' in message.css_class) %} -
+
{% if message.message_title %}{% - if message.css_class == 'user' %}🀷 {% + if message.message_title == 'Memory' %}πŸ’­ {% + elif message.css_class == 'user' %}🀷 {% elif message.css_class == 'assistant' %}πŸ€– {% elif message.css_class == 'system' %}βš™οΈ {% elif message.css_class == 'tool_use' and not starts_with_emoji(message.message_title) %}πŸ› οΈ {% @@ -102,6 +123,27 @@

πŸ” 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 #} +
+ β–Ό + {{ message.get_immediate_children_label() }} +
+ {% else %} + {# Multiple levels, show both buttons #} +
+ β–Ό + {{ message.get_immediate_children_label() }} +
+
+ β–Όβ–Ό + {{ message.get_total_descendants_label() }} total +
+ {% endif %} +
+ {% endif %}
{% endif %} {% endfor %} @@ -407,6 +449,316 @@

πŸ” Search & Filter

// Apply all filters on page load applyFilter(); + + // Fold/unfold functionality with horizontal fold bars + const foldBarSections = document.querySelectorAll('.fold-bar-section'); + + foldBarSections.forEach(section => { + section.addEventListener('click', function(e) { + e.stopPropagation(); + const action = this.getAttribute('data-action'); + const targetId = this.getAttribute('data-target'); + const isFolded = this.classList.contains('folded'); + + if (action === 'fold-one') { + // Fold/unfold immediate children only + handleFoldOne(targetId, isFolded, this); + } else if (action === 'fold-all') { + // Fold/unfold all descendants recursively + handleFoldAll(targetId, isFolded, this); + } + }); + }); + + // Update tooltip based on fold state + function updateTooltip(section) { + const isFolded = section.classList.contains('folded'); + const tooltip = isFolded + ? section.getAttribute('data-title-folded') + : section.getAttribute('data-title-unfolded'); + section.setAttribute('title', tooltip); + } + + function handleFoldOne(targetId, isFolded, sectionElement) { + // Find all messages in the document + const allMessages = document.querySelectorAll('.message'); + + // Find immediate children: messages that have targetId as their LAST ancestor + const immediateChildren = Array.from(allMessages).filter(msg => { + const classList = Array.from(msg.classList); + // Check if targetId is in the class list (ancestor) + if (!classList.includes(targetId)) return false; + + // Check if it's an immediate child by finding all ancestor IDs + // Ancestor IDs can be either d-XXX (regular messages) or session-XXX (sessions) + const ancestorIds = classList.filter(cls => cls.startsWith('d-') || cls.startsWith('session-')); + const lastAncestor = ancestorIds[ancestorIds.length - 1]; + return lastAncestor === targetId; + }); + + if (isFolded) { + // Unfold: show immediate children + immediateChildren.forEach(msg => { + msg.style.display = ''; + + // Set newly revealed children to folded state + const foldBar = msg.querySelector('.fold-bar'); + if (foldBar) { + const foldOneBtn = foldBar.querySelector('.fold-one-level'); + const foldAllBtn = foldBar.querySelector('.fold-all-levels'); + + if (foldOneBtn) { + foldOneBtn.classList.add('folded'); + foldOneBtn.querySelector('.fold-icon').textContent = 'β–Ά'; + updateTooltip(foldOneBtn); + } + if (foldAllBtn) { + foldAllBtn.classList.add('folded'); + foldAllBtn.querySelector('.fold-icon').textContent = 'β–Άβ–Ά'; + updateTooltip(foldAllBtn); + } + } + }); + sectionElement.classList.remove('folded'); + sectionElement.querySelector('.fold-icon').textContent = 'β–Ό'; + updateTooltip(sectionElement); + // Note: Second button stays β–Άβ–Ά (we haven't unfolded everything) + } else { + // Fold: hide ALL descendants (same approach as handleFoldAll for O(n) performance) + const allDescendants = document.querySelectorAll(`.message.${targetId}`); + allDescendants.forEach(msg => { + msg.style.display = 'none'; + }); + sectionElement.classList.add('folded'); + sectionElement.querySelector('.fold-icon').textContent = 'β–Ά'; + updateTooltip(sectionElement); + + // Coordinate: If we folded immediate children, all descendants are hidden + // So update the "fold all" button to β–Άβ–Ά state + const foldBar = sectionElement.parentElement; + const foldAllSection = foldBar.querySelector('.fold-all-levels'); + if (foldAllSection) { + foldAllSection.classList.add('folded'); + foldAllSection.querySelector('.fold-icon').textContent = 'β–Άβ–Ά'; + updateTooltip(foldAllSection); + } + } + } + + function handleFoldAll(targetId, isFolded, sectionElement) { + // Find all descendants (messages with targetId in their class list) + const descendants = document.querySelectorAll(`.message.${targetId}`); + + if (isFolded) { + // Unfold: show all descendants (State A/B β†’ State C) + descendants.forEach(msg => { + msg.style.display = ''; + + // Also update fold bars of all descendants to unfolded state + const foldBar = msg.querySelector('.fold-bar'); + if (foldBar) { + const foldOneBtn = foldBar.querySelector('.fold-one-level'); + const foldAllBtn = foldBar.querySelector('.fold-all-levels'); + + if (foldOneBtn) { + foldOneBtn.classList.remove('folded'); + foldOneBtn.querySelector('.fold-icon').textContent = 'β–Ό'; + updateTooltip(foldOneBtn); + } + if (foldAllBtn) { + foldAllBtn.classList.remove('folded'); + foldAllBtn.querySelector('.fold-icon').textContent = 'β–Όβ–Ό'; + updateTooltip(foldAllBtn); + } + } + }); + sectionElement.classList.remove('folded'); + sectionElement.querySelector('.fold-icon').textContent = 'β–Όβ–Ό'; + updateTooltip(sectionElement); + + // Coordinate: If we unfolded all levels, immediate children are now visible + // So update the "fold one" button to β–Ό state + const foldBar = sectionElement.parentElement; + const foldOneSection = foldBar.querySelector('.fold-one-level'); + if (foldOneSection) { + foldOneSection.classList.remove('folded'); + foldOneSection.querySelector('.fold-icon').textContent = 'β–Ό'; + updateTooltip(foldOneSection); + } + } else { + // Fold: show first level only, hide deeper descendants (State C β†’ State B) + descendants.forEach(msg => { + const classList = Array.from(msg.classList); + const ancestorIds = classList.filter(cls => cls.startsWith('d-') || cls.startsWith('session-')); + const lastAncestor = ancestorIds[ancestorIds.length - 1]; + + // Show immediate children, hide non-immediate children + if (lastAncestor === targetId) { + msg.style.display = ''; // Show immediate child + + // Set immediate children to folded state + const foldBar = msg.querySelector('.fold-bar'); + if (foldBar) { + const foldOneBtn = foldBar.querySelector('.fold-one-level'); + const foldAllBtn = foldBar.querySelector('.fold-all-levels'); + + if (foldOneBtn) { + foldOneBtn.classList.add('folded'); + foldOneBtn.querySelector('.fold-icon').textContent = 'β–Ά'; + updateTooltip(foldOneBtn); + } + if (foldAllBtn) { + foldAllBtn.classList.add('folded'); + foldAllBtn.querySelector('.fold-icon').textContent = 'β–Άβ–Ά'; + updateTooltip(foldAllBtn); + } + } + } else { + msg.style.display = 'none'; // Hide deeper descendants + } + }); + sectionElement.classList.add('folded'); + sectionElement.querySelector('.fold-icon').textContent = 'β–Άβ–Ά'; + updateTooltip(sectionElement); + + // Coordinate: First level is now visible, so update "fold one" button to β–Ό state + const foldBar = sectionElement.parentElement; + const foldOneSection = foldBar.querySelector('.fold-one-level'); + if (foldOneSection) { + foldOneSection.classList.remove('folded'); + foldOneSection.querySelector('.fold-icon').textContent = 'β–Ό'; + updateTooltip(foldOneSection); + } + } + } + + // Set initial fold state on page load + function setInitialFoldState() { + // Get all messages once + const allMessages = document.querySelectorAll('.message'); + + // Build hierarchy lookup structure in a single pass - O(n) + const messagesByParentId = {}; // Maps parent ID -> immediate children elements + const allDescendantsByParentId = {}; // Maps parent ID -> all descendant elements + + allMessages.forEach(msg => { + const classList = Array.from(msg.classList); + const ancestorIds = classList.filter(cls => cls.startsWith('d-') || cls.startsWith('session-')); + + if (ancestorIds.length > 0) { + const lastAncestor = ancestorIds[ancestorIds.length - 1]; + + // Track immediate children + if (!messagesByParentId[lastAncestor]) { + messagesByParentId[lastAncestor] = []; + } + messagesByParentId[lastAncestor].push(msg); + + // Track all descendants + ancestorIds.forEach(ancestorId => { + if (!allDescendantsByParentId[ancestorId]) { + allDescendantsByParentId[ancestorId] = []; + } + allDescendantsByParentId[ancestorId].push(msg); + }); + } + }); + + // Now process each message using cached hierarchy - O(n) + allMessages.forEach(msg => { + const messageId = msg.getAttribute('data-message-id'); + if (!messageId) return; + + const isSession = msg.classList.contains('session-header'); + const isUser = msg.classList.contains('user'); + const foldBar = msg.querySelector('.fold-bar'); + + if (!foldBar) return; + + const foldOneSection = foldBar.querySelector('.fold-one-level'); + const foldAllSection = foldBar.querySelector('.fold-all-levels'); + + // Check if user message has only tools (no assistant/system/thinking children) + let hasOnlyTools = false; + if (isUser) { + const immediateChildren = messagesByParentId[messageId] || []; + + const hasNonToolChildren = immediateChildren.some(child => + child.classList.contains('assistant') || + child.classList.contains('system') || + child.classList.contains('thinking') + ); + hasOnlyTools = immediateChildren.length > 0 && !hasNonToolChildren; + } + + // Sessions and user messages (unless they have only tools): unfolded at first level + if ((isSession || isUser) && !hasOnlyTools) { + // First level is visible (β–Ό), all levels are not (β–Άβ–Ά) + if (foldOneSection) { + foldOneSection.classList.remove('folded'); + foldOneSection.querySelector('.fold-icon').textContent = 'β–Ό'; + updateTooltip(foldOneSection); + } + if (foldAllSection) { + foldAllSection.classList.add('folded'); + foldAllSection.querySelector('.fold-icon').textContent = 'β–Άβ–Ά'; + updateTooltip(foldAllSection); + } + + // Show immediate children, hide deeper descendants + const allDescendants = allDescendantsByParentId[messageId] || []; + const immediateChildren = messagesByParentId[messageId] || []; + + allDescendants.forEach(descendant => { + // Show immediate children, hide non-immediate children + if (immediateChildren.includes(descendant)) { + descendant.style.display = ''; // Show immediate child + + // Set immediate children to folded state (β–Ά/β–Άβ–Ά) + const childFoldBar = descendant.querySelector('.fold-bar'); + if (childFoldBar) { + const childFoldOne = childFoldBar.querySelector('.fold-one-level'); + const childFoldAll = childFoldBar.querySelector('.fold-all-levels'); + + if (childFoldOne) { + childFoldOne.classList.add('folded'); + childFoldOne.querySelector('.fold-icon').textContent = 'β–Ά'; + updateTooltip(childFoldOne); + } + if (childFoldAll) { + childFoldAll.classList.add('folded'); + childFoldAll.querySelector('.fold-icon').textContent = 'β–Άβ–Ά'; + updateTooltip(childFoldAll); + } + } + } else { + descendant.style.display = 'none'; // Hide deeper descendants + } + }); + } else { + // Assistant, system, thinking, tools, sidechains, OR user messages with only tools: fully folded + if (foldOneSection) { + foldOneSection.classList.add('folded'); + foldOneSection.querySelector('.fold-icon').textContent = 'β–Ά'; + updateTooltip(foldOneSection); + } + if (foldAllSection) { + foldAllSection.classList.add('folded'); + foldAllSection.querySelector('.fold-icon').textContent = 'β–Άβ–Ά'; + updateTooltip(foldAllSection); + } + + // Hide all descendants + const allDescendants = allDescendantsByParentId[messageId] || []; + allDescendants.forEach(descendant => { + descendant.style.display = 'none'; + }); + } + }); + } + + // Apply initial fold state + setInitialFoldState(); }); diff --git a/dev-docs/FOLD_STATE_DIAGRAM.md b/dev-docs/FOLD_STATE_DIAGRAM.md new file mode 100644 index 00000000..31556320 --- /dev/null +++ b/dev-docs/FOLD_STATE_DIAGRAM.md @@ -0,0 +1,156 @@ +# Fold Bar State Diagram + +## Message Hierarchy + +The virtual parent/child structure of a conversation determines how folding works: + +``` +Session (level 0) +└── User message (level 1) + β”œβ”€β”€ System: Info (level 2) + └── Assistant response (level 2) + β”œβ”€β”€ Tool: Read ─────────────┐ (level 3) + β”‚ └── Tool result β”€β”€β”€β”€β”€β”€β”€β”€β”˜ paired, fold together + └── Tool: Task ─────────────┐ (level 3) + └── Task result β”€β”€β”€β”€β”€β”€β”˜ paired, fold together + └── Sub-assistant response (level 4, sidechain) + β”œβ”€β”€ Sub-tool: Edit ──────┐ (level 5) + β”‚ └── Sub-tool result β”€β”˜ paired + └── ... +``` + +**Notes:** +- **Paired messages** (tool_use + tool_result, thinking + assistant) fold together as a single visual unit +- **Sidechain (sub-agent) messages** appear nested under the Task tool that spawned them +- **Deduplication**: When a sub-agent's final message duplicates the Task result, it's replaced with a link to avoid redundancy + +At each level, we want to fold/unfold immediate children or all children. + +## Fold Bar Behavior + +The fold bar has two buttons with three possible states: + +### State Definitions + +| State | Button 1 | Button 2 | Visibility | Description | +|-------|----------|----------|------------|-------------| +| **A** | β–Ά | β–Άβ–Ά | Nothing visible | Fully folded | +| **B** | β–Ό | β–Άβ–Ά | First level visible | One level unfolded | +| **C** | β–Ό | β–Όβ–Ό | All levels visible | Fully unfolded | + +**Note**: The state "β–Ά β–Όβ–Ό" (first level folded, all levels unfolded) is **impossible** and should never occur. + +## State Transitions + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ State A (β–Ά β–Άβ–Ά) β”‚ + β”‚ Nothing visible β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + Click β–Ά β”‚ β”‚ Click β–Άβ–Ά + (unfold 1) β”‚ β”‚ (unfold all) + β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ State B β”‚ β”‚ State B β”‚ + β”‚ (β–Ό β–Άβ–Ά) │◄─── (β–Ό β–Άβ–Ά) β”‚ + β”‚ First β”‚ β”‚ First β”‚ + β”‚ level β”‚ β”‚ level β”‚ + β”‚ visible β”‚ β”‚ visible β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + Click β–Άβ–Ά β”‚ β”‚ Click β–Ό + (unfold all) β”‚ β”‚ (fold 1) + β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ State C β”‚ β”‚ State A β”‚ + β”‚ (β–Ό β–Όβ–Ό) β”‚ β”‚ (β–Ά β–Άβ–Ά) β”‚ + β”‚ All levels β”‚ β”‚ Nothing β”‚ + β”‚ visible β”‚ β”‚ visible β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + Click β–Όβ–Ό β”‚ β”‚ Click β–Άβ–Ά + (fold all) β”‚ β”‚ (unfold all) + β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ State B β”‚ β”‚ State C β”‚ + β”‚ (β–Ό β–Άβ–Ά) β”‚ β”‚ (β–Ό β–Όβ–Ό) β”‚ + β”‚ First β”‚ β”‚ All levels β”‚ + β”‚ level β”‚ β”‚ visible β”‚ + β”‚ visible β”‚ β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + Click β–Ό β”‚ + (fold 1) β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ State A β”‚ + β”‚ (β–Ά β–Άβ–Ά) β”‚ + β”‚ Nothing β”‚ + β”‚ visible β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Simplified Transition Table + +| Current State | Click Button 1 | Result | Click Button 2 | Result | +|---------------|----------------|--------|----------------|--------| +| **A: β–Ά β–Άβ–Ά** (nothing) | β–Ά (unfold 1) | **B: β–Ό β–Άβ–Ά** (first level) | β–Άβ–Ά (unfold all) | **C: β–Ό β–Όβ–Ό** (all levels) | +| **B: β–Ό β–Άβ–Ά** (first level) | β–Ό (fold 1) | **A: β–Ά β–Άβ–Ά** (nothing) | β–Άβ–Ά (unfold all) | **C: β–Ό β–Όβ–Ό** (all levels) | +| **C: β–Ό β–Όβ–Ό** (all levels) | β–Ό (fold 1) | **A: β–Ά β–Άβ–Ά** (nothing) | β–Όβ–Ό (fold all) | **B: β–Ό β–Άβ–Ά** (first level) | + +## Key Insights + +1. **Button 1 (fold/unfold one level)**: + - From State A (β–Ά): Unfolds to first level β†’ State B (β–Ό) + - From State B or C (β–Ό): Folds completely β†’ State A (β–Ά) + - **Always toggles between "nothing" and "first level"** + +2. **Button 2 (fold/unfold all levels)**: + - From State A (β–Άβ–Ά): Unfolds to all levels β†’ State C (β–Όβ–Ό) + - From State B (β–Άβ–Ά): Unfolds to all levels β†’ State C (β–Όβ–Ό) + - From State C (β–Όβ–Ό): Folds to first level (NOT nothing) β†’ State B (β–Ό β–Άβ–Ά) + - **When unfolding (β–Άβ–Ά), always shows ALL levels. When folding (β–Όβ–Ό), goes back to first level only.** + +3. **Coordination**: + - When button 1 changes, button 2 updates accordingly + - When button 2 changes, button 1 updates accordingly + - The impossible state "β–Ά β–Όβ–Ό" is prevented by design + +## Initial State + +- **Sessions and User messages**: Start in **State B** (β–Ό β–Άβ–Ά) - first level visible +- **Assistant, System, Thinking, Tools**: Start in **State A** (β–Ά β–Άβ–Ά) - fully folded + +## Example Flow + +**Starting from State A (fully folded):** + +1. User sees: `β–Ά 2 messages β–Άβ–Ά 125 total` +2. Clicks β–Άβ–Ά (unfold all) β†’ Goes to State C, sees everything +3. Now sees: `β–Ό fold 2 β–Όβ–Ό fold all below` +4. Clicks β–Όβ–Ό (fold all) β†’ Goes back to State B, sees only first level +5. Now sees: `β–Ό fold 2 β–Άβ–Ά fold all 125 below` +6. Clicks β–Ό (fold one) β†’ Goes to State A, sees nothing +7. Back to: `β–Ά 2 messages β–Άβ–Ά 125 total` +8. Clicks β–Ά (unfold one) β†’ Goes to State B, sees first level +9. Now sees: `β–Ό fold 2 β–Άβ–Ά fold all 125 below` + +This creates a natural exploration pattern: nothing β†’ all levels β†’ first level β†’ nothing β†’ first level. + +## Dynamic Tooltips + +Fold buttons display context-aware tooltips showing what will happen on click (not current state): + +| Button State | Tooltip | +|--------------|---------| +| β–Ά (fold-one, folded) | "Unfold (1st level)..." | +| β–Ό (fold-one, unfolded) | "Fold (all levels)..." | +| β–Άβ–Ά (fold-all, folded) | "Unfold (all levels)..." | +| β–Όβ–Ό (fold-all, unfolded) | "Fold (to 1st level)..." | + +## Implementation Notes + +- **Performance**: Descendant counting is O(n) using cached hierarchy lookups +- **Paired messages**: Pairs are counted as single units in child/descendant counts +- **Labels**: Fold bars show type-aware labels like "3 assistant, 4 tools" or "2 tool pairs" diff --git a/test/test_command_handling.py b/test/test_command_handling.py index dbea27e7..b77ef07e 100644 --- a/test/test_command_handling.py +++ b/test/test_command_handling.py @@ -59,11 +59,11 @@ def test_system_message_command_handling(): assert "Command: init" in html, ( "Should show command name in summary" ) - assert "class='message system'" in html, "Should have system CSS class" + # Check for system CSS class (may have ancestor IDs appended) + assert "class='message system" in html, "Should have system CSS class" - print( - "βœ“ Test passed: System messages with commands are shown in expandable details" - ) + # Test passed successfully + pass finally: test_file_path.unlink() diff --git a/test/test_data/dedup_agent.jsonl b/test/test_data/dedup_agent.jsonl new file mode 100644 index 00000000..2ec4a232 --- /dev/null +++ b/test/test_data/dedup_agent.jsonl @@ -0,0 +1,4 @@ +{"parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "e:\\Workspace\\src\\github\\claude-code-log", "sessionId": "test-dedup-session", "version": "2.0.46", "gitBranch": "dev/fold-ui-horizontal", "agentId": "e1c84ba5", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_agent_start", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "I'll research how data-border-color is used throughout the codebase."}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 3, "cache_creation_input_tokens": 1000, "cache_read_input_tokens": 0, "output_tokens": 50, "service_tier": "standard"}}, "requestId": "req_agent_1", "type": "assistant", "uuid": "agent-dedup-1", "timestamp": "2025-11-19T22:53:39.112Z"} +{"parentUuid": "agent-dedup-1", "isSidechain": true, "userType": "external", "cwd": "e:\\Workspace\\src\\github\\claude-code-log", "sessionId": "test-dedup-session", "version": "2.0.46", "gitBranch": "dev/fold-ui-horizontal", "agentId": "e1c84ba5", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_agent_grep", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_grep", "name": "Grep", "input": {"pattern": "data-border-color", "output_mode": "files_with_matches"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 3, "cache_creation_input_tokens": 500, "cache_read_input_tokens": 0, "output_tokens": 100, "service_tier": "standard"}}, "requestId": "req_agent_2", "type": "assistant", "uuid": "agent-dedup-2", "timestamp": "2025-11-19T22:53:39.868Z"} +{"parentUuid": "agent-dedup-2", "isSidechain": true, "userType": "external", "cwd": "e:\\Workspace\\src\\github\\claude-code-log", "sessionId": "test-dedup-session", "version": "2.0.46", "gitBranch": "dev/fold-ui-horizontal", "agentId": "e1c84ba5", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_grep", "type": "tool_result", "content": "transcript.html\nmessage_styles.css"}]}, "uuid": "agent-dedup-3", "timestamp": "2025-11-19T22:54:57.555Z", "toolUseResult": {"type": "text", "file": {"filePath": "e:\\Workspace\\src\\github\\claude-code-log\\renderer.py", "content": "# File content here...", "numLines": 50, "startLine": 3130, "totalLines": 3379}}} +{"parentUuid": "agent-dedup-3", "isSidechain": true, "userType": "external", "cwd": "e:\\Workspace\\src\\github\\claude-code-log", "sessionId": "test-dedup-session", "version": "2.0.46", "gitBranch": "dev/fold-ui-horizontal", "agentId": "e1c84ba5", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_agent_final", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "I created the test file successfully. The data-border-color attribute is set in the template and used by CSS selectors to determine fold-bar border colors."}], "stop_reason": "end_turn", "stop_sequence": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 1001, "cache_read_input_tokens": 5000, "output_tokens": 200, "service_tier": "standard"}}, "requestId": "req_agent_final", "type": "assistant", "uuid": "agent-dedup-4", "timestamp": "2025-11-19T22:55:34.309Z"} diff --git a/test/test_data/dedup_main.jsonl b/test/test_data/dedup_main.jsonl new file mode 100644 index 00000000..f3c49a7b --- /dev/null +++ b/test/test_data/dedup_main.jsonl @@ -0,0 +1,3 @@ +{"parentUuid": "8ae40e91-fa63-40a9-969d-70c0c1d6175e", "isSidechain": false, "userType": "external", "cwd": "e:\\Workspace\\src\\github\\claude-code-log", "sessionId": "test-dedup-session", "version": "2.0.46", "gitBranch": "dev/fold-ui-horizontal", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_01H4wg8bFy2psdmQvJMU6kpD", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_dedup_task", "name": "Task", "input": {"subagent_type": "general-purpose", "description": "Research data-border-color usage", "prompt": "Research how data-border-color is used in the codebase"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 3, "cache_creation_input_tokens": 1000, "cache_read_input_tokens": 0, "output_tokens": 100, "service_tier": "standard"}}, "requestId": "req_dedup_main_1", "type": "assistant", "uuid": "dedup-main-1", "timestamp": "2025-11-19T22:53:34.908Z"} +{"parentUuid": "dedup-main-1", "isSidechain": false, "userType": "external", "cwd": "e:\\Workspace\\src\\github\\claude-code-log", "sessionId": "test-dedup-session", "version": "2.0.46", "gitBranch": "dev/fold-ui-horizontal", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_dedup_task", "type": "tool_result", "content": [{"type": "text", "text": "I created the test file successfully. The data-border-color attribute is set in the template and used by CSS selectors to determine fold-bar border colors."}]}]}, "uuid": "dedup-main-2", "timestamp": "2025-11-19T22:55:34.463Z", "toolUseResult": {"status": "completed", "prompt": "Research how data-border-color is used in the codebase", "agentId": "e1c84ba5", "content": [{"type": "text", "text": "I created the test file successfully. The data-border-color attribute is set in the template and used by CSS selectors to determine fold-bar border colors."}], "totalDurationMs": 119249, "totalTokens": 59967, "totalToolUseCount": 12, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 1001, "cache_read_input_tokens": 5000, "output_tokens": 200, "service_tier": "standard"}}} +{"parentUuid": "dedup-main-2", "isSidechain": false, "userType": "external", "cwd": "e:\\Workspace\\src\\github\\claude-code-log", "sessionId": "test-dedup-session", "version": "2.0.46", "gitBranch": "dev/fold-ui-horizontal", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_dedup_final", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Great! The research is complete."}], "stop_reason": "end_turn", "stop_sequence": null, "usage": {"input_tokens": 3, "cache_creation_input_tokens": 100, "cache_read_input_tokens": 1000, "output_tokens": 50, "service_tier": "standard"}}, "requestId": "req_dedup_main_3", "type": "assistant", "uuid": "dedup-main-3", "timestamp": "2025-11-19T22:55:40.790Z"} diff --git a/test/test_data/sidechain_agent.jsonl b/test/test_data/sidechain_agent.jsonl new file mode 100644 index 00000000..3255f484 --- /dev/null +++ b/test/test_data/sidechain_agent.jsonl @@ -0,0 +1,3 @@ +{"parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "e:\\Workspace\\src\\github\\claude-code-log", "sessionId": "88a8d761-7b9a-4bf1-a8ca-c1febe6bf358", "version": "2.0.46", "gitBranch": "dev/fold-ui-horizontal", "agentId": "e1c84ba5", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_017zj75dqWUNqVxCUrhqbTkf", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "I'll research how `data-border-color` is used thro..."}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 3, "cache_creation_input_tokens": 19830, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 19830, "ephemeral_1h_input_tokens": 0}, "output_tokens": 183, "service_tier": "standard"}}, "requestId": "req_011CVJ51ytkcBEjrQhRNswqR", "type": "assistant", "uuid": "b674eedd-0d9d-49e7-8116-1f092cd750ed", "timestamp": "2025-11-19T22:53:39.112Z"} +{"parentUuid": "b674eedd-0d9d-49e7-8116-1f092cd750ed", "isSidechain": true, "userType": "external", "cwd": "e:\\Workspace\\src\\github\\claude-code-log", "sessionId": "88a8d761-7b9a-4bf1-a8ca-c1febe6bf358", "version": "2.0.46", "gitBranch": "dev/fold-ui-horizontal", "agentId": "e1c84ba5", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_017zj75dqWUNqVxCUrhqbTkf", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01BWgwmyj8gGuqFNHMPqqtN2", "name": "Grep", "input": {"pattern": "data-border-color", "output_mode": "files_with_matches"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 3, "cache_creation_input_tokens": 19830, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 19830, "ephemeral_1h_input_tokens": 0}, "output_tokens": 183, "service_tier": "standard"}}, "requestId": "req_011CVJ51ytkcBEjrQhRNswqR", "type": "assistant", "uuid": "3c78114e-3de3-471e-b80f-0c86adb361c4", "timestamp": "2025-11-19T22:53:39.868Z"} +{"parentUuid": "718670f5-3e6b-4b3e-9a70-bc3e5ac90f92", "isSidechain": true, "userType": "external", "cwd": "e:\\Workspace\\src\\github\\claude-code-log", "sessionId": "88a8d761-7b9a-4bf1-a8ca-c1febe6bf358", "version": "2.0.46", "gitBranch": "dev/fold-ui-horizontal", "agentId": "e1c84ba5", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_011V1hetiJ3pC79EoLTY53Ni", "type": "tool_result", "content": " 3130\u00e2\u2020\u2019 # Simplified: no \"Tool Re..."}]}, "uuid": "4899826e-499f-4f24-ae6e-f2ac31cddfd4", "timestamp": "2025-11-19T22:54:57.555Z", "toolUseResult": {"type": "text", "file": {"filePath": "e:\\Workspace\\src\\github\\claude-code-log\\claude_cod...", "content": " # Simplified: no \"Tool Result\" hea...", "numLines": 50, "startLine": 3130, "totalLines": 3379}}} diff --git a/test/test_data/sidechain_main.jsonl b/test/test_data/sidechain_main.jsonl new file mode 100644 index 00000000..d397bb7a --- /dev/null +++ b/test/test_data/sidechain_main.jsonl @@ -0,0 +1,3 @@ +{"parentUuid": "8ae40e91-fa63-40a9-969d-70c0c1d6175e", "isSidechain": false, "userType": "external", "cwd": "e:\\Workspace\\src\\github\\claude-code-log", "sessionId": "88a8d761-7b9a-4bf1-a8ca-c1febe6bf358", "version": "2.0.46", "gitBranch": "dev/fold-ui-horizontal", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_01H4wg8bFy2psdmQvJMU6kpD", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01JA2mLseQCAW3MdwPE4qWEk", "name": "Task", "input": {"subagent_type": "general-purpose", "description": "Research data-border-color usage and CSS class mat...", "prompt": "Research how data-border-color is used in the code..."}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 3, "cache_creation_input_tokens": 141272, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 141272, "ephemeral_1h_input_tokens": 0}, "output_tokens": 365, "service_tier": "standard"}}, "requestId": "req_011CVJ517iGrQqjVFtrWsUgP", "type": "assistant", "uuid": "83a7cabd-3642-45d6-ba73-9b9f843b9ab1", "timestamp": "2025-11-19T22:53:34.908Z"} +{"parentUuid": "83a7cabd-3642-45d6-ba73-9b9f843b9ab1", "isSidechain": false, "userType": "external", "cwd": "e:\\Workspace\\src\\github\\claude-code-log", "sessionId": "88a8d761-7b9a-4bf1-a8ca-c1febe6bf358", "version": "2.0.46", "gitBranch": "dev/fold-ui-horizontal", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01JA2mLseQCAW3MdwPE4qWEk", "type": "tool_result", "content": [{"type": "text", "text": "Perfect! Now I have a complete understanding. Let ..."}]}]}, "uuid": "97965533-18f3-41b7-aa07-0c438f2a5893", "timestamp": "2025-11-19T22:55:34.463Z", "toolUseResult": {"status": "completed", "prompt": "Research how data-border-color is used in the code...", "agentId": "e1c84ba5", "content": [{"type": "text", "text": "Perfect! Now I have a complete understanding. Let ..."}], "totalDurationMs": 119249, "totalTokens": 59967, "totalToolUseCount": 12, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 1001, "cache_read_input_tokens": 56513, "cache_creation": {"ephemeral_5m_input_tokens": 1001, "ephemeral_1h_input_tokens": 0}, "output_tokens": 2451, "service_tier": "standard"}}} +{"parentUuid": "97965533-18f3-41b7-aa07-0c438f2a5893", "isSidechain": false, "userType": "external", "cwd": "e:\\Workspace\\src\\github\\claude-code-log", "sessionId": "88a8d761-7b9a-4bf1-a8ca-c1febe6bf358", "version": "2.0.46", "gitBranch": "dev/fold-ui-horizontal", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_011b5A5rZCXR7eBYR4VbohVZ", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Excellent research! The sub-assistant has identifi..."}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 3, "cache_creation_input_tokens": 2952, "cache_read_input_tokens": 141272, "cache_creation": {"ephemeral_5m_input_tokens": 2952, "ephemeral_1h_input_tokens": 0}, "output_tokens": 190, "service_tier": "standard"}}, "requestId": "req_011CVJ5Au8m6JgC6Ly7SbVPv", "type": "assistant", "uuid": "91c82c1c-29b0-4149-a261-e5582d944ac6", "timestamp": "2025-11-19T22:55:40.790Z"} diff --git a/test/test_ide_tags.py b/test/test_ide_tags.py index 932a53e2..723e4885 100644 --- a/test/test_ide_tags.py +++ b/test/test_ide_tags.py @@ -129,7 +129,9 @@ def test_render_user_message_with_multi_item_content(): image_item, ] - content_html, is_compacted = render_user_message_content(content_list) + content_html, is_compacted, is_memory_input = render_user_message_content( + content_list + ) # Should extract IDE notification assert "πŸ€–" in content_html @@ -145,6 +147,8 @@ def test_render_user_message_with_multi_item_content(): # Should not be compacted assert is_compacted is False + # Should not be memory input + assert is_memory_input is False def test_render_message_content_single_text_item(): diff --git a/test/test_sidechain_agents.py b/test/test_sidechain_agents.py new file mode 100644 index 00000000..f9b665bc --- /dev/null +++ b/test/test_sidechain_agents.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Tests for sidechain agent insertion and deduplication functionality.""" + +import tempfile +from pathlib import Path + + +from claude_code_log.parser import load_transcript +from claude_code_log.renderer import generate_html + + +def test_agent_insertion(): + """Test that agent messages are inserted after their referencing tool result.""" + # Create test data files + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + # Write main transcript + main_file = tmpdir_path / "main.jsonl" + main_file.write_text( + (Path(__file__).parent / "test_data" / "sidechain_main.jsonl").read_text() + ) + + # Write agent transcript (must match agentId in main file) + agent_file = tmpdir_path / "agent-e1c84ba5.jsonl" + agent_file.write_text( + (Path(__file__).parent / "test_data" / "sidechain_agent.jsonl").read_text() + ) + + # Load transcript with agent insertion (agent files discovered automatically) + messages = load_transcript(main_file) + + # Verify agent messages were inserted (3 main + 3 agent = 6 total) + assert len(messages) == 6, f"Expected 6 messages, got {len(messages)}" + + # Find the tool result message (uuid 97965533-18f3-41b7-aa07-0c438f2a5893) + tool_result_idx = next( + i + for i, msg in enumerate(messages) + if msg.uuid == "97965533-18f3-41b7-aa07-0c438f2a5893" + ) + + # Verify agent messages come right after tool result + # The agent messages should be inserted between tool_result and next main message + assert tool_result_idx + 3 < len(messages), "Agent messages should be inserted" + # Verify at least one message after tool result is from sidechain + assert any( + getattr(messages[i], "isSidechain", False) + for i in range(tool_result_idx + 1, len(messages)) + ) + + +def test_deduplication_task_result_vs_sidechain(): + """Test that sidechain assistant final message is deduplicated when it matches Task result.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + # Write deduplication test data + main_file = tmpdir_path / "main.jsonl" + main_file.write_text( + (Path(__file__).parent / "test_data" / "dedup_main.jsonl").read_text() + ) + + agent_file = tmpdir_path / "agent-e1c84ba5.jsonl" + agent_file.write_text( + (Path(__file__).parent / "test_data" / "dedup_agent.jsonl").read_text() + ) + + # Load and render (agent files discovered automatically) + messages = load_transcript(main_file) + html = generate_html(messages, title="Test") + + # Verify deduplication occurred: + # The sidechain assistant's final message should be replaced with a forward link + assert "(Task summary" in html + assert "already displayed in" in html + assert "Task tool result above" in html + + # The actual content "I created the test file successfully" should only appear once + # in the Task result, not in the sidechain assistant + content_count = html.count("I created the test file successfully") + assert content_count == 1, ( + f"Expected content to appear once, found {content_count} times" + ) + + +def test_no_deduplication_when_content_different(): + """Test that deduplication doesn't occur when Task result and sidechain differ.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + # Create test data with different content + # Note: agentId is just "ghi789", the filename is "agent-ghi789.jsonl" + main_file = tmpdir_path / "main.jsonl" + main_file.write_text( + '{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"e:\\\\test","sessionId":"test-3","version":"2.0.46","gitBranch":"main","type":"user","message":{"role":"user","content":[{"type":"text","text":"Do something"}]},"uuid":"d-0","timestamp":"2025-01-15T12:00:00.000Z"}\n' + '{"parentUuid":"d-0","isSidechain":false,"userType":"external","cwd":"e:\\\\test","sessionId":"test-3","version":"2.0.46","gitBranch":"main","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_01test1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"task-3","name":"Task","input":{"prompt":"Do it"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":20}},"requestId":"req_01test1","type":"assistant","uuid":"d-1","timestamp":"2025-01-15T12:00:05.000Z"}\n' + '{"parentUuid":"d-1","isSidechain":false,"userType":"external","cwd":"e:\\\\test","sessionId":"test-3","version":"2.0.46","gitBranch":"main","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"task-3","content":"Done A"}]},"uuid":"d-2","timestamp":"2025-01-15T12:00:15.000Z","toolUseResult":{"agentId":"ghi789","content":"Done A"},"agentId":"ghi789"}\n' + ) + + agent_file = tmpdir_path / "agent-ghi789.jsonl" + agent_file.write_text( + '{"parentUuid":null,"isSidechain":true,"userType":"external","cwd":"e:\\\\test","sessionId":"test-3","version":"2.0.46","gitBranch":"main","agentId":"ghi789","type":"user","message":{"role":"user","content":[{"type":"text","text":"Do it"}]},"uuid":"agent-d-0","timestamp":"2025-01-15T12:00:06.000Z"}\n' + '{"parentUuid":"agent-d-0","isSidechain":true,"userType":"external","cwd":"e:\\\\test","sessionId":"test-3","version":"2.0.46","gitBranch":"main","agentId":"ghi789","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_01testagent1","type":"message","role":"assistant","content":[{"type":"text","text":"Done B"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":5,"output_tokens":10}},"requestId":"req_01testagent1","type":"assistant","uuid":"agent-d-1","timestamp":"2025-01-15T12:00:14.000Z"}\n' + ) + + messages = load_transcript(main_file) + html = generate_html(messages, title="Test") + + # No deduplication should occur - both "Done A" and "Done B" should appear + assert "Done A" in html + assert "Done B" in html + assert "(Task summary" not in html + + +def test_agent_messages_marked_as_sidechain(): + """Test that agent messages are properly marked with sidechain class.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + main_file = tmpdir_path / "main.jsonl" + main_file.write_text( + (Path(__file__).parent / "test_data" / "sidechain_main.jsonl").read_text() + ) + + agent_file = tmpdir_path / "agent-e1c84ba5.jsonl" + agent_file.write_text( + (Path(__file__).parent / "test_data" / "sidechain_agent.jsonl").read_text() + ) + + messages = load_transcript(main_file) + html = generate_html(messages, title="Test") + + # Agent messages should have sidechain class + assert ( + "class='message assistant sidechain" in html + or "class='message user sidechain" in html + ) + + # Verify sidechain messages are indented (have ancestry classes) + assert ( + "d-2" in html + ) # Tool result ID should appear in ancestry for sidechain messages + + +def test_multiple_agent_invocations(): + """Test handling of multiple Task invocations in same session.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + # Create scenario with two Task invocations + main_file = tmpdir_path / "main.jsonl" + main_file.write_text( + '{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/workspace/test","sessionId":"test-4","version":"2.0.46","gitBranch":"main","type":"user","message":{"role":"user","content":[{"type":"text","text":"Do two things"}]},"uuid":"d-0","timestamp":"2025-01-15T13:00:00.000Z"}\n' + '{"parentUuid":"d-0","isSidechain":false,"userType":"external","cwd":"/workspace/test","sessionId":"test-4","version":"2.0.46","gitBranch":"main","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_01","type":"message","role":"assistant","content":[{"type":"tool_use","id":"task-4a","name":"Task","input":{"prompt":"First task"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":100,"output_tokens":50}},"requestId":"req_01","type":"assistant","uuid":"d-1","timestamp":"2025-01-15T13:00:05.000Z"}\n' + '{"parentUuid":"d-1","isSidechain":false,"userType":"external","cwd":"/workspace/test","sessionId":"test-4","version":"2.0.46","gitBranch":"main","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"task-4a","content":"First done"}]},"uuid":"d-2","timestamp":"2025-01-15T13:00:15.000Z","toolUseResult":{"status":"completed","agentId":"first","content":[{"type":"text","text":"First done"}]},"agentId":"first"}\n' + '{"parentUuid":"d-2","isSidechain":false,"userType":"external","cwd":"/workspace/test","sessionId":"test-4","version":"2.0.46","gitBranch":"main","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_02","type":"message","role":"assistant","content":[{"type":"tool_use","id":"task-4b","name":"Task","input":{"prompt":"Second task"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":150,"output_tokens":60}},"requestId":"req_02","type":"assistant","uuid":"d-3","timestamp":"2025-01-15T13:00:20.000Z"}\n' + '{"parentUuid":"d-3","isSidechain":false,"userType":"external","cwd":"/workspace/test","sessionId":"test-4","version":"2.0.46","gitBranch":"main","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"task-4b","content":"Second done"}]},"uuid":"d-4","timestamp":"2025-01-15T13:00:30.000Z","toolUseResult":{"status":"completed","agentId":"second","content":[{"type":"text","text":"Second done"}]},"agentId":"second"}\n' + ) + + (tmpdir_path / "agent-first.jsonl").write_text( + '{"parentUuid":null,"isSidechain":true,"userType":"external","cwd":"/workspace/test","sessionId":"test-4","version":"2.0.46","gitBranch":"main","agentId":"first","type":"user","message":{"role":"user","content":[{"type":"text","text":"First task"}]},"uuid":"agent-d-0","timestamp":"2025-01-15T13:00:06.000Z"}\n' + '{"parentUuid":"agent-d-0","isSidechain":true,"userType":"external","cwd":"/workspace/test","sessionId":"test-4","version":"2.0.46","gitBranch":"main","agentId":"first","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_agent_01","type":"message","role":"assistant","content":[{"type":"text","text":"First done"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":50,"output_tokens":25}},"requestId":"req_agent_01","type":"assistant","uuid":"agent-d-1","timestamp":"2025-01-15T13:00:14.000Z"}\n' + ) + + (tmpdir_path / "agent-second.jsonl").write_text( + '{"parentUuid":null,"isSidechain":true,"userType":"external","cwd":"/workspace/test","sessionId":"test-4","version":"2.0.46","gitBranch":"main","agentId":"second","type":"user","message":{"role":"user","content":[{"type":"text","text":"Second task"}]},"uuid":"agent2-d-0","timestamp":"2025-01-15T13:00:21.000Z"}\n' + '{"parentUuid":"agent2-d-0","isSidechain":true,"userType":"external","cwd":"/workspace/test","sessionId":"test-4","version":"2.0.46","gitBranch":"main","agentId":"second","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_agent_02","type":"message","role":"assistant","content":[{"type":"text","text":"Second done"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":55,"output_tokens":30}},"requestId":"req_agent_02","type":"assistant","uuid":"agent2-d-1","timestamp":"2025-01-15T13:00:29.000Z"}\n' + ) + + messages = load_transcript(main_file) + + # Should have 5 main + 2 + 2 agent messages = 9 total + assert len(messages) == 9 + + # Verify both agent sequences are inserted correctly + html = generate_html(messages, title="Test") + assert "First done" in html + assert "Second done" in html diff --git a/test/test_template_rendering.py b/test/test_template_rendering.py index 3e0803a0..0a10afbd 100644 --- a/test/test_template_rendering.py +++ b/test/test_template_rendering.py @@ -40,8 +40,8 @@ def test_representative_messages_render(self): ) # Check that all message types are present - assert "class='message user'" in html_content - assert "class='message assistant'" in html_content + assert "class='message user" in html_content + assert "class='message assistant" in html_content # Summary messages are now integrated into session headers assert "session-summary" in html_content or "Summary:" in html_content @@ -249,8 +249,8 @@ def test_css_classes_applied(self): html_content = html_file.read_text(encoding="utf-8") # Check message type classes - assert "class='message user'" in html_content - assert "class='message assistant'" in html_content + assert "class='message user" in html_content + assert "class='message assistant" in html_content # Summary messages are now integrated into session headers assert "session-summary" in html_content or "Summary:" in html_content diff --git a/test/test_todowrite_rendering.py b/test/test_todowrite_rendering.py index 933a7f5f..80942ab7 100644 --- a/test/test_todowrite_rendering.py +++ b/test/test_todowrite_rendering.py @@ -211,8 +211,9 @@ def test_todowrite_integration_with_full_message(self): assert "Write tests" in html_content assert "πŸ”„" in html_content # in_progress emoji assert "⏳" in html_content # pending emoji + # Check tool_use class is present (may have ancestor IDs appended) assert ( - "class='message tool_use'" in html_content + "class='message tool_use" in html_content ) # tool as top-level message # Check CSS classes are applied From 9a00c513f33fef684dcc37d80302a198359547a1 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 26 Nov 2025 19:51:53 +0100 Subject: [PATCH 02/20] Fix Pygments Lexer Performance Bottleneck (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add performance timing instrumentation to renderer Add optional timing measurements to identify performance bottlenecks in the HTML rendering process. Controlled by CLAUDE_CODE_LOG_DEBUG_TIMING environment variable. Timing points added: - Initialization - Message deduplication - Session summary processing - Tool use context building - Main message processing loop - Session navigation building - Message pair identification - Paired message reordering - Mark messages with children - Template environment setup - Template rendering (with HTML size) Usage: CLAUDE_CODE_LOG_DEBUG_TIMING=1 claude-code-log file.jsonl This will print timing output like: [TIMING] Initialization 0.001s (total: 0.001s) [TIMING] Deduplication (1234 messages) 0.050s (total: 0.051s) [TIMING] Main message processing loop 5.234s (total: 5.285s) [TIMING] Template rendering (30MB chars) 15.432s (total: 20.717s) No impact on performance when disabled (default). * Add per-message timing statistics to main processing loop Track time spent on each individual message during the main processing loop and report statistics about slowest messages. This helps identify performance outliers. New timing output when DEBUG_TIMING is enabled: - Average time per message - Slowest 10 messages with their types and indices This helps answer: - What's the average time to process a message? - Which specific messages are slow? - Is there a pattern in slow messages (type, content, etc.)? Sample output: [TIMING] Loop statistics: [TIMING] Total messages: 7027 [TIMING] Average time per message: 7.5ms [TIMING] Slowest 10 messages: [TIMING] Message #1234 (assistant): 245.3ms [TIMING] Message #5678 (user): 189.7ms [TIMING] ... * Add detailed timing instrumentation for markdown and Pygments operations Extended the DEBUG_TIMING instrumentation to track and report: - Per-message timing with UUIDs instead of indices - Cumulative markdown rendering statistics (total time + slowest 10) - Cumulative Pygments highlighting statistics (total time + slowest 10) This helps identify performance bottlenecks in content rendering by: - Wrapping render_markdown() to track mistune operations - Instrumenting Pygments highlight() calls in both block_code plugin and _highlight_code_with_pygments() function - Using module-level globals to pass timing context to nested functions - Reporting UUIDs to easily identify which messages are slow Statistics now show: - Message UUIDs for easy identification - Total markdown rendering operations and cumulative time - Total Pygments highlighting operations and cumulative time - Top 10 slowest operations for each category * Fix critical performance bottleneck in syntax highlighting (40-125x speedup) Problem: Message rendering was experiencing severe slowdowns (2+ seconds per message) when processing Read tool results with file paths. User's excellent isolation testing identified that identical messages took 0.7ms in one context but 185ms in another, with the only difference being message ordering. Root cause: Pygments' get_lexer_for_filename() performs filesystem I/O operations (stat(), file existence checks, byte reading) to determine the appropriate lexer. On Windows with antivirus scanning, this causes severe slowdowns (100-1000ms per file path) even when the file no longer exists or has changed since the transcript was recorded. Solution: Use Pygments' internal LEXERS mapping to build a reverse filename pattern β†’ lexer alias lookup table (built once on first use, cached). This approach: 1. Supports all 597 Pygments lexers automatically (vs ~30 with manual mapping) 2. Eliminates ALL filesystem I/O by pattern matching against basenames 3. Uses Pygments' own filename patterns for complete coverage 4. Correctly handles wildcards like "*.py", "*.ABAP", etc. The cached mapping is built from pygments.lexers._mapping.LEXERS which contains tuples of (module, name, aliases, filename_patterns, mimetypes). We extract filename patterns and map them to lexer aliases for instant lookup via fnmatch. Benefits: - 40x speedup: 2136ms β†’ 53ms for problematic messages - Complete lexer coverage: All 597 Pygments lexers (909 filename patterns) - No dependency on filesystem state at render time - Consistent behavior regardless of file existence or antivirus settings - Correct business logic: render time shouldn't depend on current filesystem Performance impact: - Fast case (88a-3.jsonl): 0.9ms (unchanged) - Slow case (88a-4.jsonl): 2136ms β†’ 53ms (40x improvement) - Pygments operations: Now consistently <3ms regardless of file paths - Pattern cache built once: ~900 patterns, negligible overhead Fixes performance regression that made large transcript rendering impractical on Windows systems with active antivirus protection. * Use public Pygments API instead of internal _mapping.LEXERS Replace pygments.lexers._mapping.LEXERS with the public get_all_lexers() API for better stability across Pygments versions. Maintains 40x performance improvement (from 2136ms to ~36ms per message) while using only documented stable APIs. Performance validated: - Fast case (88a-3.jsonl): 0.9ms per message (unchanged) - Slow case (88a-4.jsonl): 36ms per message (vs 2136ms original) - All 248 unit tests passing * Optimize Read tool result rendering by eliminating double Pygments call When rendering Read tool results with >12 lines, we were calling Pygments twice: 1. Once to highlight the full content 2. Again to highlight a 5-line preview This caused ~75ms overhead per large Read result (115-line file). **Solution:** Extract preview HTML from the already-highlighted full content using regex to find the first 5 rows, rather than re-highlighting the preview lines. **Performance:** - Before: 96ms for message with pyproject.toml Read result - After: Still ~66ms (regex has overhead, but eliminated redundant work) - More importantly: No longer highlighting the same code twice The optimization maintains correctness while reducing unnecessary computation. * Optimize Pygments lexer lookup with extension-based cache Identified and fixed a critical performance bottleneck in lexer pattern matching. The previous implementation iterated through all 909 filename patterns using fnmatch for every file, causing 50ms+ delays per Read tool result. Changes: - Added extension_cache alongside pattern_cache for O(1) lookups - Simple *.ext patterns are indexed by extension (e.g., "toml" -> "toml" lexer) - Falls back to full pattern matching only when extension lookup fails - Added detailed timing instrumentation for debugging (DEBUG_TIMING flag) Performance improvement: - Lexer lookup: 50.0ms β†’ 10.3ms (5x faster) - Total Read formatting: 120ms β†’ 44ms (2.7x faster) - Full message rendering maintains similar speedup All 253 unit tests passing. This fixes the unexplained 90ms+ gap between message timing and reported Pygments time. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Remove detailed READ-DEBUG and HIGHLIGHT-DEBUG timing instrumentation The detailed timing code has served its purpose in identifying and fixing the performance bottleneck. Clean up the code by removing the granular timing instrumentation while keeping the core DEBUG_TIMING functionality. - Remove READ-DEBUG formatter timing - Remove READ-DEBUG parse, extract, highlight, regex, and build HTML timing - Remove HIGHLIGHT-DEBUG timing variables and print statements - Keep overall message timing and Pygments timing for high-level monitoring - All 253 unit tests passing * Remove unused phase_timings dead code The phase_timings variable was initialized and populated but never actually used for output. This is leftover from the detailed timing instrumentation that was previously removed. - Remove unused phase_timings list initialization - Remove unused phase_start variable and timing calculation - All 253 unit tests passing * Refactor timing instrumentation and optimize tool_use_context - Extract timing utilities to renderer_timings.py module - Standardize log_timing pattern with work inside context managers - Use lambda expressions for dynamic phase names - Remove _define_tool_use_context() pre-processing pass - Change tool_use_context to Dict[str, ToolUseContent] for type safety - Build tool_use_context inline during message processing - Replace dead code in render_message_content with warnings - All tests passing, 0 type errors πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Move DEBUG_TIMING constant to renderer_timings module - Centralizes all timing-related configuration in renderer_timings.py - Adds improved documentation for DEBUG_TIMING environment variable - Removes top-level os import from renderer.py (no longer needed) - Imports DEBUG_TIMING from renderer_timings in renderer.py - All tests passing, 0 type errors πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Simplify state transitions diagram with correct flow Replace confusing multi-path ASCII diagram with cleaner version showing: - A β†’ B (β–Ά) and A β†’ C (β–Άβ–Ά) downward transitions - B ↔ C bidirectional (β–Άβ–Ά/β–Όβ–Ό) horizontal transition - B β†’ A and C β†’ A (β–Ό) upward return transitions Uses consistent Unicode box drawing characters throughout. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix Read tool preview truncation regression The preview extraction was looking for multiple tags, but Pygments' HtmlFormatter(linenos="table") produces a single containing two s (one for line numbers, one for code). Since there's only one , the condition `len(tr_matches) >= 5` was always False, falling back to showing the full content without truncation. Fix: Add _truncate_highlighted_preview() helper that correctly truncates content within each
 tag (both linenos and code sections) to the
specified number of lines.

πŸ€– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude 

* Add regression tests for preview truncation

Add test coverage for the _truncate_highlighted_preview() function
to prevent future regressions in collapsible code preview behavior.

Tests verify:
- Helper function correctly truncates Pygments table HTML structure
- Edit tool results show truncated preview (5 lines) in collapsible blocks
- Full content remains available in expanded section

Test data: Real Edit tool use/result pair for renderer_timings.py

πŸ€– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude 

---------

Co-authored-by: Claude 
---
 claude_code_log/renderer.py         | 642 ++++++++++++++++++----------
 claude_code_log/renderer_timings.py | 158 +++++++
 dev-docs/FOLD_STATE_DIAGRAM.md      |  66 +--
 test/test_data/edit_tool.jsonl      |   2 +
 test/test_preview_truncation.py     | 129 ++++++
 5 files changed, 716 insertions(+), 281 deletions(-)
 create mode 100644 claude_code_log/renderer_timings.py
 create mode 100644 test/test_data/edit_tool.jsonl
 create mode 100644 test/test_preview_truncation.py

diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py
index 02e779e0..7a87fdee 100644
--- a/claude_code_log/renderer.py
+++ b/claude_code_log/renderer.py
@@ -2,7 +2,9 @@
 """Render Claude transcript data to HTML format."""
 
 import json
+import os
 import re
+import time
 from pathlib import Path
 from typing import List, Optional, Dict, Any, cast, TYPE_CHECKING
 
@@ -13,12 +15,14 @@
 import mistune
 from jinja2 import Environment, FileSystemLoader, select_autoescape
 from pygments import highlight  # type: ignore[reportUnknownVariableType]
-from pygments.lexers import get_lexer_for_filename, TextLexer  # 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,
+    AssistantTranscriptEntry,
+    SystemTranscriptEntry,
     SummaryTranscriptEntry,
     QueueOperationTranscriptEntry,
     ContentItem,
@@ -38,6 +42,13 @@
     should_use_as_session_starter,
     create_session_preview,
 )
+from .renderer_timings import (
+    DEBUG_TIMING,
+    report_timing_statistics,
+    timing_stat,
+    set_timing_var,
+    log_timing,
+)
 from .cache import get_library_version
 
 
@@ -232,7 +243,9 @@ def block_code(code: str, info: Optional[str] = None) -> str:
                     cssclass="highlight",
                     wrapcode=True,
                 )
-                return str(highlight(code, lexer, formatter))  # type: ignore[reportUnknownArgumentType]
+                # 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)
@@ -244,21 +257,23 @@ def block_code(code: str, info: Optional[str] = None) -> str:
 
 def render_markdown(text: str) -> str:
     """Convert markdown text to HTML using mistune with Pygments syntax highlighting."""
-    # 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))
+    # 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 extract_command_info(text_content: str) -> tuple[str, str, str]:
@@ -372,9 +387,68 @@ def _highlight_code_with_pygments(
     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:
-        # Try to get lexer based on filename
-        lexer = get_lexer_for_filename(file_path, code)  # type: ignore[reportUnknownVariableType]
+        # 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
+        if lexer_alias:
+            lexer = get_lexer_by_name(lexer_alias, stripall=True)  # type: ignore[reportUnknownVariableType]
+        else:
+            lexer = TextLexer()  # type: ignore[reportUnknownVariableType]
     except ClassNotFound:
         # Fall back to plain text lexer
         lexer = TextLexer()  # type: ignore[reportUnknownVariableType]
@@ -387,8 +461,52 @@ def _highlight_code_with_pygments(
         linenostart=linenostart,
     )
 
-    # Highlight the code
-    return str(highlight(code, lexer, formatter))  # type: ignore[reportUnknownArgumentType]
+    # 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 @@ -977,10 +1095,11 @@ def format_tool_result_content( # Try to parse as Read tool result if file_path is provided if file_path and tool_name == "Read" and not has_images: parsed_result = _parse_read_tool_result(raw_content) + if parsed_result: code_content, system_reminder, line_offset = parsed_result - # Highlight code with Pygments using correct line offset + # Highlight code with Pygments using correct line offset (single call) highlighted_html = _highlight_code_with_pygments( code_content, file_path, linenostart=line_offset ) @@ -991,11 +1110,12 @@ def format_tool_result_content( # Make collapsible if content has more than 12 lines lines = code_content.split("\n") if len(lines) > 12: - # Get preview (first ~5 lines) - preview_lines = lines[:5] - preview_html = _highlight_code_with_pygments( - "\n".join(preview_lines), file_path, linenostart=line_offset - ) + # Extract preview from already-highlighted HTML to avoid double-highlighting + # HtmlFormatter(linenos="table") produces a single with two s: + # ...
LINE_NUMS
... + # ...
CODE
... + # We truncate content within each
 to first 5 lines
+                preview_html = _truncate_highlighted_preview(highlighted_html, 5)
 
                 result_parts.append(f"""
                 
@@ -1384,46 +1504,27 @@ def render_message_content(content: List[ContentItem], message_type: str) -> str elif type(item) is ToolUseContent or ( hasattr(item, "type") and item_type == "tool_use" ): - # Handle both ToolUseContent and Anthropic ToolUseBlock - # Convert Anthropic type to our format if necessary - if not isinstance(item, ToolUseContent): - # Create a ToolUseContent from Anthropic ToolUseBlock - tool_use_item = ToolUseContent( - type="tool_use", - id=getattr(item, "id", ""), - name=getattr(item, "name", ""), - input=getattr(item, "input", {}), - ) - else: - tool_use_item = item - rendered_parts.append(format_tool_use_content(tool_use_item)) # type: ignore + # Tool use items should not appear here - they are filtered out before this function + print( + "Warning: tool_use content should not be processed in render_message_content", + flush=True, + ) elif type(item) is ToolResultContent or ( hasattr(item, "type") and item_type == "tool_result" ): - # Handle both ToolResultContent and Anthropic types - if not isinstance(item, ToolResultContent): - # Convert from Anthropic type if needed - tool_result_item = ToolResultContent( - type="tool_result", - tool_use_id=getattr(item, "tool_use_id", ""), - content=getattr(item, "content", ""), - is_error=getattr(item, "is_error", False), - ) - else: - tool_result_item = item - rendered_parts.append(format_tool_result_content(tool_result_item)) # type: ignore + # Tool result items should not appear here - they are filtered out before this function + print( + "Warning: tool_result content should not be processed in render_message_content", + flush=True, + ) elif type(item) is ThinkingContent or ( hasattr(item, "type") and item_type == "thinking" ): - # Handle both ThinkingContent and Anthropic ThinkingBlock - if not isinstance(item, ThinkingContent): - # Convert from Anthropic type if needed - thinking_item = ThinkingContent( - type="thinking", thinking=getattr(item, "thinking", str(item)) - ) - else: - thinking_item = item - rendered_parts.append(format_thinking_content(thinking_item)) # type: ignore + # Thinking items should not appear here - they are filtered out before this function + print( + "Warning: thinking content should not be processed in render_message_content", + flush=True, + ) elif type(item) is ImageContent: rendered_parts.append(format_image_content(item)) # type: ignore @@ -2517,77 +2618,202 @@ def generate_html( combined_transcript_link: Optional[str] = None, ) -> str: """Generate HTML from transcript messages using Jinja2 templates.""" - if not title: - title = "Claude Transcript" + # Performance timing + t_start = time.time() + + with log_timing("Initialization", t_start): + if not title: + title = "Claude Transcript" # Deduplicate messages by (message_type, timestamp) # Messages with the exact same timestamp are duplicates by definition - # the differences (like IDE selection tags) are just logging artifacts - from claude_code_log.models import AssistantTranscriptEntry, SystemTranscriptEntry + with log_timing( + lambda: f"Deduplication ({len(deduplicated_messages)} messages)", t_start + ): + # Track seen (message_type, timestamp) pairs + seen: set[tuple[str, str]] = set() + deduplicated_messages: List[TranscriptEntry] = [] - # Track seen (message_type, timestamp) pairs - seen: set[tuple[str, str]] = set() - deduplicated_messages: List[TranscriptEntry] = [] + for message in messages: + # Get basic message type + message_type = getattr(message, "type", "unknown") - for message in messages: - # Get basic message type - message_type = getattr(message, "type", "unknown") - - # For system messages, include level to differentiate info/warning/error - if isinstance(message, SystemTranscriptEntry): - level = getattr(message, "level", "info") - message_type = f"system-{level}" + # For system messages, include level to differentiate info/warning/error + if isinstance(message, SystemTranscriptEntry): + level = getattr(message, "level", "info") + message_type = f"system-{level}" - # Get timestamp - timestamp = getattr(message, "timestamp", "") + # Get timestamp + timestamp = getattr(message, "timestamp", "") - # Create deduplication key - dedup_key = (message_type, timestamp) + # Create deduplication key + dedup_key = (message_type, timestamp) - # Keep only first occurrence - if dedup_key not in seen: - seen.add(dedup_key) - deduplicated_messages.append(message) + # Keep only first occurrence + if dedup_key not in seen: + seen.add(dedup_key) + deduplicated_messages.append(message) - messages = deduplicated_messages + messages = deduplicated_messages # Pre-process to find and attach session summaries - session_summaries: Dict[str, str] = {} - uuid_to_session: Dict[str, str] = {} - uuid_to_session_backup: Dict[str, str] = {} + with log_timing("Session summary processing", t_start): + session_summaries: Dict[str, str] = {} + uuid_to_session: Dict[str, str] = {} + uuid_to_session_backup: Dict[str, str] = {} + + # Build mapping from message UUID to session ID + for message in messages: + if hasattr(message, "uuid") and hasattr(message, "sessionId"): + message_uuid = getattr(message, "uuid", "") + session_id = getattr(message, "sessionId", "") + if message_uuid and session_id: + # There is often duplication, in that case we want to prioritise the assistant + # message because summaries are generated from Claude's (last) success message + if type(message) is AssistantTranscriptEntry: + uuid_to_session[message_uuid] = session_id + else: + uuid_to_session_backup[message_uuid] = session_id + + # Map summaries to sessions via leafUuid -> message UUID -> session ID + for message in messages: + if isinstance(message, SummaryTranscriptEntry): + leaf_uuid = message.leafUuid + if leaf_uuid in uuid_to_session: + session_summaries[uuid_to_session[leaf_uuid]] = message.summary + elif ( + leaf_uuid in uuid_to_session_backup + and uuid_to_session_backup[leaf_uuid] not in session_summaries + ): + session_summaries[uuid_to_session_backup[leaf_uuid]] = ( + message.summary + ) - # Build mapping from message UUID to session ID - for message in messages: - if hasattr(message, "uuid") and hasattr(message, "sessionId"): - message_uuid = getattr(message, "uuid", "") - session_id = getattr(message, "sessionId", "") - if message_uuid and session_id: - # There is often duplication, in that case we want to prioritise the assistant - # message because summaries are generated from Claude's (last) success message - if type(message) is AssistantTranscriptEntry: - uuid_to_session[message_uuid] = session_id + # Attach summaries to messages + for message in messages: + if hasattr(message, "sessionId"): + session_id = getattr(message, "sessionId", "") + if session_id in session_summaries: + setattr(message, "_session_summary", session_summaries[session_id]) + + # Process messages through the main rendering loop + template_messages, sessions, session_order = _process_messages_loop(messages) + + # Prepare session navigation data + session_nav: List[Dict[str, Any]] = [] + with log_timing( + lambda: f"Session navigation building ({len(session_nav)} sessions)", t_start + ): + for session_id in session_order: + session_info = sessions[session_id] + + # Format timestamp range + first_ts = session_info["first_timestamp"] + last_ts = session_info["last_timestamp"] + timestamp_range = "" + if first_ts and last_ts: + if first_ts == last_ts: + timestamp_range = format_timestamp(first_ts) else: - uuid_to_session_backup[message_uuid] = session_id + timestamp_range = ( + f"{format_timestamp(first_ts)} - {format_timestamp(last_ts)}" + ) + elif first_ts: + timestamp_range = format_timestamp(first_ts) - # Map summaries to sessions via leafUuid -> message UUID -> session ID - for message in messages: - if isinstance(message, SummaryTranscriptEntry): - leaf_uuid = message.leafUuid - if leaf_uuid in uuid_to_session: - session_summaries[uuid_to_session[leaf_uuid]] = message.summary - elif ( - leaf_uuid in uuid_to_session_backup - and uuid_to_session_backup[leaf_uuid] not in session_summaries - ): - session_summaries[uuid_to_session_backup[leaf_uuid]] = message.summary + # Format token usage summary + token_summary = "" + total_input = session_info["total_input_tokens"] + total_output = session_info["total_output_tokens"] + total_cache_creation = session_info["total_cache_creation_tokens"] + total_cache_read = session_info["total_cache_read_tokens"] + + if total_input > 0 or total_output > 0: + token_parts: List[str] = [] + if total_input > 0: + token_parts.append(f"Input: {total_input}") + if total_output > 0: + token_parts.append(f"Output: {total_output}") + if total_cache_creation > 0: + token_parts.append(f"Cache Creation: {total_cache_creation}") + if total_cache_read > 0: + token_parts.append(f"Cache Read: {total_cache_read}") + token_summary = "Token usage – " + " | ".join(token_parts) + + session_nav.append( + { + "id": session_id, + "summary": session_info["summary"], + "timestamp_range": timestamp_range, + "first_timestamp": first_ts, + "last_timestamp": last_ts, + "message_count": session_info["message_count"], + "first_user_message": session_info["first_user_message"] + if session_info["first_user_message"] != "" + else "[No user message found in session.]", + "token_summary": token_summary, + } + ) - # Attach summaries to messages - for message in messages: - if hasattr(message, "sessionId"): - session_id = getattr(message, "sessionId", "") - if session_id in session_summaries: - setattr(message, "_session_summary", session_summaries[session_id]) + # Identify and mark paired messages (command+output, tool_use+tool_result, etc.) + with log_timing("Identify message pairs", t_start): + _identify_message_pairs(template_messages) + + # Reorder messages so pairs are adjacent while preserving chronological order + with log_timing("Reorder paired messages", t_start): + template_messages = _reorder_paired_messages(template_messages) + + # Mark messages that have children for fold/unfold controls + with log_timing("Mark messages with children", t_start): + _mark_messages_with_children(template_messages) + + # Render template + with log_timing("Template environment setup", t_start): + env = _get_template_environment() + template = env.get_template("transcript.html") + + with log_timing(lambda: f"Template rendering ({len(html_output)} chars)", t_start): + html_output = str( + template.render( + title=title, + messages=template_messages, + sessions=session_nav, + combined_transcript_link=combined_transcript_link, + library_version=get_library_version(), + ) + ) + return html_output + + +def _process_messages_loop( + messages: List[TranscriptEntry], +) -> tuple[ + List[TemplateMessage], + Dict[str, Dict[str, Any]], # sessions + List[str], # session_order +]: + """Process messages through the main rendering loop. + + This function handles the core message processing logic: + - Processes each message into template-friendly format + - Tracks sessions and token usage + - Handles message deduplication and hierarchy + - Collects timing statistics + + Note: Tool use context must be built before calling this function via + _define_tool_use_context() + + Args: + messages: List of transcript entries to process + + Returns: + Tuple containing: + - template_messages: Processed messages ready for template rendering + - sessions: Session metadata dict mapping session_id to info + - session_order: List of session IDs in chronological order + """ # Group messages by session and collect session info for navigation sessions: Dict[str, Dict[str, Any]] = {} session_order: List[str] = [] @@ -2598,34 +2824,9 @@ def generate_html( # Track which messages should show token usage (first occurrence of each requestId) show_tokens_for_message: set[str] = set() - # Build mapping of tool_use_id to tool info for specialized tool result rendering - tool_use_context: Dict[str, Dict[str, Any]] = {} - for message in messages: - if hasattr(message, "message") and hasattr(message.message, "content"): # type: ignore - content = message.message.content # type: ignore - if isinstance(content, list): - for item in content: # type: ignore[reportUnknownVariableType] - # Check if it's a tool_use item - if hasattr(item, "type") and hasattr(item, "id"): # type: ignore[reportUnknownArgumentType] - item_type = getattr(item, "type", None) # type: ignore[reportUnknownArgumentType] - if item_type == "tool_use": - tool_id = getattr(item, "id", "") # type: ignore[reportUnknownArgumentType] - tool_name = getattr(item, "name", "") # type: ignore[reportUnknownArgumentType] - tool_input = getattr(item, "input", {}) # type: ignore[reportUnknownArgumentType] - if tool_id: - tool_ctx: Dict[str, Any] = { - "name": tool_name, - "input": tool_input, - } - # For Task tools, store the prompt for comparison - if tool_name == "Task" and isinstance(tool_input, dict): - prompt_value = tool_input.get("prompt", "") # type: ignore[reportUnknownVariableType, reportUnknownMemberType] - tool_ctx["prompt"] = ( - prompt_value - if isinstance(prompt_value, str) - else "" - ) - tool_use_context[tool_id] = tool_ctx + # Build mapping of tool_use_id to ToolUseContent for specialized tool result rendering + # This will be populated inline as we encounter tool_use items during message processing + tool_use_context: Dict[str, ToolUseContent] = {} # Process messages into template-friendly format template_messages: List[TemplateMessage] = [] @@ -2642,8 +2843,27 @@ def generate_html( # Maps raw content -> (template_messages index, message_id, type: "task" or "assistant") content_map: Dict[str, tuple[int, str, str]] = {} - for message in messages: + # Per-message timing tracking + message_timings: List[ + tuple[float, str, int, str] + ] = [] # (duration, message_type, index, uuid) + + # Track expensive operations + markdown_timings: List[tuple[float, str]] = [] # (duration, context_uuid) + pygments_timings: List[tuple[float, str]] = [] # (duration, context_uuid) + + # Initialize timing tracking + set_timing_var("_markdown_timings", markdown_timings) + set_timing_var("_pygments_timings", pygments_timings) + set_timing_var("_current_msg_uuid", "") + + for msg_idx, message in enumerate(messages): + msg_start_time = time.time() if DEBUG_TIMING else 0.0 message_type = message.type + msg_uuid = getattr(message, "uuid", f"no-uuid-{msg_idx}") + + # Update current message UUID for timing tracking + set_timing_var("_current_msg_uuid", msg_uuid) # Skip summary messages - they should already be attached to their sessions if isinstance(message, SummaryTranscriptEntry): @@ -3035,32 +3255,35 @@ def generate_html( if isinstance(tool_item, ToolUseContent) or item_type == "tool_use": # Convert Anthropic type to our format if necessary if not isinstance(tool_item, ToolUseContent): - tool_use_converted = ToolUseContent( + tool_use = ToolUseContent( type="tool_use", id=getattr(tool_item, "id", ""), name=getattr(tool_item, "name", ""), input=getattr(tool_item, "input", {}), ) else: - tool_use_converted = tool_item + tool_use = tool_item - tool_content_html = format_tool_use_content(tool_use_converted) - escaped_name = escape_html(tool_use_converted.name) - escaped_id = escape_html(tool_use_converted.id) - item_tool_use_id = tool_use_converted.id + tool_content_html = format_tool_use_content(tool_use) + escaped_name = escape_html(tool_use.name) + escaped_id = escape_html(tool_use.id) + item_tool_use_id = tool_use.id tool_title_hint = f"ID: {escaped_id}" + # 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_converted) + summary = get_tool_summary(tool_use) # Set message_type (for CSS/logic) and message_title (for display) tool_message_type = "tool_use" - if tool_use_converted.name == "TodoWrite": + if tool_use.name == "TodoWrite": tool_message_title = "πŸ“ Todo List" - elif tool_use_converted.name == "Task": + elif tool_use.name == "Task": # Special handling for Task tool: show subagent_type and description - subagent_type = tool_use_converted.input.get("subagent_type", "") - description = tool_use_converted.input.get("description", "") + subagent_type = tool_use.input.get("subagent_type", "") + description = tool_use.input.get("description", "") escaped_subagent = ( escape_html(subagent_type) if subagent_type else "" ) @@ -3075,14 +3298,14 @@ def generate_html( tool_message_title = f"πŸ”§ {escaped_name} ({escaped_subagent})" else: tool_message_title = f"πŸ”§ {escaped_name}" - elif tool_use_converted.name in ("Edit", "Write"): + 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_converted.name == "Read": + elif tool_use.name == "Read": # Use πŸ“„ icon for Read if summary: escaped_summary = escape_html(summary) @@ -3112,14 +3335,20 @@ def generate_html( result_file_path: Optional[str] = None result_tool_name: Optional[str] = None if tool_result_converted.tool_use_id in tool_use_context: - tool_ctx = tool_use_context[tool_result_converted.tool_use_id] - result_tool_name = tool_ctx.get("name") - if result_tool_name in ( - "Read", - "Edit", - "Write", - ) and "file_path" in tool_ctx.get("input", {}): - result_file_path = tool_ctx["input"]["file_path"] + tool_use_from_ctx = tool_use_context[ + tool_result_converted.tool_use_id + ] + result_tool_name = tool_use_from_ctx.name + if ( + result_tool_name + in ( + "Read", + "Edit", + "Write", + ) + and "file_path" in tool_use_from_ctx.input + ): + result_file_path = tool_use_from_ctx.input["file_path"] tool_content_html = format_tool_result_content( tool_result_converted, @@ -3249,79 +3478,22 @@ def generate_html( pending_dedup = None # Reset for next iteration - # Prepare session navigation data - session_nav: List[Dict[str, Any]] = [] - for session_id in session_order: - session_info = sessions[session_id] - - # Format timestamp range - first_ts = session_info["first_timestamp"] - last_ts = session_info["last_timestamp"] - timestamp_range = "" - if first_ts and last_ts: - if first_ts == last_ts: - timestamp_range = format_timestamp(first_ts) - else: - timestamp_range = ( - f"{format_timestamp(first_ts)} - {format_timestamp(last_ts)}" - ) - elif first_ts: - timestamp_range = format_timestamp(first_ts) - - # Format token usage summary - token_summary = "" - total_input = session_info["total_input_tokens"] - total_output = session_info["total_output_tokens"] - total_cache_creation = session_info["total_cache_creation_tokens"] - total_cache_read = session_info["total_cache_read_tokens"] + # Track message timing + if DEBUG_TIMING: + msg_duration = time.time() - msg_start_time + message_timings.append((msg_duration, message_type, msg_idx, msg_uuid)) - if total_input > 0 or total_output > 0: - token_parts: List[str] = [] - if total_input > 0: - token_parts.append(f"Input: {total_input}") - if total_output > 0: - token_parts.append(f"Output: {total_output}") - if total_cache_creation > 0: - token_parts.append(f"Cache Creation: {total_cache_creation}") - if total_cache_read > 0: - token_parts.append(f"Cache Read: {total_cache_read}") - token_summary = "Token usage – " + " | ".join(token_parts) - - session_nav.append( - { - "id": session_id, - "summary": session_info["summary"], - "timestamp_range": timestamp_range, - "first_timestamp": first_ts, - "last_timestamp": last_ts, - "message_count": session_info["message_count"], - "first_user_message": session_info["first_user_message"] - if session_info["first_user_message"] != "" - else "[No user message found in session.]", - "token_summary": token_summary, - } + # Report loop statistics + if DEBUG_TIMING: + report_timing_statistics( + message_timings, + [("Markdown", markdown_timings), ("Pygments", pygments_timings)], ) - # Identify and mark paired messages (command+output, tool_use+tool_result, etc.) - _identify_message_pairs(template_messages) - - # Reorder messages so pairs are adjacent while preserving chronological order - template_messages = _reorder_paired_messages(template_messages) - - # Mark messages that have children for fold/unfold controls - _mark_messages_with_children(template_messages) - - # Render template - env = _get_template_environment() - template = env.get_template("transcript.html") - return str( - template.render( - title=title, - messages=template_messages, - sessions=session_nav, - combined_transcript_link=combined_transcript_link, - library_version=get_library_version(), - ) + return ( + template_messages, + sessions, + session_order, ) diff --git a/claude_code_log/renderer_timings.py b/claude_code_log/renderer_timings.py new file mode 100644 index 00000000..fb5111cb --- /dev/null +++ b/claude_code_log/renderer_timings.py @@ -0,0 +1,158 @@ +"""Timing utilities for renderer performance profiling. + +This module provides timing and performance profiling utilities for the renderer. +All timing-related configuration and functionality is centralized here. +""" + +import os +import time +from contextlib import contextmanager +from typing import List, Tuple, Iterator, Any, Dict, Callable, Union, Optional + +# Performance debugging - enabled via CLAUDE_CODE_LOG_DEBUG_TIMING environment variable +# Set to "1", "true", or "yes" to enable timing output +DEBUG_TIMING = os.getenv("CLAUDE_CODE_LOG_DEBUG_TIMING", "").lower() in ( + "1", + "true", + "yes", +) + +# Global timing data storage +_timing_data: Dict[str, Any] = {} + + +def set_timing_var(name: str, value: Any) -> None: + """Set a timing variable in the global timing data dict. + + Args: + name: Variable name (e.g., "_markdown_timings", "_pygments_timings", "_current_msg_uuid") + value: Value to set + """ + if DEBUG_TIMING: + _timing_data[name] = value + + +@contextmanager +def log_timing( + phase: Union[str, Callable[[], str]], + t_start: Optional[float] = None, +) -> Iterator[None]: + """Context manager for logging phase timing. + + Args: + phase: Phase name (static string) or callable returning phase name (for dynamic names) + t_start: Optional start time for calculating total elapsed time + + Example: + # Static phase name + with log_timing("Initialization", t_start): + setup_code() + + # Dynamic phase name (evaluated at end) + with log_timing(lambda: f"Processing ({len(items)} items)", t_start): + items = process() + """ + if not DEBUG_TIMING: + yield + return + + t_phase_start = time.time() + + try: + yield + finally: + t_now = time.time() + phase_time = t_now - t_phase_start + + # Evaluate phase name (call if callable, use directly if string) + phase_name = phase() if callable(phase) else phase + + # Calculate total time if t_start provided + if t_start is not None: + total_time = t_now - t_start + print( + f"[TIMING] {phase_name:40s} {phase_time:8.3f}s (total: {total_time:8.3f}s)", + flush=True, + ) + else: + print( + f"[TIMING] {phase_name:40s} {phase_time:8.3f}s", + flush=True, + ) + + # Update last timing checkpoint + _timing_data["_t_last"] = t_now + + +@contextmanager +def timing_stat(list_name: str) -> Iterator[None]: + """Context manager for tracking timing statistics. + + Args: + list_name: Name of the timing list to append to + (e.g., "_markdown_timings", "_pygments_timings") + + Example: + with timing_stat("_pygments_timings"): + result = expensive_operation() + """ + if not DEBUG_TIMING: + yield + return + + t_start = time.time() + try: + yield + finally: + duration = time.time() - t_start + if list_name in _timing_data: + msg_uuid = _timing_data.get("_current_msg_uuid", "") + _timing_data[list_name].append((duration, msg_uuid)) + + +def report_timing_statistics( + message_timings: List[Tuple[float, str, int, str]], + operation_timings: List[Tuple[str, List[Tuple[float, str]]]], +) -> None: + """Report timing statistics for message rendering. + + Args: + message_timings: List of (duration, message_type, index, uuid) tuples + operation_timings: List of (name, timings) tuples where timings is a list of (duration, uuid) + e.g., [("Markdown", markdown_timings), ("Pygments", pygments_timings)] + """ + if not message_timings: + return + + # Sort by duration descending + sorted_timings = sorted(message_timings, key=lambda x: x[0], reverse=True) + + # Calculate statistics + total_msg_time = sum(t[0] for t in message_timings) + avg_time = total_msg_time / len(message_timings) + + # Report slowest messages + print("\n[TIMING] Loop statistics:", flush=True) + print(f"[TIMING] Total messages: {len(message_timings)}", flush=True) + print(f"[TIMING] Average time per message: {avg_time * 1000:.1f}ms", flush=True) + print("[TIMING] Slowest 10 messages:", flush=True) + for duration, msg_type, idx, uuid in sorted_timings[:10]: + print( + f"[TIMING] Message {uuid} (#{idx}, {msg_type}): {duration * 1000:.1f}ms", + flush=True, + ) + + # Report operation-specific statistics + for operation_name, timings in operation_timings: + if timings: + sorted_ops = sorted(timings, key=lambda x: x[0], reverse=True) + total_time = sum(t[0] for t in timings) + print(f"\n[TIMING] {operation_name} rendering:", flush=True) + print(f"[TIMING] Total operations: {len(timings)}", flush=True) + print(f"[TIMING] Total time: {total_time:.3f}s", flush=True) + print("[TIMING] Slowest 10 operations:", flush=True) + for duration, uuid in sorted_ops[:10]: + print( + f"[TIMING] {uuid}: {duration * 1000:.1f}ms", + flush=True, + ) diff --git a/dev-docs/FOLD_STATE_DIAGRAM.md b/dev-docs/FOLD_STATE_DIAGRAM.md index 31556320..fd3df528 100644 --- a/dev-docs/FOLD_STATE_DIAGRAM.md +++ b/dev-docs/FOLD_STATE_DIAGRAM.md @@ -43,52 +43,26 @@ The fold bar has two buttons with three possible states: ## State Transitions ``` - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ State A (β–Ά β–Άβ–Ά) β”‚ - β”‚ Nothing visible β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ β”‚ - Click β–Ά β”‚ β”‚ Click β–Άβ–Ά - (unfold 1) β”‚ β”‚ (unfold all) - β–Ό β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ State B β”‚ β”‚ State B β”‚ - β”‚ (β–Ό β–Άβ–Ά) │◄─── (β–Ό β–Άβ–Ά) β”‚ - β”‚ First β”‚ β”‚ First β”‚ - β”‚ level β”‚ β”‚ level β”‚ - β”‚ visible β”‚ β”‚ visible β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ β”‚ - Click β–Άβ–Ά β”‚ β”‚ Click β–Ό - (unfold all) β”‚ β”‚ (fold 1) - β–Ό β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ State C β”‚ β”‚ State A β”‚ - β”‚ (β–Ό β–Όβ–Ό) β”‚ β”‚ (β–Ά β–Άβ–Ά) β”‚ - β”‚ All levels β”‚ β”‚ Nothing β”‚ - β”‚ visible β”‚ β”‚ visible β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ β”‚ - Click β–Όβ–Ό β”‚ β”‚ Click β–Άβ–Ά - (fold all) β”‚ β”‚ (unfold all) - β–Ό β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ State B β”‚ β”‚ State C β”‚ - β”‚ (β–Ό β–Άβ–Ά) β”‚ β”‚ (β–Ό β–Όβ–Ό) β”‚ - β”‚ First β”‚ β”‚ All levels β”‚ - β”‚ level β”‚ β”‚ visible β”‚ - β”‚ visible β”‚ β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - Click β–Ό β”‚ - (fold 1) β”‚ - β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ State A β”‚ - β”‚ (β–Ά β–Άβ–Ά) β”‚ - β”‚ Nothing β”‚ - β”‚ visible β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Ίβ”‚ State A (β–Ά / β–Άβ–Ά) │◄────────┐ + β”‚ β”‚ Nothing visible β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ Click β–Ά β”‚ β”‚ Click β–Άβ–Ά β”‚ + β”‚ (unfold 1) β”‚ β”‚ (unfold all) β”‚ + β”‚ β–Ό β–Ό β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ State B β”‚ β”‚ State C β”‚ β”‚ + β”‚ β”‚ (β–Ό / β–Άβ–Ά) β”‚ β”‚ (β–Ό / β–Όβ–Ό) β”‚ β”‚ + β”‚ β”‚ First β”‚ β”‚ All β”‚ β”‚ + β”‚ β”‚ level β”‚ β”‚ levels β”‚ β”‚ + β”‚ β”‚ visible β”‚ β”‚ visible β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ Click β–Όβ”‚ └── β–Άβ–Ά ↔ β–Όβ–Ό β”€β”€β”˜ β”‚Click β–Ό β”‚ + β”‚ β”‚ (unfold all / fold 1) β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + (fold all) (fold all) ``` ## Simplified Transition Table diff --git a/test/test_data/edit_tool.jsonl b/test/test_data/edit_tool.jsonl new file mode 100644 index 00000000..896e9d44 --- /dev/null +++ b/test/test_data/edit_tool.jsonl @@ -0,0 +1,2 @@ +{"parentUuid":"49306c7f-62f4-41ce-b0e7-b67a891148b7","isSidechain":false,"userType":"external","cwd":"e:\\Workspace\\src\\github\\claude-code-log","sessionId":"88a8d761-7b9a-4bf1-a8ca-c1febe6bf358","version":"2.0.46","gitBranch":"dev/fold-ui-horizontal","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_01A76saVFjpeVZQmepohx5gy","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PhNkTBr17qvahqhCdkUkvM","name":"Edit","input":{"file_path":"e:\\Workspace\\src\\github\\claude-code-log\\claude_code_log\\renderer_timings.py","old_string":"def set_timing_var(name: str, value: Any) -> None:\n \"\"\"Set a timing variable in the global timing data dict.\n\n Args:\n name: Variable name (e.g., \"_markdown_timings\", \"_pygments_timings\")\n value: Value to set\n \"\"\"\n if DEBUG_TIMING:\n _timing_data[name] = value\n\n\ndef set_current_msg_uuid(uuid: str) -> None:\n \"\"\"Set the current message UUID for timing tracking.\n\n Args:\n uuid: Message UUID\n \"\"\"\n if DEBUG_TIMING:\n _timing_data[\"_current_msg_uuid\"] = uuid","new_string":"def set_timing_var(name: str, value: Any) -> None:\n \"\"\"Set a timing variable in the global timing data dict.\n\n Args:\n name: Variable name (e.g., \"_markdown_timings\", \"_pygments_timings\", \"_current_msg_uuid\")\n value: Value to set\n \"\"\"\n if DEBUG_TIMING:\n _timing_data[name] = value"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":0,"cache_creation_input_tokens":10391,"cache_read_input_tokens":102802,"cache_creation":{"ephemeral_5m_input_tokens":10391,"ephemeral_1h_input_tokens":0},"output_tokens":526,"service_tier":"standard"}},"requestId":"req_011CVRRU3KTqNrUhZ9K35Zd2","type":"assistant","uuid":"e450d9e3-ccb9-4595-a7cb-e13f13bab5a0","timestamp":"2025-11-23T20:06:04.851Z"} +{"parentUuid":"e450d9e3-ccb9-4595-a7cb-e13f13bab5a0","isSidechain":false,"userType":"external","cwd":"e:\\Workspace\\src\\github\\claude-code-log","sessionId":"88a8d761-7b9a-4bf1-a8ca-c1febe6bf358","version":"2.0.46","gitBranch":"dev/fold-ui-horizontal","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PhNkTBr17qvahqhCdkUkvM","type":"tool_result","content":"The file e:\\Workspace\\src\\github\\claude-code-log\\claude_code_log\\renderer_timings.py has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n 15β†’# Global timing data storage\n 16β†’_timing_data: Dict[str, Any] = {}\n 17β†’\n 18β†’\n 19β†’def set_timing_var(name: str, value: Any) -> None:\n 20β†’ \"\"\"Set a timing variable in the global timing data dict.\n 21β†’\n 22β†’ Args:\n 23β†’ name: Variable name (e.g., \"_markdown_timings\", \"_pygments_timings\", \"_current_msg_uuid\")\n 24β†’ value: Value to set\n 25β†’ \"\"\"\n 26β†’ if DEBUG_TIMING:\n 27β†’ _timing_data[name] = value\n 28β†’\n 29β†’\n 30β†’@contextmanager\n 31β†’def timing_stat(list_name: str) -> Iterator[None]:"}]},"uuid":"46b5acca-1860-4ae3-be78-ed3537a81d8b","timestamp":"2025-11-23T20:06:14.160Z","toolUseResult":{"filePath":"e:\\Workspace\\src\\github\\claude-code-log\\claude_code_log\\renderer_timings.py","oldString":"def set_timing_var(name: str, value: Any) -> None:\n \"\"\"Set a timing variable in the global timing data dict.\n\n Args:\n name: Variable name (e.g., \"_markdown_timings\", \"_pygments_timings\")\n value: Value to set\n \"\"\"\n if DEBUG_TIMING:\n _timing_data[name] = value\n\n\ndef set_current_msg_uuid(uuid: str) -> None:\n \"\"\"Set the current message UUID for timing tracking.\n\n Args:\n uuid: Message UUID\n \"\"\"\n if DEBUG_TIMING:\n _timing_data[\"_current_msg_uuid\"] = uuid","newString":"def set_timing_var(name: str, value: Any) -> None:\n \"\"\"Set a timing variable in the global timing data dict.\n\n Args:\n name: Variable name (e.g., \"_markdown_timings\", \"_pygments_timings\", \"_current_msg_uuid\")\n value: Value to set\n \"\"\"\n if DEBUG_TIMING:\n _timing_data[name] = value","originalFile":"\"\"\"Timing utilities for renderer performance profiling.\"\"\"\n\nimport os\nimport time\nfrom contextlib import contextmanager\nfrom typing import List, Tuple, Iterator, Any, Dict\n\n# Performance debugging\nDEBUG_TIMING = os.getenv(\"CLAUDE_CODE_LOG_DEBUG_TIMING\", \"\").lower() in (\n \"1\",\n \"true\",\n \"yes\",\n)\n\n# Global timing data storage\n_timing_data: Dict[str, Any] = {}\n\n\ndef set_timing_var(name: str, value: Any) -> None:\n \"\"\"Set a timing variable in the global timing data dict.\n\n Args:\n name: Variable name (e.g., \"_markdown_timings\", \"_pygments_timings\")\n value: Value to set\n \"\"\"\n if DEBUG_TIMING:\n _timing_data[name] = value\n\n\ndef set_current_msg_uuid(uuid: str) -> None:\n \"\"\"Set the current message UUID for timing tracking.\n\n Args:\n uuid: Message UUID\n \"\"\"\n if DEBUG_TIMING:\n _timing_data[\"_current_msg_uuid\"] = uuid\n\n\n@contextmanager\ndef timing_stat(list_name: str) -> Iterator[None]:\n \"\"\"Context manager for tracking timing statistics.\n\n Args:\n list_name: Name of the timing list to append to\n (e.g., \"_markdown_timings\", \"_pygments_timings\")\n\n Example:\n with timing_stat(\"_pygments_timings\"):\n result = expensive_operation()\n \"\"\"\n if not DEBUG_TIMING:\n yield\n return\n\n t_start = time.time()\n try:\n yield\n finally:\n duration = time.time() - t_start\n if list_name in _timing_data:\n msg_uuid = _timing_data.get(\"_current_msg_uuid\", \"\")\n _timing_data[list_name].append((duration, msg_uuid))\n\n\ndef report_timing_statistics(\n message_timings: List[Tuple[float, str, int, str]],\n markdown_timings: List[Tuple[float, str]],\n pygments_timings: List[Tuple[float, str]],\n) -> None:\n \"\"\"Report timing statistics for message rendering.\n\n Args:\n message_timings: List of (duration, message_type, index, uuid) tuples\n markdown_timings: List of (duration, uuid) tuples for markdown rendering\n pygments_timings: List of (duration, uuid) tuples for Pygments highlighting\n \"\"\"\n if not message_timings:\n return\n\n # Sort by duration descending\n sorted_timings = sorted(message_timings, key=lambda x: x[0], reverse=True)\n\n # Calculate statistics\n total_msg_time = sum(t[0] for t in message_timings)\n avg_time = total_msg_time / len(message_timings)\n\n # Report slowest messages\n print(\"\\n[TIMING] Loop statistics:\", flush=True)\n print(f\"[TIMING] Total messages: {len(message_timings)}\", flush=True)\n print(f\"[TIMING] Average time per message: {avg_time * 1000:.1f}ms\", flush=True)\n print(\"[TIMING] Slowest 10 messages:\", flush=True)\n for duration, msg_type, idx, uuid in sorted_timings[:10]:\n print(\n f\"[TIMING] Message {uuid} (#{idx}, {msg_type}): {duration * 1000:.1f}ms\",\n flush=True,\n )\n\n # Report markdown rendering statistics\n if markdown_timings:\n sorted_markdown = sorted(markdown_timings, key=lambda x: x[0], reverse=True)\n total_markdown_time = sum(t[0] for t in markdown_timings)\n print(f\"\\n[TIMING] Markdown rendering:\", flush=True)\n print(f\"[TIMING] Total operations: {len(markdown_timings)}\", flush=True)\n print(f\"[TIMING] Total time: {total_markdown_time:.3f}s\", flush=True)\n print(f\"[TIMING] Slowest 10 operations:\", flush=True)\n for duration, uuid in sorted_markdown[:10]:\n print(\n f\"[TIMING] {uuid}: {duration * 1000:.1f}ms\",\n flush=True,\n )\n\n # Report Pygments highlighting statistics\n if pygments_timings:\n sorted_pygments = sorted(pygments_timings, key=lambda x: x[0], reverse=True)\n total_pygments_time = sum(t[0] for t in pygments_timings)\n print(f\"\\n[TIMING] Pygments highlighting:\", flush=True)\n print(f\"[TIMING] Total operations: {len(pygments_timings)}\", flush=True)\n print(f\"[TIMING] Total time: {total_pygments_time:.3f}s\", flush=True)\n print(f\"[TIMING] Slowest 10 operations:\", flush=True)\n for duration, uuid in sorted_pygments[:10]:\n print(\n f\"[TIMING] {uuid}: {duration * 1000:.1f}ms\",\n flush=True,\n )\n","structuredPatch":[{"oldStart":20,"oldLines":23,"newStart":20,"newLines":13,"lines":[" \"\"\"Set a timing variable in the global timing data dict."," "," Args:","- name: Variable name (e.g., \"_markdown_timings\", \"_pygments_timings\")","+ name: Variable name (e.g., \"_markdown_timings\", \"_pygments_timings\", \"_current_msg_uuid\")"," value: Value to set"," \"\"\""," if DEBUG_TIMING:"," _timing_data[name] = value"," "," ","-def set_current_msg_uuid(uuid: str) -> None:","- \"\"\"Set the current message UUID for timing tracking.","-","- Args:","- uuid: Message UUID","- \"\"\"","- if DEBUG_TIMING:","- _timing_data[\"_current_msg_uuid\"] = uuid","-","-"," @contextmanager"," def timing_stat(list_name: str) -> Iterator[None]:"," \"\"\"Context manager for tracking timing statistics."]}],"userModified":false,"replaceAll":false}} \ No newline at end of file diff --git a/test/test_preview_truncation.py b/test/test_preview_truncation.py new file mode 100644 index 00000000..0ccb45c4 --- /dev/null +++ b/test/test_preview_truncation.py @@ -0,0 +1,129 @@ +"""Tests for code preview truncation in tool results. + +Regression test for the bug where Pygments highlighted code previews +weren't truncated because the code assumed multiple rows per line, +but HtmlFormatter(linenos="table") produces a single with two s. +""" + +from pathlib import Path + +from claude_code_log.parser import load_transcript +from claude_code_log.renderer import generate_html, _truncate_highlighted_preview + + +class TestPreviewTruncation: + """Tests for preview truncation in collapsible code blocks.""" + + def test_truncate_highlighted_preview_function(self): + """Test the _truncate_highlighted_preview helper directly.""" + # Simulate Pygments output with 10 lines + html = """
 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+10
line 1
+line 2
+line 3
+line 4
+line 5
+line 6
+line 7
+line 8
+line 9
+line 10
+
""" + + # Truncate to 5 lines + result = _truncate_highlighted_preview(html, 5) + + # Should have lines 1-5 in linenos + assert ' 1' in result + assert ' 5' in result + # Should NOT have lines 6-10 + assert ' 6' not in result + assert '10' not in result + + # Should have lines 1-5 in code + assert "line 1" in result + assert "line 5" in result + # Should NOT have lines 6-10 + assert "line 6" not in result + assert "line 10" not in result + + def test_edit_tool_result_preview_truncation(self): + """Test that Edit tool results have truncated previews in collapsible blocks. + + Regression test for: Preview extraction was looking for multiple tags, + but Pygments produces a single with two s, so the fallback showed + full content instead of truncated preview. + """ + test_data_path = Path(__file__).parent / "test_data" / "edit_tool.jsonl" + + messages = load_transcript(test_data_path) + html = generate_html(messages, "Edit Tool Test") + + # The Edit tool result has 17 lines (>12), so should be collapsible + assert "collapsible-code" in html, "Should have collapsible code block" + + # Find the preview content section + assert "preview-content" in html, "Should have preview content" + + # The preview should only show first 5 lines, not all 17 + # Line 15 is "_timing_data: Dict[str, Any] = {}" - should be in preview + # Line 31 is 'def timing_stat(list_name: str) -> Iterator[None]:"' - should NOT be in preview + + # Extract preview content (between preview-content div tags) + import re + + preview_match = re.search( + r"
(.*?)
\s*", + html, + re.DOTALL, + ) + assert preview_match, "Should find preview-content div" + preview_html = preview_match.group(1) + + # Preview should have early lines (within first 5) + # Line 15 (line 1 of snippet): "_timing_data" + assert "_timing_data" in preview_html, "Preview should contain line 15 content" + + # Preview should NOT have later lines (beyond first 5) + # Line 26 (line 12 of snippet): "if DEBUG_TIMING:" + # Note: Pygments wraps tokens in tags, so check for identifier + assert "DEBUG_TIMING" not in preview_html, ( + "Preview should NOT contain line 26 content (beyond 5 lines)" + ) + + # Line 30-31 (line 16-17 of snippet): "@contextmanager" + assert "contextmanager" not in preview_html, ( + "Preview should NOT contain line 30 content (beyond 5 lines)" + ) + + def test_full_content_still_available(self): + """Test that full content is still available in the expanded section.""" + test_data_path = Path(__file__).parent / "test_data" / "edit_tool.jsonl" + + messages = load_transcript(test_data_path) + html = generate_html(messages, "Edit Tool Test") + + # The full content section should have all lines + import re + + full_match = re.search( + r"
(.*?)
\s*
", + html, + re.DOTALL, + ) + assert full_match, "Should find code-full div" + full_html = full_match.group(1) + + # Full content should have both early and late lines + # Note: Pygments wraps tokens in tags, so we check for the identifier + assert "_timing_data" in full_html, "Full content should contain line 15" + assert "DEBUG_TIMING" in full_html, "Full content should contain line 26" + assert "contextmanager" in full_html, "Full content should contain line 30" From 34a931cb372ee0b580476096258814dcc9266adf Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 26 Nov 2025 19:39:48 +0100 Subject: [PATCH 03/20] Document performance profiling in CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "Performance Profiling" section explaining how to use the CLAUDE_CODE_LOG_DEBUG_TIMING environment variable to enable timing instrumentation for identifying performance bottlenecks. Also add renderer_timings.py to File Structure section. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 0e193d92..2283b125 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,7 @@ claude-code-log /path/to/directory --from-date "3 days ago" --to-date "yesterday - `claude_code_log/parser.py` - Data extraction and parsing from JSONL files - `claude_code_log/renderer.py` - HTML generation and template rendering +- `claude_code_log/renderer_timings.py` - Performance timing instrumentation - `claude_code_log/converter.py` - High-level conversion orchestration - `claude_code_log/cli.py` - Command-line interface with project discovery - `claude_code_log/models.py` - Pydantic models for transcript data structures @@ -219,6 +220,45 @@ HTML coverage reports are generated in `htmlcov/index.html`. - **Lint and fix**: `ruff check --fix` - **Type checking**: `uv run pyright` and `uv run ty check` +### Performance Profiling + +Enable timing instrumentation to identify performance bottlenecks: + +```bash +# Enable timing output +CLAUDE_CODE_LOG_DEBUG_TIMING=1 claude-code-log path/to/file.jsonl + +# Or export for a session +export CLAUDE_CODE_LOG_DEBUG_TIMING=1 +claude-code-log path/to/file.jsonl +``` + +This outputs detailed timing for each rendering phase: + +``` +[TIMING] Initialization 0.001s (total: 0.001s) +[TIMING] Deduplication (1234 messages) 0.050s (total: 0.051s) +[TIMING] Session summary processing 0.012s (total: 0.063s) +[TIMING] Main message processing loop 5.234s (total: 5.297s) +[TIMING] Template rendering (30MB chars) 15.432s (total: 20.729s) + +[TIMING] Loop statistics: +[TIMING] Total messages: 1234 +[TIMING] Average time per message: 4.2ms +[TIMING] Slowest 10 messages: +[TIMING] Message abc-123 (#42, assistant): 245.3ms +[TIMING] ... + +[TIMING] Pygments highlighting: +[TIMING] Total operations: 89 +[TIMING] Total time: 1.234s +[TIMING] Slowest 10 operations: +[TIMING] def-456: 50.2ms +[TIMING] ... +``` + +The timing module is in `claude_code_log/renderer_timings.py`. + ### Testing & Style Guide - **Unit and Integration Tests**: See [test/README.md](test/README.md) for comprehensive testing documentation From e709a3bec447f5a01bc6acc267d56ee86ea7a8c5 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 18 Nov 2025 10:42:23 +0100 Subject: [PATCH 04/20] Add support for queue-operation 'remove' messages as steering user input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'remove' to valid queue operation types in QueueOperationTranscriptEntry - Skip only enqueue/dequeue operations (duplicates), render 'remove' as user messages - Render 'remove' operations with 'steering' CSS class (dimmed orange) - Handle queue operations' content field (not in message.message like user/assistant) - Add CSS styling for .user.steering with opacity: 0.7 for dimmed appearance 'remove' operations are out-of-band user inputs made while agent is working for "steering" purposes, distinct from enqueue/dequeue which are internal message queueing operations. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/models.py | 9 +++-- claude_code_log/renderer.py | 40 +++++++++++++++---- .../templates/components/message_styles.css | 6 +++ 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 888fa8c9..0d69b021 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -228,14 +228,17 @@ class SystemTranscriptEntry(BaseTranscriptEntry): class QueueOperationTranscriptEntry(BaseModel): - """Queue operations (enqueue/dequeue) for message queueing tracking. + """Queue operations (enqueue/dequeue/remove) for message queueing tracking. - These are internal operations that track when messages are queued and dequeued. + enqueue/dequeue are internal operations that track when messages are queued and dequeued. They are parsed but not rendered, as the content duplicates actual user messages. + + 'remove' operations are out-of-band user inputs made visible to the agent while working + for "steering" purposes. These should be rendered as user messages with a 'steering' CSS class. """ type: Literal["queue-operation"] - operation: Literal["enqueue", "dequeue"] + operation: Literal["enqueue", "dequeue", "remove"] timestamp: str sessionId: str content: Optional[List[ContentItem]] = None # Only present for enqueue operations diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 7a87fdee..3270f4ca 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -2869,9 +2869,12 @@ def _process_messages_loop( if isinstance(message, SummaryTranscriptEntry): continue - # Skip queue-operation messages - they duplicate user messages + # Skip enqueue/dequeue queue operations - they duplicate user messages + # But render 'remove' operations as steering user messages if isinstance(message, QueueOperationTranscriptEntry): - continue + if message.operation in ("enqueue", "dequeue"): + continue + # 'remove' operations fall through to be rendered as user messages # Handle system messages separately if isinstance(message, SystemTranscriptEntry): @@ -2973,9 +2976,17 @@ def _process_messages_loop( template_messages.append(system_template_message) continue - # Extract message content first to check for duplicates - # Must be UserTranscriptEntry or AssistantTranscriptEntry - message_content = message.message.content # type: ignore + # Handle queue-operation 'remove' messages as user messages + if isinstance(message, QueueOperationTranscriptEntry): + # 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" + else: + # Extract message content first to check for duplicates + # Must be UserTranscriptEntry or AssistantTranscriptEntry + message_content = message.message.content # type: ignore + text_content = extract_text_content(message_content) # Separate tool/thinking/image content from text content @@ -3183,13 +3194,28 @@ def _process_messages_loop( text_content ) else: - css_class, content_html, message_type, message_title = ( + # For queue-operation messages, treat them as user messages + if isinstance(message, QueueOperationTranscriptEntry): + effective_type = "user" + else: + effective_type = message_type + + css_class, content_html, message_type_result, message_title = ( _process_regular_message( text_only_content, - message_type, + effective_type, getattr(message, "isSidechain", False), ) ) + message_type = message_type_result # Update message_type with result + + # Add 'steering' CSS class for queue-operation 'remove' messages + if ( + isinstance(message, QueueOperationTranscriptEntry) + and message.operation == "remove" + ): + css_class = f"{css_class} steering" + message_title = "User (steering)" # Only create main message if it has text content # For assistant/thinking with only tools (no text), we don't create a container message diff --git a/claude_code_log/templates/components/message_styles.css b/claude_code_log/templates/components/message_styles.css index d047b4c6..fda3a05c 100644 --- a/claude_code_log/templates/components/message_styles.css +++ b/claude_code_log/templates/components/message_styles.css @@ -298,6 +298,12 @@ border-left-color: #ff9800; } +/* Steering user messages (out-of-band input while agent is working) */ +.user.steering { + border-left-color: #ff9800; + opacity: 0.7; +} + .assistant { border-left-color: #9c27b0; } From 7bbcbec297634e952fe1b826ed3b94c573f2da9e Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 18 Nov 2025 10:42:41 +0100 Subject: [PATCH 05/20] Fix off-by-one error in JSONL line number reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use enumerate(f, 1) instead of enumerate(f) to start counting from 1, matching how text editors number lines. This fixes the discrepancy where the error message would report line 463 when the editor shows line 464. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py index 80d3888c..3dedd216 100644 --- a/claude_code_log/parser.py +++ b/claude_code_log/parser.py @@ -159,7 +159,7 @@ def load_transcript( with open(jsonl_path, "r", encoding="utf-8", errors="replace") as f: if not silent: print(f"Processing {jsonl_path}...") - for line_no, line in enumerate(f): + for line_no, line in enumerate(f, 1): # Start counting from 1 line = line.strip() if line: try: From 65d970bf13e71f26b0a09ca621341522ec18448e Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 18 Nov 2025 16:33:32 +0100 Subject: [PATCH 06/20] Fix image handling in steering messages and use CSS variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix images in queue-operation steering messages to stay inline with text content, matching behavior of regular user messages - Add --user-dimmed CSS variable (#ff980066) for consistent theming - Update .user.steering to use var(--user-dimmed) instead of hardcoded color Previously, images in steering messages were extracted as separate tool items because the check at line 2742 only looked for message_type == "user", but queue operations had type "queue-operation" at that point. Now the condition checks for both user messages and QueueOperationTranscriptEntry instances. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 8 ++++++-- claude_code_log/templates/components/global_styles.css | 1 + claude_code_log/templates/components/message_styles.css | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 3270f4ca..2813513b 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -3005,8 +3005,12 @@ def _process_messages_loop( (ToolUseContent, ToolResultContent, ThinkingContent), ) or item_type in ("tool_use", "tool_result", "thinking") - # Keep images inline for user messages, extract for assistant messages - if is_image and message_type == "user": + # Keep images inline for user messages and queue operations (steering), + # extract for assistant messages + if is_image and ( + message_type == "user" + or isinstance(message, QueueOperationTranscriptEntry) + ): text_only_items.append(item) elif is_tool_item or is_image: tool_items.append(item) diff --git a/claude_code_log/templates/components/global_styles.css b/claude_code_log/templates/components/global_styles.css index 7a55d06f..aaa57e52 100644 --- a/claude_code_log/templates/components/global_styles.css +++ b/claude_code_log/templates/components/global_styles.css @@ -31,6 +31,7 @@ /* Solid colors for message types */ --user-color: #ff9800; + --user-dimmed: #ff980066; --assistant-color: #9c27b0; --system-color: #d98100; --system-warning-color: #2196f3; diff --git a/claude_code_log/templates/components/message_styles.css b/claude_code_log/templates/components/message_styles.css index fda3a05c..6bbb3e7b 100644 --- a/claude_code_log/templates/components/message_styles.css +++ b/claude_code_log/templates/components/message_styles.css @@ -300,7 +300,7 @@ /* Steering user messages (out-of-band input while agent is working) */ .user.steering { - border-left-color: #ff9800; + border-left-color: var(--user-dimmed); opacity: 0.7; } From 9291153db81b82b274e316e8aff759de7de8e85d Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 18 Nov 2025 16:53:22 +0100 Subject: [PATCH 07/20] Note that queue-operation 'remove' also has content --- claude_code_log/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 0d69b021..ad59c586 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -419,7 +419,7 @@ def parse_transcript_entry(data: Dict[str, Any]) -> TranscriptEntry: return SystemTranscriptEntry.model_validate(data) elif entry_type == "queue-operation": - # Parse content if present (only in enqueue operations) + # Parse content if present (in enqueue and remove operations) data_copy = data.copy() if "content" in data_copy and isinstance(data_copy["content"], list): data_copy["content"] = parse_message_content(data_copy["content"]) From 098f2b8627882e90f0757ea48fa7a25f76fbefd6 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 18 Nov 2025 16:55:21 +0100 Subject: [PATCH 08/20] Fix pyright type checking errors for QueueOperationTranscriptEntry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add explicit isinstance checks to exclude QueueOperationTranscriptEntry before accessing message.message.content attribute. This fixes type narrowing issues at lines 2789 and 2841 where pyright couldn't determine that the message wasn't a QueueOperationTranscriptEntry. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 2813513b..27a9686a 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -3050,6 +3050,7 @@ def _process_messages_loop( first_user_message = "" if ( message_type == "user" + and not isinstance(message, QueueOperationTranscriptEntry) and hasattr(message, "message") and should_use_as_session_starter(text_content) ): @@ -3104,7 +3105,9 @@ def _process_messages_loop( # 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"]: - if hasattr(message, "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( From 6f5e68de213b85f41443beea16d5b202be286ca6 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 26 Nov 2025 22:41:51 +0100 Subject: [PATCH 09/20] Skip file-history-snapshot messages silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These are internal Claude Code messages for file backup metadata that we don't need to render. Silently skip instead of logging as unrecognized message type. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/parser.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py index 3dedd216..a34ca5af 100644 --- a/claude_code_log/parser.py +++ b/claude_code_log/parser.py @@ -201,6 +201,14 @@ def load_transcript( # Parse using Pydantic models entry = parse_transcript_entry(entry_dict) messages.append(entry) + elif ( + entry_type + in [ + "file-history-snapshot", # Internal Claude Code file backup metadata + ] + ): + # Silently skip internal message types we don't render + pass else: print( f"Line {line_no} of {jsonl_path} is not a recognised message type: {line}" From 2d8a1540e96bf6b69e0c6691ff8ae07b5a0bc5db Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 26 Nov 2025 22:44:02 +0100 Subject: [PATCH 10/20] Allow string content in QueueOperationTranscriptEntry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'remove' operation can have a simple string content like 'wait...' instead of a List[ContentItem]. Update the model to accept both formats. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index ad59c586..a50c2de4 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -235,13 +235,16 @@ class QueueOperationTranscriptEntry(BaseModel): 'remove' operations are out-of-band user inputs made visible to the agent while working for "steering" purposes. These should be rendered as user messages with a 'steering' CSS class. + Content can be a list of ContentItems or a simple string (for 'remove' operations). """ type: Literal["queue-operation"] operation: Literal["enqueue", "dequeue", "remove"] timestamp: str sessionId: str - content: Optional[List[ContentItem]] = None # Only present for enqueue operations + content: Optional[Union[List[ContentItem], str]] = ( + None # List for enqueue, str for remove + ) TranscriptEntry = Union[ From 593b6839462bd14e813524c46e6804528305e5df Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 26 Nov 2025 22:52:34 +0100 Subject: [PATCH 11/20] Add popAll operation to QueueOperationTranscriptEntry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support the 'popAll' queue operation (treated as no-op like dequeue). πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index a50c2de4..e2149402 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -239,11 +239,11 @@ class QueueOperationTranscriptEntry(BaseModel): """ type: Literal["queue-operation"] - operation: Literal["enqueue", "dequeue", "remove"] + operation: Literal["enqueue", "dequeue", "remove", "popAll"] timestamp: str sessionId: str content: Optional[Union[List[ContentItem], str]] = ( - None # List for enqueue, str for remove + None # List for enqueue, str for remove/popAll ) From c5441a25db216f851945a4dff312a2b09064f83e Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 26 Nov 2025 23:08:44 +0100 Subject: [PATCH 12/20] Sneak in change of default font-family (now --font-ui) --- claude_code_log/templates/components/global_styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude_code_log/templates/components/global_styles.css b/claude_code_log/templates/components/global_styles.css index aaa57e52..e46b10e5 100644 --- a/claude_code_log/templates/components/global_styles.css +++ b/claude_code_log/templates/components/global_styles.css @@ -51,7 +51,7 @@ } body { - font-family: var(--font-monospace); + font-family: var(--font-ui); line-height: 1.5; max-width: 1200px; margin: 0 auto; From 0221307deaead93fd528c0d6f3a141889eb9a554 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 27 Nov 2025 00:00:56 +0100 Subject: [PATCH 13/20] Remove redundant Sub-assistant prompt (sidechain user) messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidechain user messages duplicate the Task tool input prompt, so they're now skipped entirely during rendering. This simplifies the output by showing the prompt only once (in the Task tool content). Changes: - Skip sidechain user messages in main processing loop - Remove sidechain user handling from _process_regular_message - Remove sidechain user level from _get_message_hierarchy_level - Update timeline.html to remove user case from sidechain handling - Update message_styles.css to note .sidechain.user no longer produced - Update browser test to expect no user sidechain messages πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 64 +++++++++---------- .../templates/components/message_styles.css | 9 +-- .../templates/components/timeline.html | 6 +- test/test_timeline_browser.py | 26 +++----- 4 files changed, 47 insertions(+), 58 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 27a9686a..2d87f924 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -837,8 +837,8 @@ def _render_line_diff(old_line: str, new_line: str) -> str: 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 - (like it would appear in the "Sub-assistant prompt" message). + Task tool spawns sub-agents. We render the prompt as the main content. + The sidechain user message (which would duplicate this prompt) is skipped. """ prompt = tool_use.input.get("prompt", "") @@ -2230,40 +2230,35 @@ def _process_regular_message( message_type: str, is_sidechain: bool, ) -> tuple[str, str, str, str]: - """Process regular message and return (css_class, content_html, message_type, message_title).""" + """Process regular message and return (css_class, 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. + """ css_class = f"{message_type}" message_title = message_type.title() # Default title + is_compacted = False # Handle user-specific preprocessing if message_type == "user": - # Sub-assistant prompts (sidechain user messages) should be rendered as markdown - if is_sidechain: - content_html = render_message_content(text_only_content, "assistant") - is_compacted = False - is_memory_input = False - else: - content_html, is_compacted, is_memory_input = render_user_message_content( - text_only_content - ) - if is_compacted: - css_class = f"{message_type} compacted" - message_title = "User (compacted conversation)" - elif is_memory_input: - message_title = "Memory" + # Note: sidechain user messages are skipped before reaching this function + content_html, is_compacted, is_memory_input = render_user_message_content( + text_only_content + ) + if is_compacted: + css_class = f"{message_type} compacted" + message_title = "User (compacted conversation)" + elif is_memory_input: + message_title = "Memory" else: # Non-user messages: render directly content_html = render_message_content(text_only_content, message_type) - is_compacted = False if is_sidechain: css_class = f"{css_class} sidechain" - # Update message title for display - if not is_compacted: # Don't override compacted message title - message_title = ( - "πŸ“ Sub-assistant prompt" - if message_type == "user" - else "πŸ”— Sub-assistant" - ) + # 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 @@ -2481,14 +2476,18 @@ def _get_message_hierarchy_level(css_class: str, is_sidechain: bool) -> int: - Level 0: Session headers - Level 1: User messages - Level 2: System messages, Assistant, Thinking - - Level 3: Tool use/result (nested under assistant), Sidechain user (sub-assistant prompt) - - Level 4: Sidechain assistant/thinking (nested under sidechain user) + - Level 3: Tool use/result (nested under assistant) + - Level 4: Sidechain assistant/thinking (nested under Task tool result) - Level 5: Sidechain tools (nested under sidechain assistant) + Note: Sidechain user messages (Sub-assistant prompts) are now skipped entirely + since they duplicate the Task tool input prompt. + Returns: Integer hierarchy level (1-5, session headers are 0) """ # 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: return 1 @@ -2496,11 +2495,7 @@ def _get_message_hierarchy_level(css_class: str, is_sidechain: bool) -> int: if "system" in css_class and not is_sidechain: return 2 - # Sidechain user (sub-assistant prompt) at level 3 (conceptually under Tool use that spawned it) - if is_sidechain and "user" in css_class: - return 3 - - # Sidechain assistant/thinking at level 4 + # Sidechain assistant/thinking at level 4 (nested under Task tool result) if is_sidechain and ("assistant" in css_class or "thinking" in css_class): return 4 @@ -2865,6 +2860,11 @@ def _process_messages_loop( # Update current message UUID for timing tracking set_timing_var("_current_msg_uuid", msg_uuid) + # Skip sidechain user messages (Sub-assistant prompts) + # These duplicate the Task tool input prompt and are redundant + if message_type == "user" and getattr(message, "isSidechain", False): + continue + # Skip summary messages - they should already be attached to their sessions if isinstance(message, SummaryTranscriptEntry): continue diff --git a/claude_code_log/templates/components/message_styles.css b/claude_code_log/templates/components/message_styles.css index 6bbb3e7b..7f44886e 100644 --- a/claude_code_log/templates/components/message_styles.css +++ b/claude_code_log/templates/components/message_styles.css @@ -214,13 +214,10 @@ } /* Sidechain messages (sub-assistant hierarchy) */ -/* Sub-assistant prompt at same level as Task tool (2em) */ -.sidechain.user { - margin-left: 2em; - margin-right: 6em; -} +/* Note: .sidechain.user (Sub-assistant prompt) is no longer produced + since it duplicates the Task tool input prompt */ -/* Sub-assistant response and thinking (nested below prompt) */ +/* Sub-assistant response and thinking (nested under Task tool result) */ .sidechain.assistant, .sidechain.thinking { margin-left: 4em; diff --git a/claude_code_log/templates/components/timeline.html b/claude_code_log/templates/components/timeline.html index 602508c0..7931efeb 100644 --- a/claude_code_log/templates/components/timeline.html +++ b/claude_code_log/templates/components/timeline.html @@ -133,14 +133,14 @@ let displayContent = content ?? messageTypeGroups[messageType].content; // Check for sidechain context regardless of primary message type + // Note: Sidechain user messages (Sub-assistant prompts) are now skipped + // since they duplicate the Task tool input prompt if (classList.includes('sidechain')) { // Override group for sidechain messages, but preserve the content messageType = 'sidechain'; // For sidechain messages, prefix with appropriate icon based on original type - if (classList.includes('user')) { - displayContent = 'πŸ“ ' + (content ?? 'Sub-assistant prompt'); - } else if (classList.includes('assistant')) { + if (classList.includes('assistant')) { displayContent = 'πŸ”— ' + (content ?? 'Sub-assistant response'); } else if (classList.includes('tool_use')) { displayContent = 'πŸ”— ' + (content ?? 'Sub-assistant tool use'); diff --git a/test/test_timeline_browser.py b/test/test_timeline_browser.py index 4063b173..9eff5336 100644 --- a/test/test_timeline_browser.py +++ b/test/test_timeline_browser.py @@ -287,18 +287,23 @@ def test_sidechain_message_filtering_integration(self, page: Page): @pytest.mark.browser def test_sidechain_messages_html_css_classes(self, page: Page): - """Test that sidechain messages in the main content have correct CSS classes.""" + """Test that sidechain messages in the main content have correct CSS classes. + + Note: User sidechain messages (Sub-assistant prompts) are now skipped + since they duplicate the Task tool input prompt. + """ sidechain_file = Path("test/test_data/sidechain.jsonl") messages = load_transcript(sidechain_file) temp_file = self._create_temp_html(messages, "Sidechain CSS Classes Test") page.goto(f"file://{temp_file}") - # Check for sub-assistant user messages in main content + # User sidechain messages should no longer be produced + # (they duplicate the Task tool input prompt) user_sidechain_messages = page.locator(".message.user.sidechain") user_count = user_sidechain_messages.count() - assert user_count > 0, ( - "Should have user sidechain messages with 'user sidechain' classes" + assert user_count == 0, ( + "User sidechain messages should no longer be produced (duplicates Task tool input)" ) # Check for sub-assistant assistant messages in main content @@ -308,19 +313,6 @@ def test_sidechain_messages_html_css_classes(self, page: Page): "Should have assistant sidechain messages with 'assistant sidechain' classes" ) - # Verify that we found the expected sidechain message types - assert user_count > 0 and assistant_count > 0, ( - f"Should have both user ({user_count}) and assistant ({assistant_count}) sidechain messages" - ) - - # Check that the specific failing test message has the right classes - failing_test_message = page.locator( - '.message.user.sidechain:has-text("failing test")' - ) - assert failing_test_message.count() > 0, ( - "Sub-assistant prompt about failing test should have 'user sidechain' classes" - ) - @pytest.mark.browser def test_sidechain_filter_complete_integration(self, page: Page): """Test complete integration of sidechain filtering between main content and timeline.""" From 877c2c0ddec924577fbe80ff5061981f67dfac26 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 27 Nov 2025 00:09:09 +0100 Subject: [PATCH 14/20] Fix missing icon for steering user messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed icon selection from exact match (css_class == 'user') to prefix match (css_class.startswith('user')) so messages with additional modifiers like 'user steering' still get the user icon. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/templates/transcript.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/claude_code_log/templates/transcript.html b/claude_code_log/templates/transcript.html index 3f209d25..c72f4866 100644 --- a/claude_code_log/templates/transcript.html +++ b/claude_code_log/templates/transcript.html @@ -106,12 +106,12 @@

πŸ” Search & Filter

{% if message.message_title %}{% if message.message_title == 'Memory' %}πŸ’­ {% - elif message.css_class == 'user' %}🀷 {% - elif message.css_class == 'assistant' %}πŸ€– {% + elif message.css_class.startswith('user') %}🀷 {% + elif message.css_class.startswith('assistant') %}πŸ€– {% elif message.css_class == 'system' %}βš™οΈ {% - elif message.css_class == 'tool_use' and not starts_with_emoji(message.message_title) %}πŸ› οΈ {% - elif message.css_class == 'tool_result' %}🧰 {% - elif message.css_class == 'thinking' %}πŸ’­ {% + elif message.css_class.startswith('tool_use') and not starts_with_emoji(message.message_title) %}πŸ› οΈ {% + elif message.css_class.startswith('tool_result') %}🧰 {% + elif message.css_class.startswith('thinking') %}πŸ’­ {% elif message.css_class == 'image' %}πŸ–ΌοΈ {% endif %}{{ message.message_title | safe }}{% endif %}
From af57ebf0dcbb89d62fe3497ba7dafe87b3ec789c Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 27 Nov 2025 00:23:13 +0100 Subject: [PATCH 15/20] Fix browser tests for sidechain user message removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since sidechain user messages are now skipped (they duplicate Task tool input), the test data sidechain.jsonl no longer has any regular user messages. This caused filter buttons with 0 count to be hidden, breaking tests that tried to click them. Changes: - Update _wait_for_timeline_loaded to optionally skip waiting for items - Replace user filter with sidechain/assistant filters in affected tests - Add visibility checks before clicking filter buttons (hidden when count=0) - Update test_filter_query_param_filters_messages to test sidechain+assistant πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- test/test_query_params_browser.py | 33 +++++---- test/test_timeline_browser.py | 113 ++++++++++++++++++------------ 2 files changed, 86 insertions(+), 60 deletions(-) diff --git a/test/test_query_params_browser.py b/test/test_query_params_browser.py index aa181e63..d3bb13aa 100644 --- a/test/test_query_params_browser.py +++ b/test/test_query_params_browser.py @@ -86,33 +86,36 @@ def test_filter_query_param_sets_active_toggles(self, page: Page): @pytest.mark.browser def test_filter_query_param_filters_messages(self, page: Page): - """Test that filter query parameter actually hides/shows messages.""" + """Test that filter query parameter actually hides/shows messages. + + Note: sidechain.jsonl only has sidechain user messages which are now skipped + (they duplicate Task tool input). Test with sidechain+assistant filters instead. + """ sidechain_file = Path("test/test_data/sidechain.jsonl") messages = load_transcript(sidechain_file) temp_file = self._create_temp_html(messages, "Query Param Filtering Test") - # Load page with user and sidechain filters active - # (sidechain messages require both sidechain AND their type filter) - page.goto(f"file://{temp_file}?filter=user,sidechain") + # Load page with sidechain and assistant filters active + page.goto(f"file://{temp_file}?filter=sidechain,assistant") # Wait for page to load and filters to apply page.wait_for_load_state("networkidle") # Wait for filter application by checking for filtered-hidden class to be applied page.wait_for_selector( - ".message.assistant.filtered-hidden", state="attached", timeout=5000 + ".message.tool_use.filtered-hidden", state="attached", timeout=5000 ) - # Only user messages should be visible - visible_user_messages = page.locator(".message.user:not(.filtered-hidden)") - user_count = visible_user_messages.count() - assert user_count > 0, "User messages should be visible" - - # Assistant messages should be hidden - visible_assistant_messages = page.locator( - ".message.assistant:not(.filtered-hidden)" + # Sidechain assistant messages should be visible + visible_sidechain_messages = page.locator( + ".message.sidechain.assistant:not(.filtered-hidden)" ) - assistant_count = visible_assistant_messages.count() - assert assistant_count == 0, "Assistant messages should be hidden" + sidechain_count = visible_sidechain_messages.count() + assert sidechain_count > 0, "Sidechain assistant messages should be visible" + + # Tool use messages should be hidden (not in filter) + visible_tool_messages = page.locator(".message.tool_use:not(.filtered-hidden)") + tool_count = visible_tool_messages.count() + assert tool_count == 0, "Tool use messages should be hidden" @pytest.mark.browser def test_no_query_params_toolbar_hidden(self, page: Page): diff --git a/test/test_timeline_browser.py b/test/test_timeline_browser.py index 9eff5336..2321e45c 100644 --- a/test/test_timeline_browser.py +++ b/test/test_timeline_browser.py @@ -38,16 +38,23 @@ def _create_temp_html(self, messages: List[TranscriptEntry], title: str) -> Path return temp_file - def _wait_for_timeline_loaded(self, page: Page): - """Wait for timeline to be fully loaded and initialized.""" + def _wait_for_timeline_loaded(self, page: Page, expect_items: bool = True): + """Wait for timeline to be fully loaded and initialized. + + Args: + page: The Playwright page object + expect_items: Whether to wait for timeline items (default True). + Set to False when filters might hide all messages. + """ # Wait for timeline container to be visible page.wait_for_selector("#timeline-container", state="attached") # Wait for vis-timeline to create its DOM elements page.wait_for_selector(".vis-timeline", timeout=10000) - # Wait for timeline items to be rendered - page.wait_for_selector(".vis-item", timeout=5000) + # Wait for timeline items to be rendered (if expected) + if expect_items: + page.wait_for_selector(".vis-item", timeout=5000) @pytest.mark.browser def test_timeline_toggle_button_exists(self, page: Page): @@ -603,18 +610,21 @@ def test_timeline_filter_synchronization(self, page: Page): # Open filter panel page.locator("#filterMessages").click() + filter_toolbar = page.locator(".filter-toolbar") + expect(filter_toolbar).to_be_visible() # Test multiple filter combinations + # Note: Filter buttons with 0 count are hidden, so we skip them test_cases = [ - ("user", '.filter-toggle[data-type="user"]'), ("assistant", '.filter-toggle[data-type="assistant"]'), ("sidechain", '.filter-toggle[data-type="sidechain"]'), ] for filter_type, selector in test_cases: - if page.locator(selector).count() > 0: + filter_toggle = page.locator(selector) + if filter_toggle.count() > 0 and filter_toggle.is_visible(): # Deselect the filter - page.locator(selector).click() + filter_toggle.click() page.wait_for_timeout(100) # Allow filters to apply # Check that main messages are filtered @@ -629,7 +639,7 @@ def test_timeline_filter_synchronization(self, page: Page): # because timeline groups messages differently) # Re-enable the filter - page.locator(selector).click() + filter_toggle.click() page.wait_for_timeout(100) # Check that messages are visible again @@ -769,18 +779,21 @@ def test_timeline_filter_edge_cases(self, page: Page): # Test rapid filter toggling page.locator("#filterMessages").click() + filter_toolbar = page.locator(".filter-toolbar") + expect(filter_toolbar).to_be_visible() - user_filter = page.locator('.filter-toggle[data-type="user"]') + # Note: Filter buttons with 0 count are hidden (e.g., user filter when sidechain.jsonl has no regular users) + sidechain_filter = page.locator('.filter-toggle[data-type="sidechain"]') assistant_filter = page.locator('.filter-toggle[data-type="assistant"]') - if user_filter.count() > 0 and assistant_filter.count() > 0: + if sidechain_filter.is_visible() and assistant_filter.is_visible(): # Rapidly toggle filters for _ in range(3): - user_filter.click() + sidechain_filter.click() page.wait_for_timeout(50) assistant_filter.click() page.wait_for_timeout(50) - user_filter.click() + sidechain_filter.click() page.wait_for_timeout(50) assistant_filter.click() page.wait_for_timeout(50) @@ -795,13 +808,13 @@ def test_timeline_filter_edge_cases(self, page: Page): page.wait_for_timeout(200) # Change filters while timeline is hidden - if user_filter.count() > 0: - user_filter.click() + if sidechain_filter.is_visible(): + sidechain_filter.click() page.wait_for_timeout(100) - # Show timeline again + # Show timeline again (may have no items if filters hide all messages) page.locator("#toggleTimeline").click() - self._wait_for_timeline_loaded(page) + self._wait_for_timeline_loaded(page, expect_items=False) # Timeline should reflect current filter state timeline_items = page.locator(".vis-item") @@ -827,22 +840,25 @@ def test_timeline_filter_performance(self, page: Page): # Open filter panel page.locator("#filterMessages").click() + filter_toolbar = page.locator(".filter-toolbar") + expect(filter_toolbar).to_be_visible() # Perform multiple filter operations in sequence start_time = page.evaluate("() => performance.now()") # Test sequence of filter operations + # Note: Filter buttons with 0 count are hidden, so check visibility operations = [ "#selectNone", "#selectAll", - '.filter-toggle[data-type="user"]', '.filter-toggle[data-type="assistant"]', '.filter-toggle[data-type="sidechain"]', ] for operation in operations: - if page.locator(operation).count() > 0: - page.locator(operation).click() + locator = page.locator(operation) + if locator.count() > 0 and locator.is_visible(): + locator.click() page.wait_for_timeout(100) end_time = page.evaluate("() => performance.now()") @@ -957,55 +973,62 @@ def test_timeline_synchronizes_with_message_filtering(self, page: Page): # Open filter panel and turn off user messages page.locator("#filterMessages").click() - user_filter = page.locator('.filter-toggle[data-type="user"]') - if user_filter.count() > 0: - user_filter.click() # Turn off user messages + filter_toolbar = page.locator(".filter-toolbar") + expect(filter_toolbar).to_be_visible() + + # Note: User filter may be hidden if sidechain.jsonl has no regular user messages + # Use sidechain filter instead for this test + sidechain_filter = page.locator('.filter-toggle[data-type="sidechain"]') + if sidechain_filter.is_visible(): + sidechain_filter.click() # Turn off sidechain messages page.wait_for_timeout(100) - # Check that user messages are hidden in main content - visible_user_messages = page.locator( - ".message.user:not(.filtered-hidden)" + # Check that sidechain messages are hidden in main content + visible_sidechain_messages = page.locator( + ".message.sidechain:not(.filtered-hidden)" ).count() - assert visible_user_messages == 0, ( - "User messages should be hidden by main filter" + assert visible_sidechain_messages == 0, ( + "Sidechain messages should be hidden by main filter" ) - # Now activate timeline + # Now activate timeline (may have no items if filters hide all messages) page.locator("#toggleTimeline").click() - self._wait_for_timeline_loaded(page) + self._wait_for_timeline_loaded(page, expect_items=False) - # Timeline should NOT contain user messages since they're filtered out + # Timeline should NOT contain sidechain messages since they're filtered out # This is the core issue - timeline might be building from all messages, not just visible ones - # Check timeline items - if the bug exists, we'll see user messages in timeline + # Check timeline items - if the bug exists, we'll see sidechain messages in timeline # even though they're filtered out in main view timeline_items = page.locator(".vis-item") timeline_count = timeline_items.count() - # Let's check if any timeline items contain user content that should be filtered + # Let's check if any timeline items contain sidechain content that should be filtered # This is tricky because we need to check the timeline's internal representation # For now, let's just verify that timeline filtering matches main filtering - # by checking if timeline shows fewer items when user filter is off + # by checking if timeline shows fewer items when sidechain filter is off - # Turn user filter back on - user_filter.click() + # Turn sidechain filter back on + sidechain_filter.click() page.wait_for_timeout(100) - # Timeline should now show more items (or same if no user messages were in timeline) - timeline_items_with_user = page.locator(".vis-item") - timeline_count_with_user = timeline_items_with_user.count() + # Timeline should now show more items (or same if no sidechain messages were in timeline) + timeline_items_with_sidechain = page.locator(".vis-item") + timeline_count_with_sidechain = timeline_items_with_sidechain.count() - # The counts should be different if user messages are properly filtered + # The counts should be different if sidechain messages are properly filtered # But this test documents the expected behavior even if it's currently broken - print(f"Timeline items without user filter: {timeline_count}") - print(f"Timeline items with user filter: {timeline_count_with_user}") + print(f"Timeline items without sidechain filter: {timeline_count}") + print( + f"Timeline items with sidechain filter: {timeline_count_with_sidechain}" + ) # The assertion here documents what SHOULD happen - # If timeline filtering works correctly, timeline_count_with_user should be >= timeline_count - # because enabling user filter should show same or more items - assert timeline_count_with_user >= timeline_count, ( - "Timeline should show same or more items when user filter is enabled" + # If timeline filtering works correctly, timeline_count_with_sidechain should be >= timeline_count + # because enabling sidechain filter should show same or more items + assert timeline_count_with_sidechain >= timeline_count, ( + "Timeline should show same or more items when sidechain filter is enabled" ) @pytest.mark.browser From 912472b897c53ab7e6ba05405eaf83dfba33969e Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 27 Nov 2025 00:37:43 +0100 Subject: [PATCH 16/20] Fix UnicodeEncodeError in style guide script on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add UTF-8 encoding for file writing and stdout to handle emoji output properly on Windows systems where the default encoding is cp1252. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- scripts/generate_style_guide.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/generate_style_guide.py b/scripts/generate_style_guide.py index c1367132..44426a8d 100755 --- a/scripts/generate_style_guide.py +++ b/scripts/generate_style_guide.py @@ -8,8 +8,13 @@ """ import json +import sys import tempfile from pathlib import Path + +# Ensure stdout uses UTF-8 for emoji output on Windows +if sys.stdout.encoding != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") from claude_code_log.converter import ( convert_jsonl_to_html, generate_projects_index_html, @@ -370,7 +375,7 @@ def generate_style_guide(): jsonl_file = temp_path / "style_guide.jsonl" # Write style guide data - with open(jsonl_file, "w") as f: + with open(jsonl_file, "w", encoding="utf-8") as f: for entry in style_guide_data: f.write(json.dumps(entry, ensure_ascii=False) + "\n") From 737c87b7fe63203b62c0daaabf7144e1e8fe7aca Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 27 Nov 2025 00:41:31 +0100 Subject: [PATCH 17/20] Add hasattr guard and type ignore for stdout.reconfigure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes ty type checker warning about possibly unbound attribute. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- scripts/generate_style_guide.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/generate_style_guide.py b/scripts/generate_style_guide.py index 44426a8d..18dc26c6 100755 --- a/scripts/generate_style_guide.py +++ b/scripts/generate_style_guide.py @@ -13,8 +13,8 @@ from pathlib import Path # Ensure stdout uses UTF-8 for emoji output on Windows -if sys.stdout.encoding != "utf-8": - sys.stdout.reconfigure(encoding="utf-8") +if sys.stdout.encoding != "utf-8" and hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr] from claude_code_log.converter import ( convert_jsonl_to_html, generate_projects_index_html, From 1762116298cf54f77585cf693ff2a2ddcb5542d8 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 27 Nov 2025 18:26:05 +0100 Subject: [PATCH 18/20] Move stdout UTF-8 reconfigure to __main__ block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only reconfigure stdout when running as a script, not when importing. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- scripts/generate_style_guide.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/generate_style_guide.py b/scripts/generate_style_guide.py index 18dc26c6..86203771 100755 --- a/scripts/generate_style_guide.py +++ b/scripts/generate_style_guide.py @@ -12,9 +12,6 @@ import tempfile from pathlib import Path -# Ensure stdout uses UTF-8 for emoji output on Windows -if sys.stdout.encoding != "utf-8" and hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr] from claude_code_log.converter import ( convert_jsonl_to_html, generate_projects_index_html, @@ -519,4 +516,7 @@ def generate_style_guide(): if __name__ == "__main__": + # Ensure stdout uses UTF-8 for emoji output when running as a script + if sys.stdout.encoding != "utf-8" and hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr] generate_style_guide() From 0dea7cd405c0e9764c98290713eb28f906cf4fd1 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 27 Nov 2025 18:27:55 +0100 Subject: [PATCH 19/20] Regenerate style guide output with latest CSS variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../style_guide_output/index_style_guide.html | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/scripts/style_guide_output/index_style_guide.html b/scripts/style_guide_output/index_style_guide.html index 5eccaf34..4f5e9bf3 100644 --- a/scripts/style_guide_output/index_style_guide.html +++ b/scripts/style_guide_output/index_style_guide.html @@ -39,17 +39,29 @@ /* Slightly transparent variants (55 = ~33% opacity) */ --highlight-light: #e3f2fd55; + /* Solid colors for message types */ + --user-color: #ff9800; + --user-dimmed: #ff980066; + --assistant-color: #9c27b0; + --system-color: #d98100; + --system-warning-color: #2196f3; + --system-error-color: #f44336; + --tool-use-color: #4caf50; + /* Solid colors for text and accents */ --text-muted: #666; --text-secondary: #495057; + /* Layout spacing */ + --message-padding: 1em; + /* Font families */ --font-monospace: 'Fira Code', 'Monaco', 'Consolas', 'SF Mono', 'Inconsolata', 'Droid Sans Mono', 'Source Code Pro', 'Ubuntu Mono', 'Cascadia Code', 'Menlo', monospace; --font-ui: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } body { - font-family: var(--font-monospace); + font-family: var(--font-ui); line-height: 1.5; max-width: 1200px; margin: 0 auto; @@ -1580,7 +1592,7 @@

Claude Code Projects (from last week to today)

πŸ“ 12 transcript files
πŸ’¬ 445 messages
-
πŸ•’ 2023-11-15 19:03:20
+
πŸ•’ 2023-11-15 20:03:20
@@ -1597,7 +1609,7 @@

Claude Code Projects (from last week to today)

πŸ“ 8 transcript files
πŸ’¬ 203 messages
-
πŸ•’ 2023-11-15 12:06:40
+
πŸ•’ 2023-11-15 13:06:40
@@ -1614,7 +1626,7 @@

Claude Code Projects (from last week to today)

πŸ“ 3 transcript files
πŸ’¬ 89 messages
-
πŸ•’ 2023-11-15 05:10:00
+
πŸ•’ 2023-11-15 06:10:00
@@ -1631,7 +1643,7 @@

Claude Code Projects (from last week to today)

πŸ“ 5 transcript files
πŸ’¬ 127 messages
-
πŸ•’ 2023-11-14 22:13:20
+
πŸ•’ 2023-11-14 23:13:20
From 3fa27a50dd123926b59f2d8f2cb6d77db37b4909 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 27 Nov 2025 18:32:19 +0100 Subject: [PATCH 20/20] Fix queue operation handling: only render 'remove' as steering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed from allowlist (enqueue, dequeue) to blocklist approach: skip all queue operations except 'remove'. This ensures popAll and any future operations are skipped by default. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 2d87f924..477f584b 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -2869,10 +2869,9 @@ def _process_messages_loop( if isinstance(message, SummaryTranscriptEntry): continue - # Skip enqueue/dequeue queue operations - they duplicate user messages - # But render 'remove' operations as steering user messages + # Skip most queue operations - only render 'remove' as steering user messages if isinstance(message, QueueOperationTranscriptEntry): - if message.operation in ("enqueue", "dequeue"): + if message.operation != "remove": continue # 'remove' operations fall through to be rendered as user messages