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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ jobs:

- name: Run benchmark tests with coverage append (primary only)
if: matrix.is-primary
env:
CLAUDE_CODE_LOG_DEBUG_TIMING: "1"
run: uv run pytest -m benchmark --cov=claude_code_log --cov-append --cov-report=xml --cov-report=html --cov-report=term -v

- name: Upload coverage HTML report as artifact
Expand Down
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ claude-code-log --all-projects
# Process all projects and open in browser
claude-code-log --open-browser

# Process all projects with date filtering
# Process all projects with date filtering
claude-code-log --from-date "yesterday" --to-date "today"
claude-code-log --from-date "last week"

Expand Down Expand Up @@ -154,6 +154,7 @@ The project uses:

- Python 3.10+ with uv package management
- Click for CLI interface and argument parsing
- Textual for interactive Terminal User Interface
- Pydantic for robust data modeling and validation
- dateparser for natural language date parsing
- Standard library for JSON/HTML processing
Expand Down Expand Up @@ -377,17 +378,13 @@ uv run claude-code-log
- tutorial overlay
- integrate `claude-trace` request logs if present?
- convert images to WebP as screenshots are often huge PNGs – this might be time consuming to keep redoing (so would also need some caching) and need heavy dependencies with compilation (unless there are fast pure Python conversation libraries? Or WASM?)
- add special formatting for built-in tools: Bash, Glob, Grep, LS, exit_plan_mode, Read, Edit, MultiEdit, Write, NotebookRead, NotebookEdit, WebFetch, TodoRead, TodoWrite, WebSearch
- do we need to handle compacted conversation?
- Thinking block should have Markdown rendering as sometimes they have formatting
- system blocks like `init` also don't render perfectly, losing new lines
- add special formatting for built-in tools: Glob, Grep, LS, MultiEdit, NotebookRead, NotebookEdit, WebFetch, TodoRead, WebSearch
- add `ccusage` like daily summary and maybe some textual summary too based on Claude generate session summaries?
– import logs from @claude Github Actions
- stream logs from @claude Github Actions, see [octotail](https://github.com/getbettr/octotail)
- wrap up CLI as Github Action to run after Cladue Github Action and process [output](https://github.com/anthropics/claude-code-base-action?tab=readme-ov-file#outputs)
- feed the filtered user messages to headless claude CLI to distill the user intent from the session
- filter message type on Python (CLI) side too, not just UI
- Markdown renderer
- add minimalist theme and make it light + dark; animate gradient background in fancy theme
- do we need special handling for hooks?
- make processing parallel, currently we only use 1 CPU (core) and it's slow
Expand Down
132 changes: 84 additions & 48 deletions claude_code_log/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ def format_exitplanmode_result(content: str) -> str:


def format_todowrite_content(tool_use: ToolUseContent) -> str:
"""Format TodoWrite tool use content as an actual todo list with checkboxes."""
"""Format TodoWrite tool use content as a todo list."""
# Parse todos from input
todos_data = tool_use.input.get("todos", [])
if not todos_data:
Expand All @@ -586,31 +586,26 @@ def format_todowrite_content(tool_use: ToolUseContent) -> str:
try:
todo_id = escape_html(str(todo.get("id", "")))
content = escape_html(str(todo.get("content", "")))
status = todo.get("status", "pending")
priority = todo.get("priority", "medium")
status = str(todo.get("status", "pending")).lower()
priority = str(todo.get("priority", "medium")).lower()
status_emoji = status_emojis.get(status, "⏳")

# Determine checkbox state
checked = "checked" if status == "completed" else ""
disabled = "disabled" if status == "completed" else ""

# CSS class for styling
item_class = f"todo-item {status} {priority}"

todo_items.append(f"""
<div class="{item_class}">
<input type="checkbox" {checked} {disabled} readonly>
<span class="todo-status">{status_emoji}</span>
<span class="todo-content">{content}</span>
<span class="todo-id">#{todo_id}</span>
</div>
""")
except AttributeError:
escaped_fallback = escape_html(str(todo))
todo_items.append(f"""
<div class="todo-item pending medium">
<input type="checkbox" readonly>
<span class="todo-status">⏳</span>
<span class="todo-content">{str(todo)}</span>
<span class="todo-content">{escaped_fallback}</span>
</div>
""")

Expand Down Expand Up @@ -2532,18 +2527,32 @@ def _process_regular_message(
# Handle user-specific preprocessing
if message_type == "user":
# Note: sidechain user messages are skipped before reaching this function
content_html, is_compacted, is_memory_input = render_user_message_content(
text_only_content
)
if is_compacted:
css_class = f"{message_type} compacted"
message_title = "User (compacted conversation)"
elif is_meta:
# Slash command expanded prompts - LLM-generated content
if is_meta:
# Slash command expanded prompts - render as collapsible markdown
# These contain LLM-generated instruction text (markdown formatted)
css_class = f"{message_type} slash-command"
message_title = "User (slash command)"
elif is_memory_input:
message_title = "Memory"
# Combine all text content (items may be TextContent, dicts, or SDK objects)
all_text = "\n\n".join(
getattr(item, "text", "")
for item in text_only_content
if hasattr(item, "text")
)
content_html = render_markdown_collapsible(
all_text,
"slash-command-content",
line_threshold=20,
preview_line_count=5,
)
else:
content_html, is_compacted, is_memory_input = render_user_message_content(
text_only_content
)
if is_compacted:
css_class = f"{message_type} compacted"
message_title = "User (compacted conversation)"
elif is_memory_input:
message_title = "Memory"
else:
# Non-user messages: render directly
content_html = render_message_content(text_only_content, message_type)
Expand Down Expand Up @@ -2578,6 +2587,7 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None:
and tool_result. Session ID is included to prevent cross-session pairing
when sessions are resumed (same tool_use_id can appear in multiple sessions).
Build index of uuid -> message index for parent-child system messages
Build index of parent_uuid -> message index for slash-command messages
2. Second pass: Sequential scan for adjacent pairs (system+output, bash, thinking+assistant)
and match tool_use/tool_result and uuid-based pairs using the index
"""
Expand All @@ -2590,6 +2600,8 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None:
tuple[str, str], int
] = {} # (session_id, tool_use_id) -> index
uuid_index: Dict[str, int] = {} # uuid -> message index for parent-child pairing
# Index slash-command messages by their parent_uuid for pairing with system commands
slash_command_by_parent: Dict[str, int] = {} # parent_uuid -> message index

for i, msg in enumerate(messages):
if msg.tool_use_id and msg.session_id:
Expand All @@ -2601,6 +2613,9 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None:
# Build UUID index for system messages (both parent and child)
if msg.uuid and "system" in msg.css_class:
uuid_index[msg.uuid] = i
# Index slash-command user messages by parent_uuid
if msg.parent_uuid and "slash-command" in msg.css_class:
slash_command_by_parent[msg.parent_uuid] = i

# Pass 2: Sequential scan to identify pairs
i = 0
Expand Down Expand Up @@ -2649,6 +2664,17 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None:
current.is_paired = True
current.pair_role = "pair_last"

# Check for system command + user slash-command pair (via parent_uuid)
# The slash-command message's parent_uuid points to the system command's uuid
if "system" in current.css_class and current.uuid:
if current.uuid in slash_command_by_parent:
slash_idx = slash_command_by_parent[current.uuid]
slash_msg = messages[slash_idx]
current.is_paired = True
current.pair_role = "pair_first"
slash_msg.is_paired = True
slash_msg.pair_role = "pair_last"

# Check for bash-input + bash-output pair (adjacent only)
if current.css_class == "bash-input" and i + 1 < len(messages):
next_msg = messages[i + 1]
Expand Down Expand Up @@ -2683,7 +2709,8 @@ def _reorder_paired_messages(messages: List[TemplateMessage]) -> List[TemplateMe

Uses dictionary-based approach to find pairs efficiently:
1. Build index of all pair_last messages by tool_use_id
2. Single pass through messages, inserting pair_last immediately after pair_first
2. Build index of slash-command pair_last messages by parent_uuid
3. Single pass through messages, inserting pair_last immediately after pair_first
"""
from datetime import datetime

Expand All @@ -2692,6 +2719,8 @@ def _reorder_paired_messages(messages: List[TemplateMessage]) -> List[TemplateMe
pair_last_index: Dict[
tuple[str, str], int
] = {} # (session_id, tool_use_id) -> message index
# Index slash-command pair_last messages by parent_uuid
slash_command_pair_index: Dict[str, int] = {} # parent_uuid -> message index

for i, msg in enumerate(messages):
if (
Expand All @@ -2702,6 +2731,14 @@ def _reorder_paired_messages(messages: List[TemplateMessage]) -> List[TemplateMe
):
key = (msg.session_id, msg.tool_use_id)
pair_last_index[key] = i
# Index slash-command messages by parent_uuid
if (
msg.is_paired
and msg.pair_role == "pair_last"
and msg.parent_uuid
and "slash-command" in msg.css_class
):
slash_command_pair_index[msg.parent_uuid] = i

# Create reordered list
reordered: List[TemplateMessage] = []
Expand All @@ -2715,16 +2752,23 @@ def _reorder_paired_messages(messages: List[TemplateMessage]) -> List[TemplateMe

# If this is the first message in a pair, immediately add its pair_last
# Key includes session_id to prevent cross-session pairing on resume
if (
msg.is_paired
and msg.pair_role == "pair_first"
and msg.tool_use_id
and msg.session_id
):
key = (msg.session_id, msg.tool_use_id)
if key in pair_last_index:
last_idx = pair_last_index[key]
if msg.is_paired and msg.pair_role == "pair_first":
pair_last = None
last_idx = None

# Check for tool_use_id based pairs
if msg.tool_use_id and msg.session_id:
key = (msg.session_id, msg.tool_use_id)
if key in pair_last_index:
last_idx = pair_last_index[key]
pair_last = messages[last_idx]

# Check for system + slash-command pairs (via uuid -> parent_uuid)
if pair_last is None and msg.uuid and msg.uuid in slash_command_pair_index:
last_idx = slash_command_pair_index[msg.uuid]
pair_last = messages[last_idx]

if pair_last is not None and last_idx is not None:
reordered.append(pair_last)
skip_indices.add(last_idx)

Expand Down Expand Up @@ -2799,8 +2843,8 @@ def _get_message_hierarchy_level(css_class: str, is_sidechain: bool) -> int:
Correct hierarchy based on logical nesting:
- Level 0: Session headers
- Level 1: User messages
- Level 2: System messages, Assistant, Thinking
- Level 3: Tool use/result (nested under assistant)
- Level 2: System commands/errors, Assistant, Thinking
- Level 3: Tool use/result, System info/warning (nested under assistant)
- Level 4: Sidechain assistant/thinking (nested under Task tool result)
- Level 5: Sidechain tools (nested under sidechain assistant)

Expand All @@ -2815,7 +2859,13 @@ def _get_message_hierarchy_level(css_class: str, is_sidechain: bool) -> int:
if "user" in css_class and not is_sidechain:
return 1

# System messages at level 2 (siblings to assistant, under user)
# System info/warning at level 3 (tool-related, e.g., hook notifications)
if (
"system-info" in css_class or "system-warning" in css_class
) and not is_sidechain:
return 3

# System commands/errors at level 2 (siblings to assistant)
if "system" in css_class and not is_sidechain:
return 2

Expand Down Expand Up @@ -2848,27 +2898,16 @@ def _build_message_hierarchy(messages: List[TemplateMessage]) -> None:
The hierarchy is determined by message type using _get_message_hierarchy_level(),
and a stack-based approach builds proper parent-child relationships.

For system messages with parent_uuid, the hierarchy level is derived from the
parent's level instead of the default, ensuring proper nesting.

Args:
messages: List of template messages in their final order (modified in place)
"""
hierarchy_stack: List[tuple[int, str]] = []
message_id_counter = 0

# Build UUID -> (message_id, level) mapping for parent_uuid resolution
# We do this in a single pass by updating the map as we assign IDs
uuid_to_info: Dict[str, tuple[str, int]] = {}

for message in messages:
# Session headers are level 0
if message.is_session_header:
current_level = 0
elif message.parent_uuid and message.parent_uuid in uuid_to_info:
# System message with known parent - nest under parent
_, parent_level = uuid_to_info[message.parent_uuid]
current_level = parent_level + 1
else:
# Determine level from css_class
is_sidechain = "sidechain" in message.css_class
Expand All @@ -2894,10 +2933,6 @@ def _build_message_hierarchy(messages: List[TemplateMessage]) -> None:
# Push current message onto stack
hierarchy_stack.append((current_level, message_id))

# Track UUID -> (message_id, level) for parent_uuid resolution
if message.uuid:
uuid_to_info[message.uuid] = (message_id, current_level)

# Update the message
message.message_id = message_id
message.ancestry = ancestry
Expand Down Expand Up @@ -3799,6 +3834,7 @@ def _process_messages_loop(
ancestry=[], # Will be assigned by _build_message_hierarchy
agent_id=getattr(message, "agentId", None),
uuid=getattr(message, "uuid", None),
parent_uuid=getattr(message, "parentUuid", None),
)

# Store raw text content for potential future use (e.g., deduplication,
Expand Down
11 changes: 8 additions & 3 deletions claude_code_log/templates/components/global_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,15 @@
--answer-accent: #4caf50;
--answer-bg: #f0fff4;

/* Priority palette (purple intensity - darker = more urgent) */
--priority-600: #7c3aed;
--priority-400: #a78bfa;
--priority-300: #c4b5fd;

/* Priority colors for todos */
--priority-high: #dc3545;
--priority-medium: #ffc107;
--priority-low: #28a745;
--priority-high: var(--priority-600);
--priority-medium: var(--priority-400);
--priority-low: var(--priority-300);

/* Status colors */
--status-in-progress: #fff3cd;
Expand Down
20 changes: 9 additions & 11 deletions claude_code_log/templates/components/message_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,9 @@

/* Right-aligned messages (user-initiated, right margin 0, left margin 33%) */
.user:not(.compacted),
.system,
.bash-input,
.bash-output {
.bash-output,
.system:not(.system-info):not(.system-warning):not(.system-error) {
margin-left: 33%;
margin-right: 0;
}
Expand All @@ -207,17 +207,11 @@
margin-right: 6em;
}

/* System warnings/info (assistant-initiated) */
/* System warnings/info at tool level (e.g., hook notifications) */
.system-warning,
.system-info {
margin-left: 0;
margin-right: 10em;
}

/* Exception: paired system-info messages align right (like user commands) */
.system.system-info.paired-message {
margin-left: 33%;
margin-right: 0;
margin-left: 2em;
margin-right: 6em;
}

/* Sidechain messages (sub-assistant hierarchy) */
Expand Down Expand Up @@ -340,6 +334,10 @@
.system-info {
border-left-color: var(--info-dimmed);
background-color: var(--highlight-dimmed);
}

.system-info .header,
.system-info .content {
font-size: 80%;
}

Expand Down
Loading
Loading