├── .sesskey ├── viewer.png ├── .gitignore ├── pyproject.toml ├── README.md ├── summary.md ├── main.py └── implementation-plan.md /.sesskey: -------------------------------------------------------------------------------- 1 | 66852f2a-1b06-4fbf-91de-9d920bab213e -------------------------------------------------------------------------------- /viewer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-gangz/cc-trace-viewer/HEAD/viewer.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | .venv/ 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | *.pyo 7 | *.pyd 8 | .Python 9 | env/ 10 | venv/ 11 | requirements.txt 12 | 13 | # Environment variables 14 | .env 15 | 16 | # IDE 17 | .vscode/ 18 | .idea/ 19 | 20 | # OS 21 | .DS_Store 22 | 23 | # Testing 24 | .pytest_cache/ 25 | .coverage 26 | htmlcov/ 27 | 28 | # Type checking 29 | .mypy_cache/ 30 | .ruff_cache/ 31 | 32 | # Distribution 33 | dist/ 34 | build/ 35 | *.egg-info/ 36 | 37 | # Custom UI context 38 | custom-ui-context/ -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "cc-trace-viewer" 3 | version = "0.1.0" 4 | description = "Claude Code trace viewer application for visualizing session traces" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | dependencies = [ 8 | "python-fasthtml>=0.12.29", 9 | "monsterui>=1.0.29", 10 | "python-dateutil>=2.9.0", 11 | ] 12 | 13 | [project.optional-dependencies] 14 | dev = [ 15 | "black>=25.9.0", 16 | "ruff>=0.13.3", 17 | "mypy>=1.18.2", 18 | ] 19 | 20 | [build-system] 21 | requires = ["hatchling"] 22 | build-backend = "hatchling.build" 23 | 24 | [tool.hatch.build.targets.wheel] 25 | only-include = ["main.py"] 26 | 27 | [project.scripts] 28 | cc-trace-viewer = "main:main" 29 | 30 | [tool.uv] 31 | dev-dependencies = [ 32 | "black>=25.9.0", 33 | "ruff>=0.13.3", 34 | "mypy>=1.18.2", 35 | ] 36 | 37 | [tool.ruff] 38 | ignore = ["F403", "F405", "F841"] 39 | 40 | [tool.mypy] 41 | ignore_missing_imports = true 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Claude Code Trace Viewer 2 | 3 | A web-based viewer for Claude Code session traces, built with FastHTML and MonsterUI. 4 | 5 | ![Claude Code Trace Viewer](viewer.png) 6 | 7 | ## Features 8 | 9 | - 📁 **Auto-detects your Claude Code sessions** from `~/.claude/projects/` 10 | - 🕐 **Accurate timezone handling** - Automatically converts UTC timestamps to your local time 11 | - 🎯 **Session filtering** - Only shows real conversations, hides summary-only sessions 12 | - 🌲 **Flat timeline view** - Easy-to-read chronological event display 13 | - 🎨 **Interactive UI** - Hover and selection states for better navigation 14 | - ⏱️ **Relative timestamps** - Shows "2 hours ago", "3 days ago", etc. 15 | 16 | ## Quick Start 17 | 18 | ### Prerequisites 19 | 20 | - Python 3.11 or higher 21 | - [uv](https://github.com/astral-sh/uv) package manager (recommended) 22 | 23 | ### Installation & Setup 24 | 25 | 1. **Clone the repository:** 26 | ```bash 27 | git clone https://github.com/d-gangz/cc-trace-viewer.git 28 | cd cc-trace-viewer 29 | ``` 30 | 31 | 2. **Install dependencies** (choose one method): 32 | 33 | **Option A: Using uv (Recommended)** 34 | ```bash 35 | # Install dependencies from pyproject.toml 36 | uv sync 37 | ``` 38 | 39 | **Option B: Using pip** 40 | ```bash 41 | # Install the package with dependencies from pyproject.toml 42 | pip install -e . 43 | ``` 44 | 45 | ### Running the Application 46 | 47 | **With uv (Recommended):** 48 | ```bash 49 | uv run python main.py 50 | ``` 51 | 52 | **With python directly:** 53 | ```bash 54 | python main.py 55 | ``` 56 | 57 | **What happens when you run it:** 58 | 1. ✅ Auto-detects your Claude Code sessions from `~/.claude/projects/` 59 | 2. ✅ Starts a local web server at http://localhost:5001 60 | 3. ✅ Opens in your browser automatically 61 | 62 | **Access the viewer:** http://localhost:5001 63 | 64 | ### What's in pyproject.toml? 65 | 66 | All dependencies are managed in `pyproject.toml`: 67 | - **python-fasthtml** - Modern Python web framework 68 | - **monsterui** - UI components for FastHTML 69 | - **python-dateutil** - Timezone-aware datetime parsing 70 | 71 | No manual dependency installation needed - both `uv sync` and `pip install -e .` read from `pyproject.toml` automatically! 72 | 73 | ## How It Works 74 | 75 | The viewer: 76 | - Discovers all JSONL session files in your `~/.claude/projects/` directory 77 | - Parses session events and builds a timeline view 78 | - Groups sessions by project 79 | - Shows the most recently active sessions first 80 | - Converts UTC timestamps to your local timezone automatically 81 | - Filters out incomplete sessions (summary-only sessions without actual conversation) 82 | 83 | ## Project Structure 84 | 85 | ``` 86 | cc-trace-viewer/ 87 | ├── main.py # Main application code 88 | ├── pyproject.toml # Project dependencies and metadata 89 | ├── uv.lock # Locked dependencies 90 | └── README.md # This file 91 | ``` 92 | 93 | ## Dependencies 94 | 95 | All dependencies are automatically installed from `pyproject.toml`: 96 | 97 | - **python-fasthtml** (>=0.12.29) - Modern Python web framework 98 | - **monsterui** (>=1.0.29) - UI components for FastHTML 99 | - **python-dateutil** (>=2.9.0) - Timezone-aware datetime parsing 100 | 101 | Simply run `uv sync` or `pip install -e .` and you're ready to go! 102 | 103 | ## Contributing 104 | 105 | Contributions are welcome! Please feel free to submit a Pull Request. 106 | 107 | ## License 108 | 109 | MIT License - feel free to use this for your own projects! 110 | 111 | ## Troubleshooting 112 | 113 | ### No sessions showing up? 114 | 115 | Make sure: 116 | 1. You have Claude Code installed and have created some sessions 117 | 2. Your sessions are stored in `~/.claude/projects/` 118 | 3. The session files are not empty or summary-only 119 | 120 | ### Timestamps showing wrong time? 121 | 122 | The app automatically converts UTC to your local timezone. If times seem off: 123 | 1. Check your system timezone is set correctly 124 | 2. Restart the application after changing system timezone 125 | 126 | ### Port 5001 already in use? 127 | 128 | You can change the port in `main.py` by modifying the `serve()` call at the bottom of the file. 129 | -------------------------------------------------------------------------------- /summary.md: -------------------------------------------------------------------------------- 1 | # Claude Code Trace Viewer - Development Summary 2 | 3 | ## 1. Primary Request and Intent 4 | 5 | The user requested comprehensive UI improvements and enhancements to the Claude Code trace viewer application across multiple sessions: 6 | 7 | ### Session 1 - Initial Requirements: 8 | - Display all sessions from `~/.claude/projects/` directory 9 | - Show sessions ordered by date/time (most recent first) 10 | - Display entire trace/conversation when clicking a session 11 | - Make the application portable for any user 12 | - Accurate timezone handling and conversion 13 | - Filter out incomplete/summary-only sessions 14 | 15 | ### Session 2 - UI Enhancement Requests: 16 | 1. **Trace Tree UI Improvements**: 17 | - Remove circle icons from trace rows 18 | - Limit text to max 2 lines with proper truncation 19 | - Remove timestamps on the right 20 | - Adjust layout to 30% trace tree / 70% event details 21 | - Show full session ID (not truncated) 22 | - Remove bold text styling 23 | - Add keyboard navigation (up/down arrows) 24 | - Flush text to left without margins 25 | 26 | 2. **Tool Call/Result Enhancements**: 27 | - Display "tool call" in yellow for tool_use events 28 | - Display "tool result" in green for tool_result events with tool name lookup 29 | - Show tool name by finding matching tool_use_id across events 30 | 31 | 3. **Event Details Redesign**: 32 | - Remove Event/Timestamp/ID sections (keep Event Data at bottom) 33 | - Add structured Content section with scenario-based rendering 34 | - Add Metrics section displaying usage data 35 | - Add base64 image rendering support 36 | 37 | 4. **Navigation & Layout**: 38 | - Auto-select first trace event on page load 39 | - Move "Back" button to header (top left, same row as title) 40 | - Make hover colors consistent across home and viewer pages 41 | - Remove border divider below main title 42 | 43 | ### Session 3 - Homepage and Layout Improvements: 44 | 1. **Homepage Project Timestamps**: 45 | - Add "X time ago" timestamp to each project (rightmost side) 46 | - Sort projects by most recent session (descending order) 47 | - Use text-gray-500 with regular font weight for timestamps 48 | 49 | 2. **Panel Consolidation**: 50 | - Combine trace tree and event details into single bordered container 51 | - Remove gap between panels, use single dividing line 52 | - Match dividing line color to card border color 53 | - Remove "Trace Tree" heading from left panel 54 | - Fix bottom gap in trace tree panel to flush with bottom 55 | 56 | 3. **Session Header Enhancement**: 57 | - Use H4 with uk-h4 class for session ID 58 | - Add project path on rightmost side with text-gray-500 59 | - Use DivFullySpaced for proper spacing 60 | 61 | 4. **Panel Height Adjustment**: 62 | - Increase panel height from 70vh to 75vh for better visibility 63 | 64 | ### Session 4 - Thinking Event Support: 65 | 1. **Trace Tree Thinking Events**: 66 | - Detect events with "thinking" type in message content array 67 | - Display "thinking" label in blue color (text-blue-500) 68 | - Show first 2 lines of thinking text in trace tree 69 | 70 | 2. **Event Details Thinking Rendering**: 71 | - Render full thinking text in Content section 72 | - Use same rendering pattern as text content type 73 | 74 | ### Session 5 - Time Duration Tracking: 75 | 1. **Duration Calculation Requirements**: 76 | - Calculate time for thinking, assistant, tool call, and tool result events 77 | - Format: seconds with 2 decimal places (e.g., "2.32s") 78 | - Calculation method: 79 | - For thinking/assistant/tool_call: current timestamp - previous event timestamp 80 | - For tool_result: current timestamp - matching tool_use timestamp (by ID) 81 | 82 | 2. **Duration Display Location**: 83 | - Trace tree: Display to the right of eyebrow label (e.g., "thinking 55.49s") 84 | - Detail panel: Display as first metric in Metrics section 85 | 86 | 3. **Formatting Refinements**: 87 | - Remove space between number and unit (2.32 s → 2.32s) 88 | - Right-align duration in trace tree using flexbox justify-between 89 | - Add purple color for assistant event labels 90 | 91 | ## 2. Key Technical Concepts 92 | 93 | - **FastHTML**: Modern Python web framework for building web applications 94 | - **MonsterUI**: UI component library for FastHTML (Card, CardContainer, CardBody components) 95 | - **HTMX**: Dynamic content loading without page refreshes 96 | - **Timezone-aware datetime handling**: Converting UTC timestamps to local time using dateutil 97 | - **JSONL format**: Line-delimited JSON for session traces 98 | - **CSS line-clamp**: Text truncation with `-webkit-line-clamp: 2` 99 | - **Flexbox layout**: For 30/70 width distribution, panel alignment, and justify-between spacing 100 | - **JavaScript event listeners**: Keyboard navigation and auto-selection 101 | - **Tool ID correlation**: Matching tool_result events to tool_use events via tool_use_id 102 | - **Scenario-based rendering**: Different UI rendering based on content type 103 | - **Word wrapping**: `whitespace-pre-wrap` and `break-words` for proper text display 104 | - **CSS Variables**: Using `var(--uk-border-default)` for consistent theming 105 | - **Height vs max-height**: Understanding difference for panel sizing 106 | - **MonsterUI Card Architecture**: Card wraps content in CardBody which adds padding 107 | - **Content type detection**: Detecting thinking, text, tool_use, tool_result, image types in message content arrays 108 | - **Duration calculation**: Timestamp parsing and difference calculation using dateutil.parser 109 | - **Event type detection**: Tool_result events have type "user" despite being results 110 | 111 | ## 3. Files and Code Sections 112 | 113 | ### main.py 114 | 115 | **Purpose**: Main application file containing the FastHTML web server and all trace viewer logic. 116 | 117 | #### Recent Changes (Session 5 - Time Duration Tracking): 118 | 119 | **1. TraceEvent.calculate_duration() Method** 120 | 121 | Added comprehensive duration calculation logic: 122 | 123 | ```python 124 | def calculate_duration( 125 | self, previous_event: Optional["TraceEvent"], all_events: List["TraceEvent"] 126 | ) -> Optional[float]: 127 | """ 128 | Calculate duration in seconds for this event. 129 | 130 | For thinking/assistant/tool_call: duration = this.timestamp - previous.timestamp 131 | For tool_result: duration = this.timestamp - matching_tool_use.timestamp 132 | 133 | Returns duration in seconds or None if cannot calculate 134 | """ 135 | # Parse this event's timestamp 136 | try: 137 | current_time = date_parser.parse(self.timestamp) 138 | except Exception: 139 | return None 140 | 141 | # For tool results, find the matching tool_use event 142 | if self.is_tool_result(): 143 | tool_use_id = self.get_tool_use_id() 144 | if tool_use_id and all_events: 145 | # Find the tool_use event with matching ID 146 | for event in all_events: 147 | if "message" in event.data: 148 | msg = event.data["message"] 149 | if isinstance(msg.get("content"), list): 150 | for item in msg["content"]: 151 | if ( 152 | isinstance(item, dict) 153 | and item.get("type") == "tool_use" 154 | and item.get("id") == tool_use_id 155 | ): 156 | # Found matching tool_use, calculate duration 157 | try: 158 | tool_use_time = date_parser.parse( 159 | event.timestamp 160 | ) 161 | duration = ( 162 | current_time - tool_use_time 163 | ).total_seconds() 164 | return duration 165 | except Exception: 166 | return None 167 | return None 168 | 169 | # For thinking/assistant/tool_call: use previous event 170 | if previous_event: 171 | try: 172 | previous_time = date_parser.parse(previous_event.timestamp) 173 | duration = (current_time - previous_time).total_seconds() 174 | return duration 175 | except Exception: 176 | return None 177 | 178 | return None 179 | ``` 180 | 181 | **Why important**: 182 | - Handles two different duration calculation methods 183 | - For tool_result: Searches all events to find matching tool_use by ID 184 | - For other events: Uses simple difference from previous event 185 | - Returns None if calculation fails, allowing graceful handling 186 | 187 | **2. TraceTreeNode Function - Duration Display and Assistant Color** 188 | 189 | Updated to calculate and display duration with proper formatting: 190 | 191 | ```python 192 | def TraceTreeNode( 193 | event: TraceEvent, 194 | session_id: str, 195 | all_events: Optional[List[TraceEvent]] = None, 196 | previous_event: Optional[TraceEvent] = None, 197 | ): 198 | """Flat timeline event node""" 199 | node_id = f"node-{event.id}" 200 | display_text = event.get_display_text() 201 | 202 | # Calculate duration for non-user events (but include tool_result which has type "user") 203 | duration = None 204 | if event.event_type != "user" or event.is_tool_result(): 205 | duration = event.calculate_duration(previous_event, all_events or []) 206 | 207 | # Check if this is a thinking event 208 | if event.is_thinking(): 209 | label = "thinking" 210 | label_color = "text-xs text-blue-500" 211 | # Check if this is a tool call 212 | elif event.is_tool_call(): 213 | label = "tool call" 214 | label_color = "text-xs text-yellow-500" 215 | elif event.is_tool_result(): 216 | label = "tool result" 217 | label_color = "text-xs text-green-500" 218 | # ... tool name lookup logic ... 219 | elif event.event_type == "assistant": 220 | label = "assistant" 221 | label_color = "text-xs text-purple-500" 222 | else: 223 | label = event.event_type 224 | label_color = "text-xs text-gray-500" 225 | 226 | # Format duration text (no space between number and unit) 227 | duration_text = "" 228 | if duration is not None: 229 | duration_text = f"{duration:.2f}s" 230 | 231 | # Create eyebrow with label and optional duration (spaced between) 232 | eyebrow_content = [Span(label, cls=label_color)] 233 | if duration_text: 234 | eyebrow_content.append(Span(duration_text, cls="text-xs text-gray-500")) 235 | 236 | return Div( 237 | Div(*eyebrow_content, cls="flex justify-between"), 238 | Span(display_text), 239 | cls="trace-event", 240 | hx_get=f"/event/{session_id}/{event.id}", 241 | hx_target="#detail-panel", 242 | id=node_id, 243 | ) 244 | ``` 245 | 246 | **Why important**: 247 | - Added previous_event parameter for duration calculation 248 | - Special handling for tool_result events (type "user" but need duration) 249 | - Added assistant color check (purple) 250 | - Duration formatted without space (2.32s not 2.32 s) 251 | - Flexbox justify-between for proper spacing (label left, duration right) 252 | 253 | **3. Viewer Route - Previous Event Context** 254 | 255 | Updated to pass previous_event to each TraceTreeNode: 256 | 257 | ```python 258 | @rt("/viewer/{session_id}") 259 | def viewer(session_id: str): 260 | # ... session file lookup ... 261 | trace_tree = parse_session_file(session_file) 262 | 263 | # Create tree nodes with previous event context for duration calculation 264 | tree_nodes = [] 265 | for idx, event in enumerate(trace_tree): 266 | previous_event = trace_tree[idx - 1] if idx > 0 else None 267 | tree_nodes.append(TraceTreeNode(event, session_id, trace_tree, previous_event)) 268 | 269 | return Layout(...) 270 | ``` 271 | 272 | **Why important**: 273 | - Provides previous event context needed for duration calculation 274 | - First event has None as previous_event (no duration) 275 | - Each subsequent event gets its predecessor 276 | 277 | **4. render_usage_metrics Function - Duration as First Metric** 278 | 279 | Updated to display duration in Metrics section: 280 | 281 | ```python 282 | def render_usage_metrics(usage_data: Dict[str, Any], duration: Optional[float] = None): 283 | """Render usage metrics section""" 284 | if not usage_data and duration is None: 285 | return None 286 | 287 | metrics_items = [] 288 | 289 | # Add duration as first metric if available (no space between number and unit) 290 | if duration is not None: 291 | metrics_items.append( 292 | Div( 293 | Span("Duration: ", cls="text-gray-400"), 294 | Span(f"{duration:.2f}s", cls="text-white"), 295 | cls="mb-1", 296 | ) 297 | ) 298 | 299 | # Add usage metrics 300 | if usage_data: 301 | for key, value in usage_data.items(): 302 | metrics_items.append( 303 | Div( 304 | Span(f"{key}: ", cls="text-gray-400"), 305 | Span(str(value), cls="text-white"), 306 | cls="mb-1", 307 | ) 308 | ) 309 | 310 | return Div( 311 | H4("Metrics", cls="mb-2 font-bold"), 312 | Div(*metrics_items, cls="mb-4 p-3 bg-gray-800 rounded"), 313 | ) 314 | ``` 315 | 316 | **Why important**: 317 | - Accepts optional duration parameter 318 | - Duration displayed as first metric (before usage stats) 319 | - Format matches trace tree (no space: "55.49s") 320 | - Shows metrics even if only duration exists (no usage data) 321 | 322 | **5. DetailPanel Function - Duration Calculation** 323 | 324 | Updated to calculate and pass duration to metrics: 325 | 326 | ```python 327 | def DetailPanel( 328 | event: TraceEvent, 329 | all_events: Optional[List[TraceEvent]] = None, 330 | previous_event: Optional[TraceEvent] = None, 331 | ): 332 | """Detail panel showing event data""" 333 | components = [] 334 | 335 | # Calculate duration for non-user events (but include tool_result which has type "user") 336 | duration = None 337 | if (event.event_type != "user" or event.is_tool_result()) and all_events: 338 | duration = event.calculate_duration(previous_event, all_events) 339 | 340 | # Check if event has message content 341 | if "message" in event.data: 342 | msg = event.data["message"] 343 | content = msg.get("content") 344 | usage = msg.get("usage") 345 | 346 | if isinstance(content, list): 347 | # Add metrics section if usage or duration exists 348 | if usage or duration is not None: 349 | metrics = render_usage_metrics(usage, duration) 350 | if metrics: 351 | components.append(metrics) 352 | ``` 353 | 354 | **Why important**: 355 | - Accepts previous_event parameter for duration calculation 356 | - Same special handling for tool_result events 357 | - Passes duration to render_usage_metrics 358 | - Shows metrics if either usage or duration exists 359 | 360 | **6. Event Route - Previous Event Lookup** 361 | 362 | Updated to find and pass previous_event: 363 | 364 | ```python 365 | @rt("/event/{session_id}/{id}") 366 | def event(session_id: str, id: str): 367 | # ... session file lookup ... 368 | trace_tree = parse_session_file(session_file) 369 | found_event = find_event(trace_tree, id) 370 | 371 | if not found_event: 372 | return Div(P(f"Event {id} not found", cls=TextT.muted), cls="p-4") 373 | 374 | # Find previous event for duration calculation 375 | previous_event = None 376 | for idx, event in enumerate(trace_tree): 377 | if event.id == id and idx > 0: 378 | previous_event = trace_tree[idx - 1] 379 | break 380 | 381 | return DetailPanel(found_event, trace_tree, previous_event) 382 | ``` 383 | 384 | **Why important**: 385 | - Finds previous event when displaying details 386 | - Enables duration calculation in detail panel 387 | - First event gets None (no previous event) 388 | 389 | ### summary.md 390 | 391 | **Purpose**: Development summary documenting all features, changes, and decisions made during the project. 392 | 393 | **Status**: Being updated with Session 5 changes (time duration tracking) 394 | 395 | ## 4. Problem Solving 396 | 397 | ### Problems Solved in Session 5: 398 | 399 | 1. **Tool Result Duration Not Showing**: 400 | - **Problem**: Tool_result events didn't display duration in trace tree or detail panel 401 | - **Root Cause**: Tool_result events have `type: "user"` in JSONL data, so they were skipped by the condition `if event.event_type != "user"` 402 | - **Discovery**: Used debug prints and checked JSONL data to find event type 403 | - **Solution**: Changed condition to `if event.event_type != "user" or event.is_tool_result()` in both TraceTreeNode and DetailPanel 404 | - **Verification**: Ran server with debug output and confirmed duration calculation worked 405 | 406 | 2. **Duration Calculation for Tool Results**: 407 | - **Problem**: Tool results need to calculate duration from matching tool_use event, not previous event 408 | - **Solution**: Implemented ID matching logic to find corresponding tool_use event 409 | - **Implementation**: Loop through all events, check message content for tool_use type, match by ID 410 | - **Result**: Correctly calculates time between tool_use and tool_result (e.g., 0.42s for Read tool) 411 | 412 | 3. **Duration Formatting and Spacing**: 413 | - **Problem**: User wanted no space between number and "s", and right-aligned duration 414 | - **Solution**: 415 | - Changed format from `f"{duration:.2f} s"` to `f"{duration:.2f}s"` 416 | - Added `cls="flex justify-between"` to eyebrow div 417 | - Removed `ml-2` margin class from duration span 418 | - **Result**: Label on left, duration on right, no space before "s" 419 | 420 | 4. **Assistant Label Color**: 421 | - **Problem**: Assistant events needed purple color to distinguish from other types 422 | - **Solution**: Added explicit check `elif event.event_type == "assistant"` with purple color 423 | - **Placement**: Added before generic else clause to catch assistant events specifically 424 | 425 | ### Problems Solved in Session 4: 426 | 427 | 1. **Thinking Event Detection**: 428 | - **Problem**: Need to identify events containing thinking content from LLM 429 | - **Solution**: Added `is_thinking()` method to check for "thinking" type in message content array 430 | - **Implementation**: Follows same pattern as `is_tool_call()` and `is_tool_result()` 431 | 432 | 2. **Thinking Text Display in Trace Tree**: 433 | - **Problem**: Need to show first 2 lines of thinking text in trace tree rows 434 | - **Solution**: Updated `get_display_text()` to extract thinking text and split by newlines 435 | - **Code**: `lines = thinking_text.split('\n')` then `return '\n'.join(lines[:2])` 436 | 437 | 3. **Thinking Event Labeling**: 438 | - **Problem**: Need to show "thinking" label in blue color in trace tree 439 | - **Solution**: Added thinking check BEFORE tool call checks in TraceTreeNode 440 | - **Why order matters**: Event priority determines which label is shown 441 | 442 | 4. **Thinking Content Rendering**: 443 | - **Problem**: Need to display full thinking text in event details panel 444 | - **Solution**: Added "thinking" type case in DetailPanel's content type switch 445 | - **Pattern**: Uses same rendering as text type for consistency 446 | 447 | ### Problems Solved in Session 3: 448 | 449 | 1. **Project Timestamp Display**: 450 | - **Problem**: Needed to show when each project was last active 451 | - **Solution**: Extract most recent session timestamp using `max(sessions, key=lambda s: s.created_at)` 452 | 453 | 2. **Project Sorting**: 454 | - **Problem**: Projects displayed in alphabetical order, not by activity 455 | - **Solution**: Sort projects dict by max session timestamp in descending order 456 | 457 | 3. **Panel Border Consolidation**: 458 | - **Problem**: Trace tree and event details had separate borders with gap 459 | - **Solution**: Wrap both panels in single CardContainer instead of separate Card components 460 | 461 | 4. **Border Color Mismatch**: 462 | - **Problem**: Dividing line color didn't match card border 463 | - **Solution**: Use CSS variable `var(--uk-border-default)` for consistent theming 464 | 465 | 5. **Trace Tree Bottom Gap**: 466 | - **Problem**: Left panel had visible gap at bottom 467 | - **Root Cause**: Using `max-height` without fixed container height 468 | - **Solution**: Set outer div to `height: 75vh` and inner scrollable to `max-height: 75vh` 469 | 470 | ### Previous Session Bug Fixes: 471 | 472 | **MCP Tool Result Rendering Bug (Session 2)**: 473 | - **Problem**: MCP tools caused Internal Server Error in Event Details panel 474 | - **Root Cause**: `toolUseResult` has different types (dict for native, list for MCP) 475 | - **Solution**: Added type checking to handle both formats 476 | 477 | **Base64 Image Rendering (Session 2)**: 478 | - **Problem**: Base64 images displayed as raw JSON 479 | - **Solution**: Implemented image rendering in three locations (user messages, tool results, toolUseResult) 480 | 481 | ## 5. Pending Tasks 482 | 483 | **None** - All requested features have been implemented and committed. 484 | 485 | ## 6. Current Work 486 | 487 | **Time Duration Tracking Implementation (Session 5)** 488 | 489 | The most recent work completed was implementing comprehensive time duration tracking for trace events: 490 | 491 | **Changes Made** (main.py): 492 | 493 | 1. **Added calculate_duration() method** (lines 194-249): 494 | - Calculates duration in seconds for events 495 | - For thinking/assistant/tool_call: uses previous event timestamp 496 | - For tool_result: finds matching tool_use by ID and uses that timestamp 497 | - Returns Optional[float] for graceful handling of missing data 498 | 499 | 2. **Updated TraceTreeNode function** (lines 465-531): 500 | - Added previous_event parameter 501 | - Calculate duration for non-user events (plus tool_result special case) 502 | - Format duration without space: `f"{duration:.2f}s"` 503 | - Added assistant purple color check 504 | - Use flexbox justify-between for label/duration spacing 505 | 506 | 3. **Updated viewer route** (lines 927-933): 507 | - Create tree nodes with previous event context 508 | - Loop through events with index to get previous event 509 | - Pass all needed parameters to TraceTreeNode 510 | 511 | 4. **Updated render_usage_metrics** (lines 543-576): 512 | - Accept optional duration parameter 513 | - Display duration as first metric 514 | - Format without space: "Duration: 55.49s" 515 | 516 | 5. **Updated DetailPanel function** (lines 580-603): 517 | - Accept previous_event parameter 518 | - Calculate duration with same logic as TraceTreeNode 519 | - Pass duration to render_usage_metrics 520 | 521 | 6. **Updated event route** (lines 1042-1049): 522 | - Find previous event in trace_tree 523 | - Pass to DetailPanel for duration calculation 524 | 525 | **Formatting Refinements**: 526 | - Removed space between number and "s" (2.32 s → 2.32s) 527 | - Right-aligned duration using flexbox justify-between 528 | - Added purple color for assistant labels 529 | 530 | **Commits Made**: 531 | - "feat(viewer): add thinking event detection and display" (264d0f1) 532 | - "feat(viewer): add time duration display for trace events" (2729093) 533 | - "style(viewer): improve duration formatting and add assistant color" (2dcd559) 534 | 535 | All Session 5 duration tracking features are complete and pushed to GitHub. 536 | 537 | ## 7. Optional Next Step 538 | 539 | **All Tasks Complete** 540 | 541 | The time duration tracking feature is fully implemented, tested, and committed. No further work is pending from the user's explicit requests in this session. 542 | 543 | **Summary of completed work**: 544 | - ✅ Duration calculation for thinking, assistant, tool_call, and tool_result events 545 | - ✅ Display in trace tree (right-aligned, no space before "s") 546 | - ✅ Display in DetailPanel Metrics section (as first metric) 547 | - ✅ Special handling for tool_result events (type "user" edge case) 548 | - ✅ Formatting refinements (spacing, alignment) 549 | - ✅ Assistant label color (purple) 550 | - ✅ All changes committed and pushed to GitHub 551 | 552 | The Claude Code Trace Viewer now provides comprehensive performance insights: 553 | - ✅ Comprehensive event details with scenario-based rendering 554 | - ✅ Tool call/result identification and labeling 555 | - ✅ Thinking event detection and display 556 | - ✅ **Time duration tracking for all events** 557 | - ✅ **Performance metrics in trace tree and detail panel** 558 | - ✅ Keyboard navigation and auto-selection 559 | - ✅ Clean, unified panel layout with single border 560 | - ✅ Project/session timestamps with activity sorting 561 | - ✅ Session header showing project context 562 | - ✅ Base64 image rendering support 563 | - ✅ MCP and native tool support 564 | - ✅ Portable design for any user's system 565 | - ✅ **Purple assistant labels, blue thinking, yellow tool calls, green tool results** 566 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Claude Code trace viewer application that displays session traces from JSONL files. 3 | 4 | This application automatically detects the current user's Claude Code sessions and 5 | displays them in a web interface with accurate local timezone conversions. 6 | 7 | Input data sources: JSONL files in ~/.claude/projects/ (auto-detected for current user) 8 | Output destinations: Web UI served at http://localhost:5001 9 | Dependencies: FastHTML, MonsterUI, python-dateutil 10 | Key exports: app, serve() 11 | Side effects: Reads session files from user's home directory, serves HTTP server 12 | 13 | Usage: 14 | python main.py 15 | # or with uv: 16 | uv run python main.py 17 | 18 | The app will automatically: 19 | - Detect the current user's home directory 20 | - Find Claude Code sessions in ~/.claude/projects/ 21 | - Convert UTC timestamps to local timezone for accurate relative time display 22 | """ 23 | 24 | from fasthtml.common import * 25 | from monsterui.all import * 26 | from pathlib import Path 27 | from datetime import datetime 28 | from dateutil import parser as date_parser 29 | import json 30 | from dataclasses import dataclass, field 31 | from typing import List, Optional, Dict, Any 32 | 33 | # App setup 34 | custom_css = Style( 35 | """ 36 | .trace-event { 37 | cursor: pointer; 38 | padding: 0.5rem; 39 | transition: all 0.2s ease; 40 | border-radius: 0.375rem; 41 | border-left: 3px solid transparent; 42 | line-height: 1.5; 43 | word-wrap: break-word; 44 | } 45 | 46 | .trace-event > span:last-child { 47 | display: -webkit-box; 48 | -webkit-line-clamp: 2; 49 | -webkit-box-orient: vertical; 50 | overflow: hidden; 51 | } 52 | 53 | .trace-event:hover { 54 | background-color: rgb(31, 41, 55); 55 | } 56 | 57 | .trace-event.selected { 58 | background-color: rgb(17, 24, 39); 59 | border-left-color: rgb(59, 130, 246); 60 | } 61 | 62 | .trace-event-sidechain { 63 | padding-left: 16px; 64 | border-left: 2px solid rgb(107, 114, 128); 65 | margin-left: 8px; 66 | border-radius: 0; 67 | } 68 | 69 | .trace-event-sidechain.selected { 70 | border-left-color: rgb(59, 130, 246); 71 | } 72 | """ 73 | ) 74 | 75 | selection_script = Script( 76 | """ 77 | document.addEventListener('DOMContentLoaded', function() { 78 | // Auto-select first trace event on page load 79 | setTimeout(function() { 80 | var events = document.querySelectorAll('.trace-event'); 81 | if (events.length > 0) { 82 | htmx.trigger(events[0], 'click'); 83 | } 84 | }, 100); 85 | 86 | // Handle selection on HTMX request 87 | document.body.addEventListener('htmx:beforeRequest', function(evt) { 88 | if (evt.detail.elt.classList.contains('trace-event')) { 89 | document.querySelectorAll('.trace-event').forEach(function(item) { 90 | item.classList.remove('selected'); 91 | }); 92 | evt.detail.elt.classList.add('selected'); 93 | } 94 | }); 95 | 96 | // Handle keyboard navigation 97 | document.addEventListener('keydown', function(evt) { 98 | if (evt.key !== 'ArrowUp' && evt.key !== 'ArrowDown') { 99 | return; 100 | } 101 | 102 | evt.preventDefault(); 103 | 104 | var events = Array.from(document.querySelectorAll('.trace-event')); 105 | if (events.length === 0) return; 106 | 107 | var selected = document.querySelector('.trace-event.selected'); 108 | var currentIndex = selected ? events.indexOf(selected) : -1; 109 | var newIndex; 110 | 111 | if (evt.key === 'ArrowDown') { 112 | newIndex = currentIndex + 1; 113 | if (newIndex >= events.length) newIndex = 0; 114 | } else { 115 | newIndex = currentIndex - 1; 116 | if (newIndex < 0) newIndex = events.length - 1; 117 | } 118 | 119 | // Trigger HTMX on new element 120 | htmx.trigger(events[newIndex], 'click'); 121 | 122 | // Scroll into view 123 | events[newIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 124 | }); 125 | }); 126 | """ 127 | ) 128 | 129 | app, rt = fast_app(hdrs=[*Theme.blue.headers(), custom_css, selection_script]) 130 | 131 | 132 | # Data models 133 | @dataclass 134 | class TraceEvent: 135 | """Single trace event from JSONL file""" 136 | 137 | id: str # uuid from JSONL 138 | event_type: str # type field (user, assistant, system, summary) 139 | timestamp: str 140 | data: Dict[str, Any] # Full event data 141 | parent_id: Optional[str] = None # parentUuid from JSONL 142 | children: List["TraceEvent"] = field(default_factory=list) 143 | level: int = 0 144 | is_sidechain: bool = False # isSidechain from JSONL 145 | 146 | def is_tool_call(self) -> bool: 147 | """Check if this event is a tool call""" 148 | if "message" in self.data: 149 | msg = self.data["message"] 150 | if isinstance(msg.get("content"), list): 151 | for item in msg["content"]: 152 | if isinstance(item, dict) and item.get("type") == "tool_use": 153 | return True 154 | return False 155 | 156 | def is_tool_result(self) -> bool: 157 | """Check if this event is a tool result""" 158 | if "message" in self.data: 159 | msg = self.data["message"] 160 | if isinstance(msg.get("content"), list): 161 | for item in msg["content"]: 162 | if isinstance(item, dict) and item.get("type") == "tool_result": 163 | return True 164 | return False 165 | 166 | def get_tool_use_id(self) -> Optional[str]: 167 | """Get tool_use_id from tool_result""" 168 | if "message" in self.data: 169 | msg = self.data["message"] 170 | if isinstance(msg.get("content"), list): 171 | for item in msg["content"]: 172 | if isinstance(item, dict) and item.get("type") == "tool_result": 173 | return item.get("tool_use_id") 174 | return None 175 | 176 | def get_tool_name(self) -> str: 177 | """Get tool name if this is a tool call""" 178 | if "message" in self.data: 179 | msg = self.data["message"] 180 | if isinstance(msg.get("content"), list): 181 | for item in msg["content"]: 182 | if isinstance(item, dict) and item.get("type") == "tool_use": 183 | return item.get("name", "unknown") 184 | return "" 185 | 186 | def is_thinking(self) -> bool: 187 | """Check if this event contains thinking content""" 188 | if "message" in self.data: 189 | msg = self.data["message"] 190 | if isinstance(msg.get("content"), list): 191 | for item in msg["content"]: 192 | if isinstance(item, dict) and item.get("type") == "thinking": 193 | return True 194 | return False 195 | 196 | def get_thinking_text(self) -> str: 197 | """Get thinking text from message content""" 198 | if "message" in self.data: 199 | msg = self.data["message"] 200 | if isinstance(msg.get("content"), list): 201 | for item in msg["content"]: 202 | if isinstance(item, dict) and item.get("type") == "thinking": 203 | return item.get("thinking", "") 204 | return "" 205 | 206 | def calculate_duration( 207 | self, previous_event: Optional["TraceEvent"], all_events: List["TraceEvent"] 208 | ) -> Optional[float]: 209 | """ 210 | Calculate duration in seconds for this event. 211 | 212 | For thinking/assistant/tool_call: duration = this.timestamp - previous.timestamp 213 | For tool_result: duration = this.timestamp - matching_tool_use.timestamp 214 | 215 | Returns duration in seconds or None if cannot calculate 216 | """ 217 | # Parse this event's timestamp 218 | try: 219 | current_time = date_parser.parse(self.timestamp) 220 | except Exception: 221 | return None 222 | 223 | # For tool results, find the matching tool_use event 224 | if self.is_tool_result(): 225 | tool_use_id = self.get_tool_use_id() 226 | if tool_use_id and all_events: 227 | # Find the tool_use event with matching ID 228 | for event in all_events: 229 | if "message" in event.data: 230 | msg = event.data["message"] 231 | if isinstance(msg.get("content"), list): 232 | for item in msg["content"]: 233 | if ( 234 | isinstance(item, dict) 235 | and item.get("type") == "tool_use" 236 | and item.get("id") == tool_use_id 237 | ): 238 | # Found matching tool_use, calculate duration 239 | try: 240 | tool_use_time = date_parser.parse( 241 | event.timestamp 242 | ) 243 | duration = ( 244 | current_time - tool_use_time 245 | ).total_seconds() 246 | return duration 247 | except Exception: 248 | return None 249 | return None 250 | 251 | # For thinking/assistant/tool_call: use previous event 252 | if previous_event: 253 | try: 254 | previous_time = date_parser.parse(previous_event.timestamp) 255 | duration = (current_time - previous_time).total_seconds() 256 | return duration 257 | except Exception: 258 | return None 259 | 260 | return None 261 | 262 | def get_display_text(self) -> str: 263 | """Get human-readable text for display""" 264 | # For summary type 265 | if self.event_type == "summary": 266 | return self.data.get("summary", "") 267 | 268 | # For user/assistant messages 269 | if "message" in self.data: 270 | msg = self.data["message"] 271 | if isinstance(msg.get("content"), str): 272 | return msg["content"][:200] # Truncate long messages 273 | elif isinstance(msg.get("content"), list): 274 | # Handle content array (tool uses, etc) 275 | for item in msg["content"]: 276 | if isinstance(item, dict): 277 | if item.get("type") == "text": 278 | return item.get("text", "")[:200] 279 | elif item.get("type") == "tool_use": 280 | return item.get("name", "unknown") 281 | elif item.get("type") == "tool_result": 282 | # Will be set by TraceTreeNode with proper lookup 283 | return "tool_result" 284 | elif item.get("type") == "thinking": 285 | # Return first 2 lines of thinking text 286 | thinking_text = item.get("thinking", "") 287 | lines = thinking_text.split("\n") 288 | return "\n".join(lines[:2]) 289 | return "Multiple content items" 290 | 291 | # For system events 292 | if self.event_type == "system": 293 | return self.data.get("content", "")[:200] 294 | 295 | return f"{self.event_type} event" 296 | 297 | 298 | @dataclass 299 | class Session: 300 | """Session metadata and trace events""" 301 | 302 | session_id: str 303 | project_name: str 304 | created_at: datetime 305 | file_path: Path 306 | trace_tree: List[TraceEvent] = field(default_factory=list) 307 | 308 | 309 | # Session discovery and parsing 310 | def get_sessions_dir() -> Path: 311 | """Get Claude projects directory""" 312 | return Path.home() / ".claude" / "projects" 313 | 314 | 315 | def discover_sessions() -> List[Session]: 316 | """Discover all session files from project directories""" 317 | projects_dir = get_sessions_dir() 318 | if not projects_dir.exists(): 319 | return [] 320 | 321 | sessions = [] 322 | # Iterate through project directories 323 | for project_dir in projects_dir.iterdir(): 324 | if not project_dir.is_dir() or project_dir.name.startswith("."): 325 | continue 326 | 327 | # Extract project name from directory name (format: -Users-gang-project-name) 328 | project_name = project_dir.name.replace("-", "/") 329 | 330 | # Find all JSONL files in project directory 331 | for session_file in project_dir.glob("*.jsonl"): 332 | session_id = session_file.stem 333 | 334 | # Parse file to get last timestamp (most recent activity) 335 | try: 336 | created_at = None 337 | with open(session_file, "r") as f: 338 | # Read through all lines to find the last event with timestamp 339 | for line in f: 340 | if not line.strip(): 341 | continue 342 | try: 343 | event = json.loads(line) 344 | timestamp_str = event.get("timestamp", "") 345 | if timestamp_str: 346 | # Keep updating to get the LAST timestamp 347 | created_at = date_parser.parse(timestamp_str) 348 | # Convert to local time (naive datetime) 349 | if created_at.tzinfo is not None: 350 | # Convert UTC to local time 351 | created_at = created_at.astimezone().replace( 352 | tzinfo=None 353 | ) 354 | except Exception: 355 | continue 356 | 357 | # Skip sessions with no timestamp (summary-only sessions) 358 | if created_at is None: 359 | continue 360 | 361 | sessions.append( 362 | Session( 363 | session_id=session_id, 364 | project_name=project_name, 365 | created_at=created_at, 366 | file_path=session_file, 367 | ) 368 | ) 369 | except Exception as e: 370 | print(f"Error parsing {session_file}: {e}") 371 | continue 372 | 373 | return sorted(sessions, key=lambda s: s.created_at, reverse=True) 374 | 375 | 376 | def parse_session_file(file_path: Path) -> List[TraceEvent]: 377 | """Parse JSONL file as flat timeline (not tree)""" 378 | events = [] 379 | 380 | with open(file_path, "r") as f: 381 | for idx, line in enumerate(f): 382 | if not line.strip(): 383 | continue 384 | 385 | try: 386 | data = json.loads(line) 387 | event = TraceEvent( 388 | id=data.get("uuid", str(idx)), 389 | event_type=data.get("type", "unknown"), 390 | timestamp=data.get("timestamp", ""), 391 | data=data, 392 | parent_id=data.get("parentUuid"), 393 | is_sidechain=data.get("isSidechain", False), 394 | ) 395 | events.append(event) 396 | except Exception as e: 397 | print(f"Error parsing line {idx}: {e}") 398 | continue 399 | 400 | # Return as flat list (no tree structure for conversation view) 401 | # All events at level 0 402 | return events 403 | 404 | 405 | def group_sessions_by_project(sessions: List[Session]) -> Dict[str, List[Session]]: 406 | """Group sessions by project name""" 407 | projects = {} 408 | for session in sessions: 409 | if session.project_name not in projects: 410 | projects[session.project_name] = [] 411 | projects[session.project_name].append(session) 412 | return projects 413 | 414 | 415 | # Helper functions 416 | def get_relative_time(dt: datetime) -> str: 417 | """Convert datetime to relative time string (e.g., '2 days ago')""" 418 | now = datetime.now() 419 | diff = now - dt 420 | 421 | seconds = diff.total_seconds() 422 | 423 | if seconds < 60: 424 | return "just now" 425 | elif seconds < 3600: 426 | minutes = int(seconds / 60) 427 | return f"{minutes} minute{'s' if minutes != 1 else ''} ago" 428 | elif seconds < 86400: 429 | hours = int(seconds / 3600) 430 | return f"{hours} hour{'s' if hours != 1 else ''} ago" 431 | elif seconds < 604800: 432 | days = int(seconds / 86400) 433 | return f"{days} day{'s' if days != 1 else ''} ago" 434 | elif seconds < 2592000: 435 | weeks = int(seconds / 604800) 436 | return f"{weeks} week{'s' if weeks != 1 else ''} ago" 437 | elif seconds < 31536000: 438 | months = int(seconds / 2592000) 439 | return f"{months} month{'s' if months != 1 else ''} ago" 440 | else: 441 | years = int(seconds / 31536000) 442 | return f"{years} year{'s' if years != 1 else ''} ago" 443 | 444 | 445 | # UI Components 446 | def ProjectAccordion(project_name: str, sessions: List[Session]): 447 | """Accordion item for a project with its sessions""" 448 | session_items = [] 449 | for session in sessions: 450 | relative_time = get_relative_time(session.created_at) 451 | session_items.append( 452 | Li( 453 | A( 454 | DivFullySpaced( 455 | Span(session.session_id, cls=TextT.bold), 456 | Span(relative_time, cls="text-gray-500 font-normal"), 457 | ), 458 | href=f"/viewer/{session.session_id}", 459 | cls="hover:bg-gray-800 p-2 rounded block", 460 | ) 461 | ) 462 | ) 463 | 464 | # Get most recent session timestamp for this project 465 | most_recent = max(sessions, key=lambda s: s.created_at) 466 | project_time = get_relative_time(most_recent.created_at) 467 | 468 | return Li( 469 | A( 470 | DivFullySpaced( 471 | Span(project_name), 472 | Span(project_time, cls="text-gray-500 font-normal"), 473 | ), 474 | cls="uk-accordion-title font-bold", 475 | ), 476 | Div(Ul(*session_items, cls="space-y-1 mt-2"), cls="uk-accordion-content"), 477 | ) 478 | 479 | 480 | def TraceTreeNode( 481 | event: TraceEvent, 482 | session_id: str, 483 | all_events: Optional[List[TraceEvent]] = None, 484 | previous_event: Optional[TraceEvent] = None, 485 | ): 486 | """Flat timeline event node""" 487 | node_id = f"node-{event.id}" 488 | 489 | display_text = event.get_display_text() 490 | 491 | # Calculate duration for non-user events (but include tool_result which has type "user") 492 | duration = None 493 | if event.event_type != "user" or event.is_tool_result(): 494 | duration = event.calculate_duration(previous_event, all_events or []) 495 | 496 | # Check if this is a thinking event 497 | if event.is_thinking(): 498 | label = "thinking" 499 | label_color = "text-xs text-blue-500" 500 | # Check if this is a tool call 501 | elif event.is_tool_call(): 502 | # Get the tool call ID and extract last 4 characters 503 | tool_id = None 504 | tool_name = None 505 | subagent_type = None 506 | 507 | if "message" in event.data: 508 | msg = event.data["message"] 509 | if isinstance(msg.get("content"), list): 510 | for item in msg["content"]: 511 | if isinstance(item, dict) and item.get("type") == "tool_use": 512 | tool_id = item.get("id", "") 513 | tool_name = item.get("name", "") 514 | # Check if this is a Task tool (subagent) 515 | if tool_name == "Task": 516 | input_data = item.get("input", {}) 517 | subagent_type = input_data.get("subagent_type", "") 518 | break 519 | 520 | # Handle Task tool (subagent) differently 521 | if tool_name == "Task" and subagent_type: 522 | # Change display text to show the subagent name 523 | display_text = subagent_type 524 | 525 | label = "tool call" 526 | if tool_id and len(tool_id) >= 4: 527 | last_4 = tool_id[-4:] 528 | label = Span( 529 | Span("subagent ", cls="text-xs text-cyan-500"), 530 | Span(last_4, cls="text-xs text-gray-500 font-normal"), 531 | ) 532 | else: 533 | label = Span("subagent", cls="text-xs text-cyan-500") 534 | label_color = None # Already set in label 535 | else: 536 | # Regular tool call 537 | label = "tool call" 538 | if tool_id and len(tool_id) >= 4: 539 | last_4 = tool_id[-4:] 540 | label = Span( 541 | Span("tool call ", cls="text-xs text-yellow-500"), 542 | Span(last_4, cls="text-xs text-gray-500 font-normal"), 543 | ) 544 | else: 545 | label = Span("tool call", cls="text-xs text-yellow-500") 546 | label_color = None # Already set in label 547 | elif event.is_tool_result(): 548 | # Get tool_use_id and extract last 4 characters 549 | tool_use_id = event.get_tool_use_id() 550 | 551 | label = "tool result" 552 | if tool_use_id and len(tool_use_id) >= 4: 553 | last_4 = tool_use_id[-4:] 554 | label = Span( 555 | Span("tool result ", cls="text-xs text-green-500"), 556 | Span(last_4, cls="text-xs text-gray-500 font-normal"), 557 | ) 558 | else: 559 | label = Span("tool result", cls="text-xs text-green-500") 560 | label_color = None # Already set in label 561 | 562 | # Find the corresponding tool_use event to get the tool name for display_text 563 | if tool_use_id and all_events: 564 | for e in all_events: 565 | # Check if this event has a tool_use with matching id 566 | if "message" in e.data: 567 | msg = e.data["message"] 568 | if isinstance(msg.get("content"), list): 569 | for item in msg["content"]: 570 | if ( 571 | isinstance(item, dict) 572 | and item.get("type") == "tool_use" 573 | ): 574 | if item.get("id") == tool_use_id: 575 | display_text = item.get("name", "unknown") 576 | break 577 | elif event.event_type == "assistant": 578 | label = "assistant" 579 | label_color = "text-xs text-purple-500" 580 | else: 581 | label = event.event_type 582 | label_color = "text-xs text-gray-500" 583 | 584 | # Format duration text (no space between number and unit) 585 | duration_text = "" 586 | if duration is not None: 587 | duration_text = f"{duration:.2f}s" 588 | 589 | # Create eyebrow with label and optional duration (spaced between) 590 | # label is either a string (with label_color) or a Span element (already styled) 591 | if isinstance(label, str): 592 | eyebrow_content = [Span(label, cls=label_color)] 593 | else: 594 | eyebrow_content = [label] 595 | 596 | if duration_text: 597 | eyebrow_content.append(Span(duration_text, cls="text-xs text-gray-500")) 598 | 599 | # Build CSS classes for sidechain events 600 | css_classes = "trace-event" 601 | if event.is_sidechain: 602 | css_classes += " trace-event-sidechain" 603 | 604 | return Div( 605 | Div(*eyebrow_content, cls="flex justify-between"), 606 | Span(display_text), 607 | cls=css_classes, 608 | hx_get=f"/event/{session_id}/{event.id}", 609 | hx_target="#detail-panel", 610 | id=node_id, 611 | ) 612 | 613 | 614 | def render_markdown_content(text: str): 615 | """Render text as markdown (simple version - can be enhanced)""" 616 | # For now, return as pre-formatted text with word wrap 617 | return Div( 618 | text, 619 | cls="whitespace-pre-wrap break-words", 620 | ) 621 | 622 | 623 | def render_usage_metrics(usage_data: Dict[str, Any], duration: Optional[float] = None): 624 | """Render usage metrics section""" 625 | if not usage_data and duration is None: 626 | return None 627 | 628 | metrics_items = [] 629 | 630 | # Add duration as first metric if available (no space between number and unit) 631 | if duration is not None: 632 | metrics_items.append( 633 | Div( 634 | Span("Duration: ", cls="text-gray-400"), 635 | Span(f"{duration:.2f}s", cls="text-white"), 636 | cls="mb-1", 637 | ) 638 | ) 639 | 640 | # Add usage metrics 641 | if usage_data: 642 | for key, value in usage_data.items(): 643 | metrics_items.append( 644 | Div( 645 | Span(f"{key}: ", cls="text-gray-400"), 646 | Span(str(value), cls="text-white"), 647 | cls="mb-1", 648 | ) 649 | ) 650 | 651 | return Div( 652 | H4("Metrics", cls="mb-2 font-bold"), 653 | Div( 654 | *metrics_items, 655 | cls="mb-4 p-3 bg-gray-900 rounded", 656 | ), 657 | ) 658 | 659 | 660 | def DetailPanel( 661 | event: TraceEvent, 662 | all_events: Optional[List[TraceEvent]] = None, 663 | previous_event: Optional[TraceEvent] = None, 664 | ): 665 | """Detail panel showing event data""" 666 | components = [] 667 | 668 | # Calculate duration for non-user events (but include tool_result which has type "user") 669 | duration = None 670 | if (event.event_type != "user" or event.is_tool_result()) and all_events: 671 | duration = event.calculate_duration(previous_event, all_events) 672 | 673 | # Check if event has message content 674 | if "message" in event.data: 675 | msg = event.data["message"] 676 | content = msg.get("content") 677 | usage = msg.get("usage") # Usage is inside message object 678 | 679 | if isinstance(content, list): 680 | # Add metrics section if usage or duration exists 681 | if usage or duration is not None: 682 | metrics = render_usage_metrics(usage, duration) 683 | if metrics: 684 | components.append(metrics) 685 | 686 | # Add Content section header 687 | components.append(H4("Content", cls="mb-2 font-bold")) 688 | 689 | for idx, item in enumerate(content): 690 | if not isinstance(item, dict): 691 | continue 692 | 693 | item_type = item.get("type") 694 | 695 | # Scenario A: text type 696 | if item_type == "text": 697 | text_content = item.get("text", "") 698 | components.append( 699 | Div( 700 | render_markdown_content(text_content), 701 | cls="mb-4 p-3 bg-gray-900 rounded", 702 | ) 703 | ) 704 | 705 | # Scenario D: thinking type 706 | elif item_type == "thinking": 707 | thinking_text = item.get("thinking", "") 708 | components.append( 709 | Div( 710 | render_markdown_content(thinking_text), 711 | cls="mb-4 p-3 bg-gray-900 rounded", 712 | ) 713 | ) 714 | 715 | # Scenario A2: image type (in user messages) 716 | elif item_type == "image": 717 | source = item.get("source", {}) 718 | if isinstance(source, dict): 719 | data = source.get("data", "") 720 | media_type = source.get("media_type", "image/png") 721 | source_type = source.get("type", "base64") 722 | 723 | if data and source_type == "base64": 724 | components.append( 725 | Div( 726 | Img( 727 | src=f"data:{media_type};base64,{data}", 728 | alt="User uploaded image", 729 | cls="max-w-full h-auto rounded", 730 | style="max-height: 600px;", 731 | ), 732 | cls="mb-4 p-3 bg-gray-900 rounded", 733 | ) 734 | ) 735 | 736 | # Scenario B: tool_use type 737 | elif item_type == "tool_use": 738 | components.append( 739 | Div( 740 | Div( 741 | Span("ID: ", cls="text-gray-400"), 742 | Span(item.get("id", ""), cls="text-white break-all"), 743 | cls="mb-2", 744 | ), 745 | Div( 746 | Span("Name: ", cls="text-gray-400"), 747 | Span(item.get("name", ""), cls="text-white"), 748 | cls="mb-2", 749 | ), 750 | Div( 751 | Span("Input: ", cls="text-gray-400"), 752 | Pre( 753 | Code(json.dumps(item.get("input", {}), indent=2)), 754 | cls="text-white text-sm whitespace-pre-wrap break-words mt-1", 755 | ), 756 | ), 757 | cls="mb-4 p-3 bg-gray-900 rounded", 758 | ) 759 | ) 760 | 761 | # Scenario C: tool_result type 762 | elif item_type == "tool_result": 763 | tool_result_components = [] 764 | 765 | # Get tool_use_id and find the tool name 766 | tool_use_id = item.get("tool_use_id") 767 | tool_name = "unknown" 768 | 769 | # Look for the corresponding tool_use event to get the tool name 770 | # Search through all events (same logic as TraceTreeNode) 771 | if tool_use_id and all_events: 772 | for e in all_events: 773 | if "message" in e.data: 774 | e_msg = e.data["message"] 775 | if isinstance(e_msg.get("content"), list): 776 | for check_item in e_msg["content"]: 777 | if ( 778 | isinstance(check_item, dict) 779 | and check_item.get("type") == "tool_use" 780 | ): 781 | if check_item.get("id") == tool_use_id: 782 | tool_name = check_item.get( 783 | "name", "unknown" 784 | ) 785 | break 786 | if tool_name != "unknown": 787 | break 788 | 789 | # Add tool ID and name 790 | tool_result_components.append( 791 | Div( 792 | Span("Tool ID: ", cls="text-gray-400"), 793 | Span(tool_use_id or "N/A", cls="text-white break-all"), 794 | cls="mb-2", 795 | ) 796 | ) 797 | tool_result_components.append( 798 | Div( 799 | Span("Tool Name: ", cls="text-gray-400"), 800 | Span(tool_name, cls="text-white"), 801 | cls="mb-2", 802 | ) 803 | ) 804 | 805 | # Display content if exists 806 | tool_content = item.get("content") 807 | if tool_content: 808 | if isinstance(tool_content, str): 809 | tool_result_components.append( 810 | Div(render_markdown_content(tool_content), cls="mt-2") 811 | ) 812 | elif isinstance(tool_content, list): 813 | for content_item in tool_content: 814 | if isinstance(content_item, dict): 815 | content_type = content_item.get("type") 816 | 817 | if content_type == "text": 818 | tool_result_components.append( 819 | Div( 820 | render_markdown_content( 821 | content_item.get("text", "") 822 | ), 823 | cls="mt-2", 824 | ) 825 | ) 826 | elif content_type == "image": 827 | # Render base64 image in tool_result content 828 | source = content_item.get("source", {}) 829 | if isinstance(source, dict): 830 | data = source.get("data", "") 831 | media_type = source.get( 832 | "media_type", "image/png" 833 | ) 834 | source_type = source.get("type", "base64") 835 | 836 | if data and source_type == "base64": 837 | tool_result_components.append( 838 | Div( 839 | Img( 840 | src=f"data:{media_type};base64,{data}", 841 | alt="Content image", 842 | cls="max-w-full h-auto rounded", 843 | style="max-height: 600px;", 844 | ), 845 | cls="mt-2", 846 | ) 847 | ) 848 | 849 | # Wrap all tool result components 850 | components.append( 851 | Div(*tool_result_components, cls="mb-4 p-3 bg-gray-900 rounded") 852 | ) 853 | 854 | # Add Tool Result section 855 | tool_use_result = event.data.get("toolUseResult") 856 | if tool_use_result: 857 | components.append(H4("Tool Result", cls="mb-2 font-bold mt-4")) 858 | # Handle both dict (native tools) and list (MCP tools) formats 859 | if isinstance(tool_use_result, dict): 860 | components.append( 861 | Div( 862 | *[ 863 | Div( 864 | Span(f"{key}: ", cls="text-gray-400"), 865 | Span( 866 | ( 867 | str(value) 868 | if not isinstance( 869 | value, (dict, list) 870 | ) 871 | else "" 872 | ), 873 | cls="text-white", 874 | ), 875 | ( 876 | Pre( 877 | Code(json.dumps(value, indent=2)), 878 | cls="text-white text-sm whitespace-pre-wrap break-words mt-1", 879 | ) 880 | if isinstance(value, (dict, list)) 881 | else None 882 | ), 883 | cls="mb-2", 884 | ) 885 | for key, value in tool_use_result.items() 886 | ], 887 | cls="mb-4 p-3 bg-gray-900 rounded", 888 | ) 889 | ) 890 | elif isinstance(tool_use_result, list): 891 | # MCP tools return toolUseResult as a list of content items 892 | for content_item in tool_use_result: 893 | if isinstance(content_item, dict): 894 | item_type = content_item.get("type") 895 | 896 | if item_type == "text": 897 | components.append( 898 | Div( 899 | render_markdown_content( 900 | content_item.get("text", "") 901 | ), 902 | cls="mb-4 p-3 bg-gray-900 rounded", 903 | ) 904 | ) 905 | elif item_type == "image": 906 | # Render base64 image 907 | source = content_item.get("source", {}) 908 | if isinstance(source, dict): 909 | data = source.get("data", "") 910 | media_type = source.get( 911 | "media_type", "image/png" 912 | ) 913 | source_type = source.get("type", "base64") 914 | 915 | if data and source_type == "base64": 916 | components.append( 917 | Div( 918 | Img( 919 | src=f"data:{media_type};base64,{data}", 920 | alt="Tool result image", 921 | cls="max-w-full h-auto rounded", 922 | style="max-height: 600px;", 923 | ), 924 | cls="mb-4 p-3 bg-gray-900 rounded", 925 | ) 926 | ) 927 | 928 | elif isinstance(content, str): 929 | # Simple text content 930 | components.append(H4("Content", cls="mb-2 font-bold")) 931 | components.append( 932 | Div( 933 | render_markdown_content(content), cls="mb-4 p-3 bg-gray-900 rounded" 934 | ) 935 | ) 936 | 937 | # Always add Event Data section at the bottom 938 | formatted_json = json.dumps(event.data, indent=2) 939 | components.append(H4("Event Data:", cls="mb-2 font-bold mt-4")) 940 | components.append( 941 | Pre( 942 | Code(formatted_json), 943 | cls="bg-gray-900 text-gray-100 p-4 rounded overflow-auto text-sm", 944 | ) 945 | ) 946 | 947 | return Div(*components) 948 | 949 | 950 | def Layout(content, show_back_button=False): 951 | """Main layout wrapper""" 952 | if show_back_button: 953 | header = Div( 954 | A( 955 | "Back", 956 | href="/", 957 | cls="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded", 958 | ), 959 | H1("Claude Code Trace Viewer", cls="my-8 text-center flex-grow"), 960 | Div(style="width: 80px"), # Spacer to balance the layout 961 | cls="flex items-center pb-4", 962 | ) 963 | else: 964 | header = DivCentered(H1("Claude Code Trace Viewer", cls="my-8"), cls="pb-4") 965 | 966 | return Container( 967 | header, 968 | content, 969 | cls="min-h-screen", 970 | ) 971 | 972 | 973 | # Routes 974 | @rt 975 | def index(): 976 | """Home page with project accordion""" 977 | sessions = discover_sessions() 978 | 979 | if not sessions: 980 | return Layout( 981 | Card( 982 | P("No session files found in ~/.claude/sessions/", cls=TextT.muted), 983 | cls="mt-8", 984 | ) 985 | ) 986 | 987 | projects = group_sessions_by_project(sessions) 988 | 989 | # Sort projects by most recent session (descending) 990 | sorted_projects = sorted( 991 | projects.items(), 992 | key=lambda item: max(s.created_at for s in item[1]), 993 | reverse=True, 994 | ) 995 | 996 | accordion_items = [ 997 | ProjectAccordion(project_name, project_sessions) 998 | for project_name, project_sessions in sorted_projects 999 | ] 1000 | 1001 | return Layout( 1002 | Div( 1003 | H2("Projects & Sessions", cls="mb-4"), 1004 | Ul( 1005 | *accordion_items, cls="uk-accordion", data_uk_accordion="multiple: true" 1006 | ), 1007 | cls="mt-8", 1008 | ) 1009 | ) 1010 | 1011 | 1012 | @rt("/viewer/{session_id}") 1013 | def viewer(session_id: str): 1014 | """Trace viewer page with tree and detail panel""" 1015 | # Find session file in project directories 1016 | sessions = discover_sessions() 1017 | session_file = None 1018 | project_name = None 1019 | for session in sessions: 1020 | if session.session_id == session_id: 1021 | session_file = session.file_path 1022 | project_name = session.project_name 1023 | break 1024 | 1025 | if not session_file or not session_file.exists(): 1026 | return Layout( 1027 | Card( 1028 | P(f"Session file not found: {session_id}", cls=TextT.muted), cls="mt-8" 1029 | ) 1030 | ) 1031 | 1032 | trace_tree = parse_session_file(session_file) 1033 | 1034 | # Create tree nodes with previous event context for duration calculation 1035 | tree_nodes = [] 1036 | for idx, event in enumerate(trace_tree): 1037 | previous_event = trace_tree[idx - 1] if idx > 0 else None 1038 | tree_nodes.append(TraceTreeNode(event, session_id, trace_tree, previous_event)) 1039 | 1040 | return Layout( 1041 | Div( 1042 | DivFullySpaced( 1043 | H4(f"Session: {session_id}", cls="uk-h4"), 1044 | Span(project_name, cls="text-gray-500 font-normal"), 1045 | ), 1046 | # Combined panel with single border - using CardContainer directly 1047 | CardContainer( 1048 | Div( 1049 | # Left panel - Trace tree (30% width) 1050 | Div( 1051 | Div( 1052 | *( 1053 | tree_nodes 1054 | if tree_nodes 1055 | else [P("No trace events found", cls=TextT.muted)] 1056 | ), 1057 | cls="overflow-auto", 1058 | style="max-height: 75vh;", 1059 | ), 1060 | cls="p-4", 1061 | style="width: 30%; border-right: 1px solid var(--uk-border-default); height: 75vh;", 1062 | ), 1063 | # Right panel - Detail view (70% width) 1064 | Div( 1065 | H3("Event Details", cls="mb-4 font-bold"), 1066 | Div( 1067 | P("Select an event to view details", cls=TextT.muted), 1068 | id="detail-panel", 1069 | cls="overflow-auto", 1070 | style="max-height: 75vh", 1071 | ), 1072 | cls="p-4", 1073 | style="width: 70%", 1074 | ), 1075 | style="display: flex;", 1076 | ), 1077 | cls="mt-4", 1078 | ), 1079 | ), 1080 | show_back_button=True, 1081 | ) 1082 | 1083 | 1084 | @rt("/event/{session_id}/{id}") 1085 | def event(session_id: str, id: str): 1086 | """Get event details (for HTMX)""" 1087 | # Find session file in project directories 1088 | sessions = discover_sessions() 1089 | session_file = None 1090 | for session in sessions: 1091 | if session.session_id == session_id: 1092 | session_file = session.file_path 1093 | break 1094 | 1095 | if not session_file or not session_file.exists(): 1096 | return Div( 1097 | P("Session file not found", cls=TextT.muted), 1098 | cls="p-4", 1099 | ) 1100 | 1101 | # Parse session and find event 1102 | trace_tree = parse_session_file(session_file) 1103 | 1104 | def find_event(events: List[TraceEvent], event_id: str) -> Optional[TraceEvent]: 1105 | for event in events: 1106 | if event.id == event_id: 1107 | return event 1108 | if event.children: 1109 | found = find_event(event.children, event_id) 1110 | if found: 1111 | return found 1112 | return None 1113 | 1114 | found_event = find_event(trace_tree, id) 1115 | 1116 | if not found_event: 1117 | return Div( 1118 | P(f"Event {id} not found", cls=TextT.muted), 1119 | cls="p-4", 1120 | ) 1121 | 1122 | # Find previous event for duration calculation 1123 | previous_event = None 1124 | for idx, event in enumerate(trace_tree): 1125 | if event.id == id and idx > 0: 1126 | previous_event = trace_tree[idx - 1] 1127 | break 1128 | 1129 | return DetailPanel(found_event, trace_tree, previous_event) 1130 | 1131 | 1132 | # Start server 1133 | serve() 1134 | -------------------------------------------------------------------------------- /implementation-plan.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | # Claude Code Trace Viewer - FastHTML + MonsterUI Implementation 10 | 11 | ## Overview 12 | 13 | A local web application to visualize Claude Code session traces stored in `/Users/gang/.claude/projects/`. Built with FastHTML and MonsterUI for a fully Python-based, server-rendered solution. 14 | 15 | ## Architecture (Local-First) 16 | 17 | **Single FastHTML application** with no React/JavaScript frontend. Everything server-rendered with HTMX for dynamic updates. 18 | 19 | ### Technology Stack 20 | 21 | - **Backend**: FastHTML (Python) - single application 22 | - **UI Framework**: MonsterUI (Tailwind-based components) 23 | - **Styling**: Built-in Tailwind CSS (via MonsterUI) 24 | - **Interactivity**: HTMX (built into FastHTML) 25 | - **Data**: Direct JSONL file parsing (no database needed) 26 | 27 | ## Data Structure Understanding 28 | 29 | ### Projects Directory Structure 30 | 31 | ``` 32 | /Users/gang/.claude/projects/ 33 | ├── -Users-gang-CLIProxyAPI/ 34 | │ ├── 89b6496f-9eb2-49c0-87a8-732914965845.jsonl 35 | │ ├── 3397ac25-5364-4e62-894b-9413c5acf2e9.jsonl 36 | │ └── ... 37 | ├── -Users-gang-courses-rag-course-practice/ 38 | │ └── [session files].jsonl 39 | └── [other projects]/ 40 | ``` 41 | 42 | - **9 project folders** (each representing a working directory) 43 | - **188 total session files** (`.jsonl` format) 44 | 45 | ### JSONL Session File Format 46 | 47 | Each session file contains one JSON object per line: 48 | 49 | ```json 50 | {"type": "summary", "summary": "Session title", "leafUuid": "..."} 51 | {"type": "file-history-snapshot", "messageId": "...", "snapshot": {...}} 52 | {"type": "user", "message": {"role": "user", "content": "..."}, "timestamp": "...", "uuid": "...", "parentUuid": "..."} 53 | {"type": "assistant", "message": {"role": "assistant", "content": [...]}, "timestamp": "...", "uuid": "...", "parentUuid": "..."} 54 | {"type": "system", "content": "...", "timestamp": "...", "uuid": "...", "parentUuid": "..."} 55 | ``` 56 | 57 | **Event Types:** 58 | 59 | - `summary` - Session metadata (first line) 60 | - `user` - User messages 61 | - `assistant` - Assistant responses (may contain tool calls) 62 | - `system` - System messages 63 | - `file-history-snapshot` - File state snapshots 64 | 65 | **Key Fields:** 66 | 67 | - `uuid` - Unique identifier for each event 68 | - `parentUuid` - Parent event UUID (for building tree structure) 69 | - `timestamp` - ISO format timestamp 70 | - `message` - Contains `role` and `content` (for user/assistant) 71 | - `sessionId` - Session identifier 72 | - `cwd` - Working directory 73 | - `gitBranch` - Git branch name 74 | 75 | ## Application Structure 76 | 77 | ### Project Location 78 | 79 | The trace viewer lives in **its own project folder** (separate from session data): 80 | 81 | ``` 82 | /Users/gang/cc-trace-viewer/ ← New project for the viewer 83 | ├── app.py # Main FastHTML application 84 | ├── models.py # Data models for sessions/traces 85 | ├── components.py # Reusable UI components 86 | ├── static/ 87 | │ └── custom.css # Custom styling for trace tree 88 | └── requirements.txt # Dependencies 89 | ``` 90 | 91 | ### Data Access Pattern 92 | 93 | The viewer **reads from multiple locations** on your local machine: 94 | 95 | **Session Data:** 96 | 97 | ``` 98 | /Users/gang/.claude/projects/ ← Session JSONL files 99 | ├── -Users-gang-CLIProxyAPI/ 100 | │ ├── abc-123.jsonl 101 | │ └── def-456.jsonl 102 | └── -Users-gang-courses-rag-course-practice/ 103 | └── xyz-789.jsonl 104 | ``` 105 | 106 | **CLAUDE.md Files (accessed based on session `cwd` field):** 107 | 108 | ``` 109 | /Users/gang/.claude/CLAUDE.md ← Global CLAUDE.md 110 | /Users/gang/CLIProxyAPI/CLAUDE.md ← Project-specific 111 | /Users/gang/courses/rag-course-practice/CLAUDE.md 112 | ``` 113 | 114 | **How It Works:** 115 | 116 | 1. Viewer reads session JSONL from `/Users/gang/.claude/projects/` 117 | 2. Extracts `cwd` from session (e.g., `/Users/gang/CLIProxyAPI`) 118 | 3. Reads `CLAUDE.md` from that `cwd` directory 119 | 4. All file access is **local** (no network, no API, just Python file I/O) 120 | 121 | **Configuration in app.py:** 122 | 123 | ```python 124 | from pathlib import Path 125 | 126 | # Hardcoded paths - change if needed 127 | PROJECTS_DIR = Path("/Users/gang/.claude/projects") 128 | GLOBAL_CLAUDE_MD = Path.home() / ".claude" / "CLAUDE.md" 129 | ``` 130 | 131 | ## Features 132 | 133 | ### 1. Home Page - Combined Project List & Sessions (Accordion View) 134 | 135 | **Route**: `/` 136 | 137 | **Displays:** 138 | 139 | - **Accordion-style layout** with all projects and their sessions on a single page 140 | - Each project is an expandable/collapsible section showing: 141 | - **Project Header** (always visible): 142 | - Project name (e.g., "CLIProxyAPI") 143 | - Number of sessions 144 | - Last session date 145 | - Expand/collapse indicator 146 | - **Session List** (revealed when expanded): 147 | - Sessions sorted by recency (newest first) 148 | - Each session row shows: 149 | - Date & Time (YYYY-MM-DD HH:MM:SS) 150 | - Summary (from first line of JSONL) 151 | - Message count 152 | - Click to open trace viewer 153 | - **Default state**: All projects collapsed, or most recent project expanded 154 | - **No navigation required**: Everything accessible from one page 155 | 156 | **Layout:** 157 | 158 | ``` 159 | Claude Code Trace Viewer 160 | ├─ ▼ CLIProxyAPI (4 sessions, last: 2025-09-30 11:11) 161 | │ ├─ 2025-09-30 11:11 - "Session about..." [15 msgs] → [Click to view trace] 162 | │ ├─ 2025-09-30 10:58 - "Model setup..." [10 msgs] → [Click to view trace] 163 | │ ├─ 2025-09-30 10:54 - "Bug fix..." [75 msgs] → [Click to view trace] 164 | │ └─ 2025-09-30 10:38 - "Initial setup..." [3 msgs] → [Click to view trace] 165 | ├─ ► RAG Course Practice (21 sessions, last: 2025-10-04 18:22) 166 | ├─ ► Personal Bizos (100 sessions, last: 2025-10-05 11:01) 167 | └─ ► Other Projects... 168 | ``` 169 | 170 | **Implementation:** 171 | 172 | ```python 173 | @rt("/") 174 | def index(): 175 | """ 176 | Combined home page with accordion view of projects and sessions 177 | """ 178 | projects = scan_projects() # Returns list[Project] with sessions loaded 179 | 180 | return Titled("Claude Code Trace Viewer", 181 | Container( 182 | # Accordion container 183 | *[ProjectAccordion(project) for project in projects], 184 | cls="space-y-4")) 185 | 186 | def ProjectAccordion(project: Project): 187 | """ 188 | Accordion section for a single project with its sessions 189 | """ 190 | # Generate unique ID for accordion 191 | accordion_id = f"project-{project.folder_name}" 192 | 193 | return Div( 194 | # Project header (clickable to expand/collapse) 195 | Div( 196 | DivHStacked( 197 | UkIcon("chevron-down", cls="accordion-icon"), 198 | H3(project.name, cls="text-xl font-bold"), 199 | Span(f"{project.session_count} sessions", 200 | cls="text-sm text-gray-400"), 201 | Span(f"Last: {project.last_session.strftime('%Y-%m-%d %H:%M')}", 202 | cls="text-sm text-gray-500")), 203 | cls="project-header cursor-pointer p-4 bg-gray-800 rounded hover:bg-gray-700", 204 | hx_get=f"/toggle-project/{project.folder_name}", 205 | hx_target=f"#{accordion_id}-content", 206 | hx_swap="outerHTML"), 207 | 208 | # Session list (initially hidden or loaded via HTMX) 209 | Div( 210 | SessionList(project.sessions) if project.expanded else None, 211 | id=f"{accordion_id}-content", 212 | cls="accordion-content"), 213 | 214 | cls="project-accordion") 215 | 216 | def SessionList(sessions: list[Session]): 217 | """ 218 | List of sessions for a project (sorted by recency) 219 | """ 220 | return Div( 221 | *[SessionRow(session) for session in sessions], 222 | cls="session-list p-4 bg-gray-900 rounded-b") 223 | 224 | def SessionRow(session: Session): 225 | """ 226 | Single session row - clickable to open trace viewer 227 | """ 228 | return Div( 229 | DivHStacked( 230 | Span(session.timestamp.strftime("%Y-%m-%d %H:%M:%S"), 231 | cls="text-sm font-mono text-gray-400 w-40"), 232 | Span(session.summary[:60] + "..." if len(session.summary) > 60 else session.summary, 233 | cls="flex-1 text-gray-200"), 234 | Span(f"{session.message_count} msgs", 235 | cls="text-xs text-gray-500 w-20 text-right")), 236 | cls="session-row p-3 hover:bg-gray-800 rounded cursor-pointer border-b border-gray-700", 237 | hx_get=f"/session/{session.id}", 238 | hx_target="body", 239 | hx_swap="innerHTML", 240 | hx_push_url="true") # Update URL for browser back button 241 | 242 | @rt("/toggle-project/{project_name}") 243 | def toggle_project(project_name: str): 244 | """ 245 | HTMX endpoint to toggle project accordion (load/hide sessions) 246 | """ 247 | project = get_project(project_name) 248 | 249 | if project.expanded: 250 | # Collapse: return empty div 251 | return Div(id=f"project-{project_name}-content", cls="accordion-content") 252 | else: 253 | # Expand: return session list 254 | sessions = load_sessions(project_name) 255 | return SessionList(sessions) 256 | ``` 257 | 258 | ### 2. Trace Viewer - Full Session Detail 259 | 260 | **Route**: `/session/{session_id}` 261 | 262 | **Layout:** Two-panel design matching the screenshot 263 | 264 | #### Left Panel - Trace Tree 265 | 266 | Collapsible tree structure showing: 267 | 268 | - User messages (chat icon) 269 | - Assistant responses (bot icon) 270 | - Tool calls (tool icon, expandable) 271 | - Tool use details (nested) 272 | - Tool results (nested) 273 | - System messages (info icon) 274 | 275 | Each node displays: 276 | 277 | - Type icon 278 | - Event type label 279 | - Timing information 280 | - Token counts (for LLM messages with `usage` field) 281 | - Expand/collapse for nested content 282 | 283 | **Tree Structure:** 284 | 285 | ``` 286 | 📝 user - 10:39:34 287 | ├─ 🤖 assistant - 10:39:38 288 | │ ├─ 🔧 Read (tool call) 289 | │ │ └─ ✓ tool result 290 | │ └─ 🔧 Grep (tool call) 291 | │ └─ ✓ tool result 292 | ├─ 📝 user - 10:40:12 293 | └─ 🤖 assistant - 10:40:15 294 | ``` 295 | 296 | #### Right Panel - Detail View 297 | 298 | Shows full content when clicking any trace event: 299 | 300 | - **Tabs for Input/Output** (similar to screenshot) 301 | - **Syntax highlighting** for: 302 | - Markdown (user/assistant messages) 303 | - JSON (tool inputs/outputs) 304 | - Code blocks (within messages) 305 | - **Session Context Panel** (top of right panel): 306 | - Claude Code version (e.g., "v2.0.0") 307 | - Session timestamp 308 | - Working directory 309 | - Git branch 310 | - Current CLAUDE.md files (with warning they may have changed) 311 | - Git command helper for finding commit at session time 312 | - **Copy buttons** for content 313 | 314 | **HTMX Integration:** 315 | 316 | ```python 317 | @rt("/session/{session_id}") 318 | def session_trace(session_id: str): 319 | trace_events = parse_session_file(session_id) 320 | return Container( 321 | DivHStacked( 322 | TraceTree(trace_events), 323 | DetailPanel(trace_events[0] if trace_events else None))) 324 | 325 | @rt("/trace-node/{uuid}") 326 | def trace_node_detail(uuid: str, session_id: str): 327 | # HTMX endpoint to load detail panel for clicked node 328 | event = get_event_by_uuid(session_id, uuid) 329 | return DetailPanel(event) 330 | ``` 331 | 332 | ### 3. Session Context Panel - CLAUDE.md & Git Navigation 333 | 334 | **Purpose**: Help users understand the context that was active during a session and provide clues to find the exact project state. 335 | 336 | **What's Available in Session Files:** 337 | 338 | - ✅ Claude Code version (e.g., "2.0.0") - exact version running during session 339 | - ✅ Working directory (e.g., "/Users/gang/CLIProxyAPI") 340 | - ✅ Git branch (e.g., "main") 341 | - ✅ Timestamp (e.g., "2025-09-30 10:39:34") 342 | - ❌ NOT available: Commit hash, CLAUDE.md snapshot, system prompt 343 | 344 | **What the Viewer Shows:** 345 | 346 | **Session Context Panel (displayed at top of trace viewer):** 347 | 348 | ``` 349 | ┌──────────────────────────────────────────────────────────────┐ 350 | │ Session Context │ 351 | ├──────────────────────────────────────────────────────────────┤ 352 | │ ⚙️ Claude Code Version: v2.0.0 │ 353 | │ 📅 Session Time: 2025-09-30 10:39:34 │ 354 | │ 📂 Working Directory: /Users/gang/CLIProxyAPI │ 355 | │ 🌿 Git Branch: main │ 356 | │ │ 357 | │ 📄 CLAUDE.md Files (current state - may have changed): │ 358 | │ [View Global CLAUDE.md] [View Project CLAUDE.md] │ 359 | │ │ 360 | │ 💡 Find exact project state at session time: │ 361 | │ cd /Users/gang/CLIProxyAPI │ 362 | │ git log --before="2025-09-30 10:40:00" \ │ 363 | │ --after="2025-09-30 10:35:00" -n 5 │ 364 | │ [Copy Command] │ 365 | └──────────────────────────────────────────────────────────────┘ 366 | ``` 367 | 368 | **Implementation:** 369 | 370 | ```python 371 | def get_session_context(session_id: str) -> dict: 372 | """ 373 | Retrieve session context including CLAUDE.md files and git navigation hints 374 | 375 | Input data sources: Session JSONL file, local CLAUDE.md files 376 | Output destinations: Session context dictionary 377 | Dependencies: pathlib, datetime 378 | Key exports: get_session_context() 379 | Side effects: Reads CLAUDE.md files from disk 380 | """ 381 | # Parse session to get metadata 382 | events = parse_session_file(session_id) 383 | first_event = events[0] if events else {} 384 | 385 | context = { 386 | 'version': first_event.get('version'), 387 | 'timestamp': first_event.get('timestamp'), 388 | 'cwd': first_event.get('cwd'), 389 | 'git_branch': first_event.get('git_branch'), 390 | 'session_id': first_event.get('sessionId'), 391 | 'project_claude_md': None, 392 | 'global_claude_md': None, 393 | 'git_command': None 394 | } 395 | 396 | # Try to read project CLAUDE.md (current state) 397 | if context['cwd']: 398 | project_claude_path = Path(context['cwd']) / 'CLAUDE.md' 399 | if project_claude_path.exists(): 400 | context['project_claude_md'] = project_claude_path.read_text() 401 | 402 | # Read global CLAUDE.md (current state) 403 | global_claude_path = Path.home() / '.claude' / 'CLAUDE.md' 404 | if global_claude_path.exists(): 405 | context['global_claude_md'] = global_claude_path.read_text() 406 | 407 | # Generate git command for finding commit at session time 408 | if context['timestamp'] and context['cwd']: 409 | timestamp = datetime.fromisoformat(context['timestamp']) 410 | before = (timestamp + timedelta(minutes=5)).strftime('%Y-%m-%d %H:%M:%S') 411 | after = (timestamp - timedelta(minutes=5)).strftime('%Y-%m-%d %H:%M:%S') 412 | 413 | context['git_command'] = ( 414 | f'cd {context["cwd"]}\n' 415 | f'git log --before="{before}" --after="{after}" -n 5' 416 | ) 417 | 418 | return context 419 | 420 | def SessionContextPanel(context: dict): 421 | """ 422 | Displays session context with CLAUDE.md and git navigation 423 | """ 424 | return Card( 425 | H3("Session Context", cls="text-lg font-bold mb-4"), 426 | 427 | # Metadata 428 | Div( 429 | P(f"⚙️ Claude Code Version: v{context['version']}"), 430 | P(f"📅 Session Time: {context['timestamp']}"), 431 | P(f"📂 Working Directory: {context['cwd']}"), 432 | P(f"🌿 Git Branch: {context['git_branch']}"), 433 | cls="space-y-2 mb-4"), 434 | 435 | # CLAUDE.md files 436 | Div( 437 | P("📄 CLAUDE.md Files (current state - may have changed):", 438 | cls="font-semibold text-yellow-400 mb-2"), 439 | DivHStacked( 440 | Button("View Global CLAUDE.md", 441 | hx_get=f"/view-claude-md?type=global", 442 | hx_target="#claude-md-modal", 443 | cls="btn-sm"), 444 | Button("View Project CLAUDE.md", 445 | hx_get=f"/view-claude-md?type=project&cwd={context['cwd']}", 446 | hx_target="#claude-md-modal", 447 | cls="btn-sm") if context['project_claude_md'] else None, 448 | cls="gap-2"), 449 | cls="mb-4"), 450 | 451 | # Git navigation helper 452 | Div( 453 | P("💡 Find exact project state at session time:", 454 | cls="font-semibold mb-2"), 455 | Pre( 456 | Code(context['git_command'], cls="text-sm"), 457 | cls="bg-gray-900 p-3 rounded"), 458 | Button("Copy Command", 459 | onclick=f"navigator.clipboard.writeText({repr(context['git_command'])})", 460 | cls="btn-sm mt-2"), 461 | cls="mb-4"), 462 | 463 | cls="bg-gray-800 p-4 rounded mb-4") 464 | 465 | @rt("/view-claude-md") 466 | def view_claude_md(type: str, cwd: str = None): 467 | """ 468 | HTMX endpoint to show CLAUDE.md content in a modal 469 | """ 470 | if type == "global": 471 | path = Path.home() / '.claude' / 'CLAUDE.md' 472 | title = "Global CLAUDE.md" 473 | else: 474 | path = Path(cwd) / 'CLAUDE.md' 475 | title = f"Project CLAUDE.md ({Path(cwd).name})" 476 | 477 | if not path.exists(): 478 | content = "File not found" 479 | else: 480 | content = path.read_text() 481 | 482 | return Modal( 483 | H3(title, cls="text-xl font-bold mb-4"), 484 | P("⚠️ Warning: This is the CURRENT state. It may have changed since the session.", 485 | cls="text-yellow-400 mb-4"), 486 | Pre(Code(content, cls="language-markdown"), 487 | cls="bg-gray-900 p-4 rounded overflow-auto max-h-96"), 488 | Button("Close", cls="btn-primary mt-4"), 489 | id="claude-md-modal") 490 | ``` 491 | 492 | **Key Features:** 493 | 494 | 1. **Claude Code Version Display**: Shows exact version (e.g., "v2.0.0") that was running 495 | 2. **Current CLAUDE.md Files**: Shows current state with clear warning 496 | 3. **Git Navigation Helper**: Provides ready-to-copy git command to find commits around session time 497 | 4. **Timestamp-Based Search**: Uses session timestamp ±5 minutes to narrow down commits 498 | 5. **Modal View**: Click to view full CLAUDE.md content without leaving trace viewer 499 | 500 | **User Workflow:** 501 | 502 | 1. Open session trace viewer 503 | 2. See session context panel at top 504 | 3. View current CLAUDE.md as reference 505 | 4. Copy git command to find exact commit 506 | 5. Run command in terminal to find commit hash 507 | 6. Checkout commit or browse on GitHub to see exact CLAUDE.md state 508 | 509 | ### 4. Timing & Token Usage Features 510 | 511 | **Purpose**: Provide detailed performance metrics and token consumption analysis for each session. 512 | 513 | **What's Available from Session Files:** 514 | 515 | ✅ **Timestamps** - Every event has ISO-formatted timestamp 516 | 517 | ```json 518 | "timestamp": "2025-09-30T02:39:34.496Z" 519 | ``` 520 | 521 | ✅ **Token Usage** - Assistant messages include detailed usage metadata 522 | 523 | ```json 524 | "usage": { 525 | "input_tokens": 4, 526 | "cache_creation_input_tokens": 24171, 527 | "cache_read_input_tokens": 0, 528 | "output_tokens": 5, 529 | "service_tier": "standard" 530 | } 531 | ``` 532 | 533 | ❌ **NOT Available** - No explicit duration field (we calculate it) 534 | 535 | **Calculated Metrics:** 536 | 537 | 1. **Response Time**: Duration between user message and assistant response 538 | 2. **Tool Execution Time**: Duration between tool call and tool result 539 | 3. **Total Session Duration**: First event timestamp to last event timestamp 540 | 4. **Total Token Consumption**: Sum of all tokens across entire session 541 | 542 | **Display in Trace Tree:** 543 | 544 | ``` 545 | 📝 user - 10:39:34 (3.6s) ← Duration to next event 546 | └─ 🤖 assistant - 10:39:38 ← 24,180 tok (hover for breakdown) 547 | ├─ 🔧 Read - 10:39:38 (0.3s) 548 | │ └─ ✓ result - 10:39:39 549 | └─ 🔧 Grep - 10:39:39 (0.2s) 550 | └─ ✓ result - 10:39:39 551 | 552 | 📝 user - 10:40:12 (4.2s) 553 | └─ 🤖 assistant - 10:40:16 ← 1,661 tok 554 | ``` 555 | 556 | **Display in Detail Panel:** 557 | 558 | When clicking an assistant message with token usage: 559 | 560 | ``` 561 | 🪙 Token Usage: 562 | • Input Tokens: 4 563 | • Cache Creation: 24,171 564 | • Cache Read: 0 565 | • Output Tokens: 5 566 | • Total: 24,180 567 | ``` 568 | 569 | **Token Breakdown Explanation:** 570 | 571 | - **Input Tokens**: Regular input tokens processed 572 | - **Cache Creation**: Tokens used to create prompt cache (first use) 573 | - **Cache Read**: Tokens read from prompt cache (subsequent uses) 574 | - **Output Tokens**: Tokens generated in response 575 | 576 | **Implementation:** 577 | 578 | ```python 579 | # In TraceEvent model 580 | @property 581 | def token_breakdown(self) -> dict: 582 | """Detailed token usage breakdown""" 583 | if not self.usage: 584 | return {} 585 | 586 | return { 587 | 'input_tokens': self.usage.get('input_tokens', 0), 588 | 'cache_creation_input_tokens': self.usage.get('cache_creation_input_tokens', 0), 589 | 'cache_read_input_tokens': self.usage.get('cache_read_input_tokens', 0), 590 | 'output_tokens': self.usage.get('output_tokens', 0), 591 | 'total_tokens': ( 592 | self.usage.get('input_tokens', 0) + 593 | self.usage.get('cache_creation_input_tokens', 0) + 594 | self.usage.get('cache_read_input_tokens', 0) + 595 | self.usage.get('output_tokens', 0) 596 | ) 597 | } 598 | 599 | def duration_to(self, next_event: 'TraceEvent') -> float: 600 | """Calculate duration in seconds to next event""" 601 | if not next_event or not next_event.timestamp: 602 | return 0 603 | delta = next_event.timestamp - self.timestamp 604 | return delta.total_seconds() 605 | ``` 606 | 607 | **Session Summary Statistics:** 608 | 609 | At the top of the trace viewer, show: 610 | 611 | - **Total Duration**: 5m 42s 612 | - **Total Turns**: 12 (user + assistant exchanges) 613 | - **Total Tokens**: 156,234 614 | - **Average Response Time**: 3.8s 615 | 616 | ## Data Models 617 | 618 | ### Project Model 619 | 620 | ```python 621 | @dataclass 622 | class Project: 623 | name: str # Human-readable name (e.g., "CLIProxyAPI") 624 | path: str # Full path to project folder 625 | folder_name: str # Original folder name 626 | session_count: int # Number of .jsonl files 627 | last_session: datetime # Most recent session timestamp 628 | ``` 629 | 630 | ### Session Model 631 | 632 | ```python 633 | @dataclass 634 | class Session: 635 | id: str # Session UUID (filename without .jsonl) 636 | project: str # Project folder name 637 | summary: str # Session summary from first line 638 | timestamp: datetime # First event timestamp 639 | message_count: int # Total number of events 640 | first_user_message: str # Preview of first user message 641 | file_path: str # Full path to .jsonl file 642 | ``` 643 | 644 | ### TraceEvent Model 645 | 646 | ```python 647 | @dataclass 648 | class TraceEvent: 649 | uuid: str # Event UUID 650 | parent_uuid: str | None # Parent event UUID (for tree) 651 | type: str # user, assistant, system, etc. 652 | timestamp: datetime # Event timestamp 653 | content: Any # Message content or system content 654 | 655 | # Optional fields 656 | message: dict | None # Full message object (user/assistant) 657 | tool_calls: list[dict] | None # Tool calls (for assistant messages) 658 | tool_use_id: str | None # Tool use ID (for tool results) 659 | usage: dict | None # Token usage (for LLM calls) 660 | session_id: str # Session identifier 661 | cwd: str | None # Working directory 662 | git_branch: str | None # Git branch 663 | 664 | # Tree structure 665 | children: list['TraceEvent'] # Child events 666 | 667 | @property 668 | def token_count(self) -> int: 669 | """Total tokens (input + output)""" 670 | if self.usage: 671 | return self.usage.get('input_tokens', 0) + \ 672 | self.usage.get('output_tokens', 0) 673 | return 0 674 | 675 | @property 676 | def has_output(self) -> bool: 677 | """Check if event has output content""" 678 | return bool(self.message and 679 | self.message.get('role') == 'assistant') 680 | 681 | @property 682 | def token_breakdown(self) -> dict: 683 | """ 684 | Detailed token usage breakdown 685 | 686 | Returns dict with: 687 | - input_tokens: Regular input tokens 688 | - cache_creation_input_tokens: Tokens used to create cache 689 | - cache_read_input_tokens: Tokens read from cache 690 | - output_tokens: Generated output tokens 691 | - total_tokens: Sum of all tokens 692 | """ 693 | if not self.usage: 694 | return {} 695 | 696 | return { 697 | 'input_tokens': self.usage.get('input_tokens', 0), 698 | 'cache_creation_input_tokens': self.usage.get('cache_creation_input_tokens', 0), 699 | 'cache_read_input_tokens': self.usage.get('cache_read_input_tokens', 0), 700 | 'output_tokens': self.usage.get('output_tokens', 0), 701 | 'total_tokens': ( 702 | self.usage.get('input_tokens', 0) + 703 | self.usage.get('cache_creation_input_tokens', 0) + 704 | self.usage.get('cache_read_input_tokens', 0) + 705 | self.usage.get('output_tokens', 0) 706 | ) 707 | } 708 | 709 | def duration_to(self, next_event: 'TraceEvent') -> float: 710 | """Calculate duration in seconds to next event""" 711 | if not next_event or not next_event.timestamp: 712 | return 0 713 | delta = next_event.timestamp - self.timestamp 714 | return delta.total_seconds() 715 | ``` 716 | 717 | ## Core Functions 718 | 719 | ### JSONL Parser 720 | 721 | ```python 722 | def parse_session_file(session_id: str) -> list[TraceEvent]: 723 | """ 724 | Parse JSONL file into tree of TraceEvents 725 | 726 | Input data sources: /Users/gang/.claude/projects/*/*.jsonl 727 | Output destinations: In-memory TraceEvent tree structure 728 | Dependencies: json, pathlib 729 | Key exports: parse_session_file() 730 | Side effects: Reads files from disk 731 | """ 732 | file_path = find_session_file(session_id) 733 | events = [] 734 | 735 | with open(file_path) as f: 736 | for line in f: 737 | data = json.loads(line) 738 | 739 | # Skip non-trace events 740 | if data['type'] == 'file-history-snapshot': 741 | continue 742 | 743 | # Create TraceEvent from JSON 744 | if data['type'] in ['user', 'assistant', 'system']: 745 | events.append(TraceEvent.from_json(data)) 746 | 747 | # Build tree structure using parent_uuid 748 | return build_event_tree(events) 749 | ``` 750 | 751 | ### Tree Builder 752 | 753 | ```python 754 | def build_event_tree(events: list[TraceEvent]) -> list[TraceEvent]: 755 | """ 756 | Convert flat list to tree using parent_uuid relationships 757 | 758 | Algorithm: 759 | 1. Create UUID -> Event mapping 760 | 2. For each event, attach to parent or mark as root 761 | 3. Return list of root events 762 | """ 763 | event_map = {e.uuid: e for e in events} 764 | roots = [] 765 | 766 | for event in events: 767 | if event.parent_uuid and event.parent_uuid in event_map: 768 | # Attach to parent 769 | event_map[event.parent_uuid].children.append(event) 770 | else: 771 | # No parent = root node 772 | roots.append(event) 773 | 774 | return roots 775 | ``` 776 | 777 | ### Project Scanner 778 | 779 | ```python 780 | def scan_projects() -> list[Project]: 781 | """ 782 | Scan /Users/gang/.claude/projects/ for all project folders 783 | 784 | Returns list of Project objects with metadata 785 | """ 786 | projects_dir = Path("/Users/gang/.claude/projects") 787 | projects = [] 788 | 789 | for project_folder in projects_dir.iterdir(): 790 | if not project_folder.is_dir(): 791 | continue 792 | 793 | # Count session files 794 | session_files = list(project_folder.glob("*.jsonl")) 795 | if not session_files: 796 | continue 797 | 798 | # Get last session timestamp 799 | last_session = max( 800 | get_session_timestamp(f) for f in session_files) 801 | 802 | # Clean up project name 803 | name = project_folder.name.replace('-Users-gang-', '') \ 804 | .replace('-', ' ').title() 805 | 806 | projects.append(Project( 807 | name=name, 808 | path=str(project_folder), 809 | folder_name=project_folder.name, 810 | session_count=len(session_files), 811 | last_session=last_session 812 | )) 813 | 814 | return sorted(projects, key=lambda p: p.last_session, reverse=True) 815 | ``` 816 | 817 | ## UI Components 818 | 819 | ### TraceTree Component 820 | 821 | ```python 822 | def TraceTree(events: list[TraceEvent], session_id: str): 823 | """ 824 | Renders left panel tree structure 825 | 826 | Creates nested navigation with expandable nodes 827 | """ 828 | return Div( 829 | NavContainer( 830 | *[TraceNode(e, level=0, session_id=session_id) 831 | for e in events], 832 | cls="trace-tree"), 833 | cls="trace-tree-container", 834 | style="width: 40%; height: 100vh; overflow-y: auto; border-right: 1px solid #333;") 835 | ``` 836 | 837 | ### TraceNode Component 838 | 839 | ```python 840 | def TraceNode(event: TraceEvent, level: int, session_id: str, next_sibling: TraceEvent = None): 841 | """ 842 | Single tree node (recursive for children) 843 | 844 | Displays: 845 | - Icon based on event type 846 | - Event label 847 | - Timestamp 848 | - Duration to next event (if available) 849 | - Token count with breakdown (if applicable) 850 | - Nested children 851 | """ 852 | icon = get_icon_for_type(event.type) 853 | label = get_label_for_event(event) 854 | time_str = event.timestamp.strftime("%H:%M:%S") 855 | 856 | # Calculate duration to next sibling (for response time) 857 | duration_str = None 858 | if next_sibling: 859 | duration = event.duration_to(next_sibling) 860 | if duration > 0: 861 | duration_str = Span(f"({duration:.1f}s)", 862 | cls="text-xs text-yellow-400 font-semibold") 863 | 864 | # Token display with breakdown 865 | token_display = None 866 | if event.token_count > 0: 867 | breakdown = event.token_breakdown 868 | # Tooltip with full breakdown 869 | tooltip = ( 870 | f"Input: {breakdown['input_tokens']} | " 871 | f"Cache Create: {breakdown['cache_creation_input_tokens']} | " 872 | f"Cache Read: {breakdown['cache_read_input_tokens']} | " 873 | f"Output: {breakdown['output_tokens']}" 874 | ) 875 | token_display = Span( 876 | f"{breakdown['total_tokens']} tok", 877 | cls="text-xs text-gray-400", 878 | title=tooltip) # Hover to see breakdown 879 | 880 | # Main node content 881 | node_content = DivHStacked( 882 | UkIcon(icon, width=16, height=16), 883 | Span(label, cls="font-medium text-sm"), 884 | Span(time_str, cls="text-xs text-gray-500"), 885 | duration_str, # Duration if available 886 | token_display, # Token count if available 887 | cls="cursor-pointer hover:bg-gray-800 p-2 rounded gap-2", 888 | hx_get=f"/trace-node/{event.uuid}?session_id={session_id}", 889 | hx_target="#detail-panel", 890 | hx_swap="innerHTML") 891 | 892 | # If has children, create nested nav and pass next sibling info 893 | if event.children: 894 | children_nodes = [] 895 | for i, child in enumerate(event.children): 896 | next_child = event.children[i+1] if i+1 < len(event.children) else None 897 | children_nodes.append(TraceNode(child, level+1, session_id, next_child)) 898 | 899 | return NavParentLi( 900 | node_content, 901 | NavContainer(*children_nodes, parent=False), 902 | style=f"margin-left: {level * 20}px") 903 | else: 904 | return Li(node_content, 905 | style=f"margin-left: {level * 20}px") 906 | 907 | 908 | def get_icon_for_type(event_type: str) -> str: 909 | """Map event type to Lucide icon name""" 910 | icons = { 911 | 'user': 'message-circle', 912 | 'assistant': 'bot', 913 | 'system': 'info', 914 | 'tool_call': 'wrench', 915 | 'tool_result': 'check-circle' 916 | } 917 | return icons.get(event_type, 'circle') 918 | 919 | 920 | def get_label_for_event(event: TraceEvent) -> str: 921 | """Generate human-readable label for event""" 922 | if event.type == 'user': 923 | # Truncate first line of message 924 | content = event.message.get('content', '')[:50] 925 | return f"User: {content}..." 926 | elif event.type == 'assistant': 927 | if event.tool_calls: 928 | return f"Assistant ({len(event.tool_calls)} tools)" 929 | return "Assistant" 930 | elif event.type == 'system': 931 | return "System" 932 | return event.type.title() 933 | ``` 934 | 935 | ### DetailPanel Component 936 | 937 | ```python 938 | def DetailPanel(event: TraceEvent | None, next_event: TraceEvent = None): 939 | """ 940 | Right panel showing event details in scrollable vertical layout 941 | 942 | Layout (no tabs, everything scrollable): 943 | 1. Metrics section at top (duration, tokens, timing) 944 | 2. Input section (always shown) 945 | 3. Output section (if available, shown below input) 946 | 4. Additional metadata at bottom 947 | 948 | Displays: 949 | - Metrics (duration, tokens) - AT THE TOP 950 | - Input content with syntax highlighting 951 | - Output content with syntax highlighting (below input) 952 | - Metadata (timestamp, UUID, etc) - AT THE BOTTOM 953 | - Copy buttons for each section 954 | - Everything is scrollable vertically (no tabs) 955 | """ 956 | if not event: 957 | return Div( 958 | P("Select an event from the tree to view details", 959 | cls="text-gray-500 text-center mt-20"), 960 | id="detail-panel", 961 | cls="w-3/5 p-6") 962 | 963 | # === METRICS SECTION (AT TOP, like the screenshot) === 964 | metrics_section = None 965 | if event.type == 'assistant' and event.token_count > 0: 966 | breakdown = event.token_breakdown 967 | 968 | # Calculate duration from previous event (if available) 969 | duration_display = None 970 | if next_event: 971 | duration = event.duration_to(next_event) 972 | duration_display = f"{duration:.2f}s" 973 | 974 | # Metrics grid (3 columns like screenshot) 975 | metrics_section = Div( 976 | H3("📊 Metrics", cls="text-lg font-semibold mb-3"), 977 | 978 | # Row 1: Start time, Duration, Total tokens 979 | Div( 980 | Div( 981 | Span("Start", cls="text-xs text-gray-500 block"), 982 | Span(event.timestamp.strftime("%b %d %I:%M:%S %p"), 983 | cls="text-sm font-semibold")), 984 | Div( 985 | Span("Duration", cls="text-xs text-gray-500 block"), 986 | Span(duration_display or "N/A", cls="text-sm font-semibold")), 987 | Div( 988 | Span("Total tokens", cls="text-xs text-gray-500 block"), 989 | Span(f"{breakdown['total_tokens']:,}", cls="text-sm font-semibold")), 990 | cls="grid grid-cols-3 gap-4 mb-4"), 991 | 992 | # Row 2: Token breakdown (Prompt tokens, Completion tokens, Total) 993 | Div( 994 | Div( 995 | Span("Prompt tokens", cls="text-xs text-gray-500 block"), 996 | Span(f"{breakdown['input_tokens'] + breakdown['cache_creation_input_tokens'] + breakdown['cache_read_input_tokens']:,}", 997 | cls="text-sm font-semibold")), 998 | Div( 999 | Span("Completion tokens", cls="text-xs text-gray-500 block"), 1000 | Span(f"{breakdown['output_tokens']:,}", cls="text-sm font-semibold")), 1001 | Div( 1002 | Span("Total tokens", cls="text-xs text-gray-500 block"), 1003 | Span(f"{breakdown['total_tokens']:,}", cls="text-sm font-semibold")), 1004 | cls="grid grid-cols-3 gap-4 mb-4"), 1005 | 1006 | # Expandable: Detailed cache breakdown 1007 | Details( 1008 | Summary("Cache Breakdown", cls="cursor-pointer text-xs text-gray-400 hover:text-gray-200"), 1009 | Ul( 1010 | Li(f"Input Tokens: {breakdown['input_tokens']:,}"), 1011 | Li(f"Cache Creation: {breakdown['cache_creation_input_tokens']:,}"), 1012 | Li(f"Cache Read: {breakdown['cache_read_input_tokens']:,}"), 1013 | Li(f"Output Tokens: {breakdown['output_tokens']:,}"), 1014 | cls="ml-4 mt-2 text-xs space-y-1"), 1015 | cls="mt-2"), 1016 | 1017 | cls="p-4 bg-gray-900 rounded mb-4 border border-gray-700") 1018 | 1019 | # === INPUT & OUTPUT SECTIONS (No tabs, just scrollable vertical layout) === 1020 | 1021 | # Input section (always present) 1022 | input_section = Section( 1023 | DivHStacked( 1024 | H3("➡️ Input", cls="text-lg font-bold"), 1025 | Button("Copy", cls="btn-sm", 1026 | onclick=f"navigator.clipboard.writeText({repr(event.input_content)})")), 1027 | CodeBlock(event.input_content, language="markdown"), 1028 | cls="mb-6") 1029 | 1030 | # Output section (if assistant message) 1031 | output_section = None 1032 | if event.has_output: 1033 | output_section = Section( 1034 | DivHStacked( 1035 | H3("⬅️ Output", cls="text-lg font-bold"), 1036 | Button("Copy", cls="btn-sm", 1037 | onclick=f"navigator.clipboard.writeText({repr(event.output_content)})")), 1038 | CodeBlock(event.output_content, language="json"), 1039 | cls="mb-6") 1040 | 1041 | # === ADDITIONAL METADATA (at bottom) === 1042 | metadata = Div( 1043 | P(f"⏱️ Timestamp: {event.timestamp.isoformat()}"), 1044 | P(f"🔑 UUID: {event.uuid}"), 1045 | P(f"📋 Session: {event.session_id}") if event.session_id else None, 1046 | P(f"📂 Directory: {event.cwd}") if event.cwd else None, 1047 | P(f"🌿 Branch: {event.git_branch}") if event.git_branch else None, 1048 | cls="text-sm text-gray-400 mt-4 p-4 bg-gray-900 rounded space-y-2") 1049 | 1050 | return Div( 1051 | metrics_section, # METRICS AT TOP 1052 | input_section, # INPUT (always shown) 1053 | output_section, # OUTPUT (if available) - shown below input 1054 | metadata, # METADATA AT BOTTOM 1055 | id="detail-panel", 1056 | cls="w-3/5 p-6 overflow-y-auto", 1057 | style="height: 100vh;") 1058 | 1059 | 1060 | def CodeBlock(content: str, language: str = "text"): 1061 | """Syntax-highlighted code block""" 1062 | return Pre( 1063 | Code(content, cls=f"language-{language}"), 1064 | cls="bg-gray-900 p-4 rounded overflow-x-auto") 1065 | ``` 1066 | 1067 | ## Visual Design 1068 | 1069 | ### Layout 1070 | 1071 | **Home Page (Accordion View):** 1072 | 1073 | ``` 1074 | ┌────────────────────────────────────────────────────────────┐ 1075 | │ Claude Code Trace Viewer │ 1076 | ├────────────────────────────────────────────────────────────┤ 1077 | │ ▼ CLIProxyAPI 4 sessions Last: 11:11 │ 1078 | │ ├─ 2025-09-30 11:11 Session about... [15 msgs] → │ 1079 | │ ├─ 2025-09-30 10:58 Model setup... [10 msgs] → │ 1080 | │ ├─ 2025-09-30 10:54 Bug fix... [75 msgs] → │ 1081 | │ └─ 2025-09-30 10:38 Initial setup... [3 msgs] → │ 1082 | ├────────────────────────────────────────────────────────────┤ 1083 | │ ► RAG Course Practice 21 sessions Last: 18:22 │ 1084 | ├────────────────────────────────────────────────────────────┤ 1085 | │ ► Personal Bizos 100 sessions Last: 11:01 │ 1086 | ├────────────────────────────────────────────────────────────┤ 1087 | │ ► Other Projects... │ 1088 | └────────────────────────────────────────────────────────────┘ 1089 | ``` 1090 | 1091 | **Two-Panel Design (Scrollable Vertical Layout):** 1092 | 1093 | ```` 1094 | ┌──────────────────────────────────────────────────────────────────────┐ 1095 | │ Trace Tree (40%) │ Detail Panel (60%) - All Scrollable │ 1096 | │ │ │ 1097 | │ 📝 user - 10:39:34 (3.6s) │ 📊 Metrics │ 1098 | │ └─ 🤖 assistant │ ┌──────────────────────────────────┐ │ 1099 | │ 10:39:38 │ │ Start Duration Total tok │ │ 1100 | │ 24,180 tok │ │ Feb 19 10:08 16.04s 2,241 │ │ 1101 | │ ├─ 🔧 Read (0.3s) │ │ Prompt tok Completion Total │ │ 1102 | │ │ └─ ✓ result │ │ 694 1,547 2,241 │ │ 1103 | │ └─ 🔧 Grep (0.2s) │ └──────────────────────────────────┘ │ 1104 | │ └─ ✓ result │ │ 1105 | │ │ ➡️ Input [Copy] │ 1106 | │ 📝 user - 10:40:12 (4.2s) │ ┌──────────────────────────────────┐ │ 1107 | │ └─ 🤖 assistant │ │ ```markdown │ │ 1108 | │ 10:40:16 │ │ User message content here... │ │ 1109 | │ 1,661 tok │ │ ``` │ │ 1110 | │ │ └──────────────────────────────────┘ │ 1111 | │ │ │ 1112 | │ [scrollable] │ ⬅️ Output [Copy] │ 1113 | │ │ ┌──────────────────────────────────┐ │ 1114 | │ │ │ ```json │ │ 1115 | │ │ │ { "type": "text", │ │ 1116 | │ │ │ "content": "Response..." } │ │ 1117 | │ │ │ ``` │ │ 1118 | │ │ └──────────────────────────────────┘ │ 1119 | │ │ │ 1120 | │ │ ⏱️ Timestamp: 2025-09-30T02:39:34Z │ 1121 | │ │ 🔑 UUID: abc-123-def │ 1122 | │ │ │ 1123 | │ │ [scroll for more...] │ 1124 | └──────────────────────────────────────────────────────────────────────┘ 1125 | ```` 1126 | 1127 | **Visual Elements:** 1128 | 1129 | - **No tabs**: Input and Output shown sequentially in vertical scroll 1130 | - **Timestamps**: Gray text showing `HH:MM:SS` 1131 | - **Durations**: Yellow text in parentheses `(3.6s)` 1132 | - **Token counts**: Gray text, hover shows breakdown tooltip 1133 | - **Icons**: Lucide icons for event types 1134 | - **Layout**: Metrics → Input → Output → Metadata (all scrollable) 1135 | 1136 | ### Styling 1137 | 1138 | **Dark Theme:** 1139 | 1140 | - Background: `#1a1a1a` (dark gray) 1141 | - Text: `#e5e5e5` (light gray) 1142 | - Borders: `#333` (medium gray) 1143 | - Hover: `#2a2a2a` (slightly lighter) 1144 | 1145 | **Color Coding:** 1146 | 1147 | - **Timestamps**: `#9ca3af` (gray-400) - subtle, secondary info 1148 | - **Durations**: `#fbbf24` (yellow-400) - highlighted, attention-grabbing 1149 | - **Token counts**: `#9ca3af` (gray-400) - with yellow highlight on hover 1150 | - **Token breakdown**: `#fbbf24` (yellow-400) for heading, white for values 1151 | 1152 | **Tree Indentation:** 1153 | 1154 | ```css 1155 | .trace-tree li { 1156 | padding-left: 20px; 1157 | border-left: 1px solid #333; 1158 | } 1159 | 1160 | .trace-tree li:hover { 1161 | background-color: #2a2a2a; 1162 | } 1163 | ``` 1164 | 1165 | **Syntax Highlighting:** 1166 | 1167 | - Use Prism.js or Highlight.js 1168 | - Include via CDN in FastHTML `hdrs` parameter 1169 | - Dark theme: `prism-tomorrow` or `atom-one-dark` 1170 | 1171 | ### Icons 1172 | 1173 | Using Lucide icons via `UkIcon` from MonsterUI: 1174 | 1175 | - `message-circle` - User messages 1176 | - `bot` - Assistant responses 1177 | - `wrench` - Tool calls 1178 | - `check-circle` - Tool results 1179 | - `info` - System messages 1180 | - `folder` - Projects 1181 | - `file-text` - Sessions 1182 | 1183 | ## Dependencies 1184 | 1185 | ### requirements.txt 1186 | 1187 | ``` 1188 | python-fasthtml>=0.6.0 1189 | monsterui>=0.1.0 1190 | python-dateutil>=2.8.0 1191 | ``` 1192 | 1193 | ### Installation 1194 | 1195 | ```bash 1196 | pip install python-fasthtml monsterui python-dateutil 1197 | ``` 1198 | 1199 | ## Running the Application 1200 | 1201 | ### Development Mode 1202 | 1203 | ```bash 1204 | cd /Users/gang/claude-trace-viewer 1205 | python app.py 1206 | ``` 1207 | 1208 | Visit: `http://localhost:5001` 1209 | 1210 | ### Configuration 1211 | 1212 | Default settings in `app.py`: 1213 | 1214 | ```python 1215 | PROJECTS_DIR = Path("/Users/gang/.claude/projects") 1216 | PORT = 5001 1217 | DEBUG = True 1218 | ``` 1219 | 1220 | ## User Flow 1221 | 1222 | ### Complete User Journey 1223 | 1224 | **1. Start the Viewer** 1225 | 1226 | ```bash 1227 | cd /Users/gang/claude-trace-viewer 1228 | python app.py 1229 | # Opens http://localhost:5001 1230 | ``` 1231 | 1232 | **2. Home Page - See All Projects & Sessions (Single Page)** 1233 | 1234 | - View accordion list of all projects 1235 | - Click project header to expand/collapse sessions 1236 | - Sessions sorted by recency (newest first) 1237 | - Click any session row to open trace viewer 1238 | 1239 | **3. Trace Viewer Page - Two-Panel Layout** 1240 | 1241 | - **Top**: Session Context Panel 1242 | - Claude Code version 1243 | - Timestamp 1244 | - Working directory 1245 | - Git branch 1246 | - [View CLAUDE.md] buttons 1247 | - Git command helper 1248 | - **Left Panel**: Trace Tree (40%) 1249 | - Click nodes to select events 1250 | - Expandable tree structure 1251 | - **Right Panel**: Detail View (60%) 1252 | - Shows selected event details 1253 | - Input/Output tabs 1254 | - Syntax highlighting 1255 | - Metadata display 1256 | 1257 | **4. View Session Context** 1258 | 1259 | - Click **[View Global CLAUDE.md]** → Modal with current global CLAUDE.md 1260 | - Click **[View Project CLAUDE.md]** → Modal with current project CLAUDE.md 1261 | - Click **[Copy Command]** → Copy git log command 1262 | - Run command in terminal → Find commit hash 1263 | - Use commit hash → View CLAUDE.md at that exact time 1264 | 1265 | **5. Navigate Back** 1266 | 1267 | - Browser back button → Returns to home page 1268 | - Or click home link (if added) 1269 | 1270 | ### Key User Actions 1271 | 1272 | | Action | Result | 1273 | | ---------------------- | ----------------------------------- | 1274 | | Click project header | Expands/collapses sessions | 1275 | | Click session row | Opens trace viewer | 1276 | | Click trace tree node | Updates detail panel | 1277 | | Click [View CLAUDE.md] | Shows modal with current CLAUDE.md | 1278 | | Click [Copy Command] | Copies git log command to clipboard | 1279 | | Browser back | Returns to previous page | 1280 | 1281 | ## Implementation Checklist 1282 | 1283 | - [ ] Create project structure (`app.py`, `models.py`, `components.py`) 1284 | - [ ] Install dependencies (`requirements.txt`) 1285 | - [ ] Implement data models (Project, Session, TraceEvent) 1286 | - [ ] Build JSONL parser (`parse_session_file()`) 1287 | - [ ] Build tree builder (`build_event_tree()`) 1288 | - [ ] Implement project scanner (`scan_projects()`) 1289 | - [ ] Create FastHTML app with routes: 1290 | - [ ] `/` - Home page (accordion with projects + sessions) 1291 | - [ ] `/toggle-project/{project_name}` - HTMX endpoint to expand/collapse 1292 | - [ ] `/session/{session_id}` - Trace viewer 1293 | - [ ] `/trace-node/{uuid}` - HTMX detail endpoint 1294 | - [ ] Build UI components: 1295 | - [ ] `ProjectAccordion` - Expandable project section 1296 | - [ ] `SessionList` - List of sessions (sorted by recency) 1297 | - [ ] `SessionRow` - Individual session row 1298 | - [ ] `TraceTree` - Left panel tree structure 1299 | - [ ] `TraceNode` - Single tree node (recursive) 1300 | - [ ] `DetailPanel` - Right panel detail view 1301 | - [ ] `CodeBlock` - Syntax highlighted code 1302 | - [ ] Add custom CSS (`static/custom.css`) 1303 | - [ ] Add syntax highlighting (Prism.js) 1304 | - [ ] Test with real session files 1305 | - [ ] Add error handling and edge cases 1306 | - [ ] Polish UI and styling 1307 | 1308 | ## Future Enhancements 1309 | 1310 | - **Search/Filter**: Full-text search across sessions 1311 | - **Export**: Export trace as JSON, Markdown, or HTML 1312 | - **Stats Dashboard**: Session statistics, token usage over time 1313 | - **Dark/Light Mode Toggle**: User preference 1314 | - **Keyboard Shortcuts**: Navigate tree with arrow keys 1315 | - **Real-time Updates**: Watch for new sessions (file system monitoring) 1316 | - **Diff View**: Compare two sessions side-by-side 1317 | - **Bookmarks**: Mark important sessions 1318 | - **Tags**: User-defined tags for sessions 1319 | --------------------------------------------------------------------------------