Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9d76ab8
Foldable messages (#42)
cboos Nov 25, 2025
9a00c51
Fix Pygments Lexer Performance Bottleneck (#48)
cboos Nov 26, 2025
34a931c
Document performance profiling in CLAUDE.md
cboos Nov 26, 2025
e709a3b
Add support for queue-operation 'remove' messages as steering user input
cboos Nov 18, 2025
7bbcbec
Fix off-by-one error in JSONL line number reporting
cboos Nov 18, 2025
65d970b
Fix image handling in steering messages and use CSS variable
cboos Nov 18, 2025
9291153
Note that queue-operation 'remove' also has content
cboos Nov 18, 2025
098f2b8
Fix pyright type checking errors for QueueOperationTranscriptEntry
cboos Nov 18, 2025
6f5e68d
Skip file-history-snapshot messages silently
cboos Nov 26, 2025
2d8a154
Allow string content in QueueOperationTranscriptEntry
cboos Nov 26, 2025
593b683
Add popAll operation to QueueOperationTranscriptEntry
cboos Nov 26, 2025
c5441a2
Sneak in change of default font-family (now --font-ui)
cboos Nov 26, 2025
0221307
Remove redundant Sub-assistant prompt (sidechain user) messages
cboos Nov 26, 2025
877c2c0
Fix missing icon for steering user messages
cboos Nov 26, 2025
af57ebf
Fix browser tests for sidechain user message removal
cboos Nov 26, 2025
912472b
Fix UnicodeEncodeError in style guide script on Windows
cboos Nov 26, 2025
737c87b
Add hasattr guard and type ignore for stdout.reconfigure
cboos Nov 26, 2025
1762116
Move stdout UTF-8 reconfigure to __main__ block
cboos Nov 27, 2025
0dea7cd
Regenerate style guide output with latest CSS variables
cboos Nov 27, 2025
3fa27a5
Fix queue operation handling: only render 'remove' as steering
cboos Nov 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ __pycache__/

# Distribution / packaging
.Python
.python-version
build/
develop-eggs/
dist/
Expand Down
40 changes: 40 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ claude-code-log /path/to/directory --from-date "3 days ago" --to-date "yesterday

- `claude_code_log/parser.py` - Data extraction and parsing from JSONL files
- `claude_code_log/renderer.py` - HTML generation and template rendering
- `claude_code_log/renderer_timings.py` - Performance timing instrumentation
- `claude_code_log/converter.py` - High-level conversion orchestration
- `claude_code_log/cli.py` - Command-line interface with project discovery
- `claude_code_log/models.py` - Pydantic models for transcript data structures
Expand Down Expand Up @@ -219,6 +220,45 @@ HTML coverage reports are generated in `htmlcov/index.html`.
- **Lint and fix**: `ruff check --fix`
- **Type checking**: `uv run pyright` and `uv run ty check`

### Performance Profiling

Enable timing instrumentation to identify performance bottlenecks:

```bash
# Enable timing output
CLAUDE_CODE_LOG_DEBUG_TIMING=1 claude-code-log path/to/file.jsonl

# Or export for a session
export CLAUDE_CODE_LOG_DEBUG_TIMING=1
claude-code-log path/to/file.jsonl
```

This outputs detailed timing for each rendering phase:

```
[TIMING] Initialization 0.001s (total: 0.001s)
[TIMING] Deduplication (1234 messages) 0.050s (total: 0.051s)
[TIMING] Session summary processing 0.012s (total: 0.063s)
[TIMING] Main message processing loop 5.234s (total: 5.297s)
[TIMING] Template rendering (30MB chars) 15.432s (total: 20.729s)

[TIMING] Loop statistics:
[TIMING] Total messages: 1234
[TIMING] Average time per message: 4.2ms
[TIMING] Slowest 10 messages:
[TIMING] Message abc-123 (#42, assistant): 245.3ms
[TIMING] ...

[TIMING] Pygments highlighting:
[TIMING] Total operations: 89
[TIMING] Total time: 1.234s
[TIMING] Slowest 10 operations:
[TIMING] def-456: 50.2ms
[TIMING] ...
```

The timing module is in `claude_code_log/renderer_timings.py`.

### Testing & Style Guide

- **Unit and Integration Tests**: See [test/README.md](test/README.md) for comprehensive testing documentation
Expand Down
18 changes: 13 additions & 5 deletions claude_code_log/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class ToolResultContent(BaseModel):
tool_use_id: str
content: Union[str, List[Dict[str, Any]]]
is_error: Optional[bool] = None
agentId: Optional[str] = None # Reference to agent file for sub-agent messages


class ThinkingContent(BaseModel):
Expand Down Expand Up @@ -202,6 +203,7 @@ class UserTranscriptEntry(BaseTranscriptEntry):
type: Literal["user"]
message: UserMessage
toolUseResult: Optional[ToolUseResult] = None
agentId: Optional[str] = None # From toolUseResult when present


class AssistantTranscriptEntry(BaseTranscriptEntry):
Expand All @@ -226,17 +228,23 @@ class SystemTranscriptEntry(BaseTranscriptEntry):


class QueueOperationTranscriptEntry(BaseModel):
"""Queue operations (enqueue/dequeue) for message queueing tracking.
"""Queue operations (enqueue/dequeue/remove) for message queueing tracking.

These are internal operations that track when messages are queued and dequeued.
enqueue/dequeue are internal operations that track when messages are queued and dequeued.
They are parsed but not rendered, as the content duplicates actual user messages.

'remove' operations are out-of-band user inputs made visible to the agent while working
for "steering" purposes. These should be rendered as user messages with a 'steering' CSS class.
Content can be a list of ContentItems or a simple string (for 'remove' operations).
"""

type: Literal["queue-operation"]
operation: Literal["enqueue", "dequeue"]
operation: Literal["enqueue", "dequeue", "remove", "popAll"]
timestamp: str
sessionId: str
content: Optional[List[ContentItem]] = None # Only present for enqueue operations
content: Optional[Union[List[ContentItem], str]] = (
None # List for enqueue, str for remove/popAll
)


TranscriptEntry = Union[
Expand Down Expand Up @@ -414,7 +422,7 @@ def parse_transcript_entry(data: Dict[str, Any]) -> TranscriptEntry:
return SystemTranscriptEntry.model_validate(data)

elif entry_type == "queue-operation":
# Parse content if present (only in enqueue operations)
# Parse content if present (in enqueue and remove operations)
data_copy = data.copy()
if "content" in data_copy and isinstance(data_copy["content"], list):
data_copy["content"] = parse_message_content(data_copy["content"])
Expand Down
88 changes: 86 additions & 2 deletions claude_code_log/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from .models import (
TranscriptEntry,
UserTranscriptEntry,
SummaryTranscriptEntry,
parse_transcript_entry,
ContentItem,
Expand Down Expand Up @@ -120,8 +121,22 @@ def load_transcript(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
silent: bool = False,
_loaded_files: Optional[set[Path]] = None,
) -> List[TranscriptEntry]:
"""Load and parse JSONL transcript file, using cache if available."""
"""Load and parse JSONL transcript file, using cache if available.

Args:
_loaded_files: Internal parameter to track loaded files and prevent infinite recursion.
"""
# Initialize loaded files set on first call
if _loaded_files is None:
_loaded_files = set()

# Prevent infinite recursion by checking if this file is already being loaded
if jsonl_path in _loaded_files:
return []

_loaded_files.add(jsonl_path)
# Try to load from cache first
if cache_manager is not None:
# Use filtered loading if date parameters are provided
Expand All @@ -139,11 +154,12 @@ def load_transcript(

# Parse from source file
messages: List[TranscriptEntry] = []
agent_ids: set[str] = set() # Collect agentId references while parsing

with open(jsonl_path, "r", encoding="utf-8", errors="replace") as f:
if not silent:
print(f"Processing {jsonl_path}...")
for line_no, line in enumerate(f):
for line_no, line in enumerate(f, 1): # Start counting from 1
line = line.strip()
if line:
try:
Expand All @@ -154,6 +170,25 @@ def load_transcript(
)
continue

# Check for agentId BEFORE Pydantic parsing
# agentId can be at top level OR nested in toolUseResult
# For UserTranscriptEntry, we need to copy it to top level so Pydantic preserves it
if "agentId" in entry_dict:
agent_id = entry_dict.get("agentId")
if agent_id:
agent_ids.add(agent_id)
elif "toolUseResult" in entry_dict:
tool_use_result = entry_dict.get("toolUseResult")
if (
isinstance(tool_use_result, dict)
and "agentId" in tool_use_result
):
agent_id_value = tool_use_result.get("agentId") # type: ignore[reportUnknownVariableType, reportUnknownMemberType]
if isinstance(agent_id_value, str):
agent_ids.add(agent_id_value)
# Copy agentId to top level for Pydantic to preserve
entry_dict["agentId"] = agent_id_value

entry_type: str | None = entry_dict.get("type")

if entry_type in [
Expand All @@ -166,6 +201,14 @@ def load_transcript(
# Parse using Pydantic models
entry = parse_transcript_entry(entry_dict)
messages.append(entry)
elif (
entry_type
in [
"file-history-snapshot", # Internal Claude Code file backup metadata
]
):
# Silently skip internal message types we don't render
pass
else:
print(
f"Line {line_no} of {jsonl_path} is not a recognised message type: {line}"
Expand Down Expand Up @@ -195,6 +238,47 @@ def load_transcript(
"\n{traceback.format_exc()}"
)

# Load agent files if any were referenced
# Build a map of agentId -> agent messages
agent_messages_map: dict[str, List[TranscriptEntry]] = {}
if agent_ids:
parent_dir = jsonl_path.parent
for agent_id in agent_ids:
agent_file = parent_dir / f"agent-{agent_id}.jsonl"
# Skip if the agent file is the same as the current file (self-reference)
if agent_file == jsonl_path:
continue
if agent_file.exists():
if not silent:
print(f"Loading agent file {agent_file}...")
# Recursively load the agent file (it might reference other agents)
agent_messages = load_transcript(
agent_file,
cache_manager,
from_date,
to_date,
silent=True,
_loaded_files=_loaded_files,
)
agent_messages_map[agent_id] = agent_messages

# Insert agent messages at their point of use
if agent_messages_map:
# Iterate through messages and insert agent messages after the message
# that references them (via UserTranscriptEntry.agentId)
result_messages: List[TranscriptEntry] = []
for message in messages:
result_messages.append(message)

# Check if this is a UserTranscriptEntry with agentId
if isinstance(message, UserTranscriptEntry) and message.agentId:
agent_id = message.agentId
if agent_id in agent_messages_map:
# Insert agent messages right after this message
result_messages.extend(agent_messages_map[agent_id])

messages = result_messages

# Save to cache if cache manager is available
if cache_manager is not None:
cache_manager.save_cached_entries(jsonl_path, messages)
Expand Down
Loading