diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index df5ec8ea..c5bc698a 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -12,6 +12,7 @@ should_use_as_session_starter, create_session_preview, extract_working_directories, + get_warmup_session_ids, ) from .cache import CacheManager, SessionCacheData, get_library_version from .parser import ( @@ -303,10 +304,19 @@ def _update_cache_with_session_data( usage.cache_read_input_tokens ) - # Update cache with session data + # Filter out warmup-only and empty sessions before caching + warmup_session_ids = get_warmup_session_ids(messages) + sessions_cache_data = { + sid: data + for sid, data in sessions_cache_data.items() + if sid not in warmup_session_ids + and data.first_user_message # Filter empty sessions (agent-only) + } + + # Update cache with filtered session data cache_manager.update_session_cache(sessions_cache_data) - # Update cache with working directories + # Update cache with working directories (from filtered sessions) cache_manager.update_working_directories( extract_working_directories(list(sessions_cache_data.values())) ) @@ -342,6 +352,9 @@ def _collect_project_sessions(messages: List[TranscriptEntry]) -> List[Dict[str, """Collect session data for project index navigation.""" from .parser import extract_text_content + # Pre-compute warmup session IDs to filter them out + warmup_session_ids = get_warmup_session_ids(messages) + # Pre-process to find and attach session summaries # This matches the logic from renderer.py generate_html() exactly session_summaries: Dict[str, str] = {} @@ -375,14 +388,14 @@ def _collect_project_sessions(messages: List[TranscriptEntry]) -> List[Dict[str, ): session_summaries[uuid_to_session_backup[leaf_uuid]] = message.summary - # Group messages by session + # Group messages by session (excluding warmup-only sessions) sessions: Dict[str, Dict[str, Any]] = {} for message in messages: if hasattr(message, "sessionId") and not isinstance( message, SummaryTranscriptEntry ): session_id = getattr(message, "sessionId", "") - if not session_id: + if not session_id or session_id in warmup_session_ids: continue if session_id not in sessions: @@ -439,6 +452,9 @@ def _collect_project_sessions(messages: List[TranscriptEntry]) -> List[Dict[str, if session_data["first_user_message"] != "" else "[No user message found in session.]", } + # Skip sessions with no user messages (empty sessions / agent-only) + if session_data["first_user_message"] == "": + continue session_list.append(session_dict) # Sort by first timestamp (ascending order, oldest first like transcript page) @@ -456,12 +472,15 @@ def _generate_individual_session_files( cache_was_updated: bool = False, ) -> None: """Generate individual HTML files for each session.""" - # Find all unique session IDs + # Pre-compute warmup sessions to exclude them + warmup_session_ids = get_warmup_session_ids(messages) + + # Find all unique session IDs (excluding warmup sessions) session_ids: set[str] = set() for message in messages: if hasattr(message, "sessionId"): session_id: str = getattr(message, "sessionId") - if session_id: + if session_id and session_id not in warmup_session_ids: session_ids.add(session_id) # Get session data from cache for better titles @@ -630,6 +649,9 @@ def process_projects_hierarchy( or "[No user message found in session.]", } for session_data in cached_project_data.sessions.values() + # Filter out warmup-only and empty sessions (agent-only) + if session_data.first_user_message + and session_data.first_user_message != "Warmup" ], } ) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index aa20cec3..8b40c10a 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -221,11 +221,17 @@ class SummaryTranscriptEntry(BaseModel): class SystemTranscriptEntry(BaseTranscriptEntry): - """System messages like warnings, notifications, etc.""" + """System messages like warnings, notifications, hook summaries, etc.""" type: Literal["system"] - content: str + content: Optional[str] = None + subtype: Optional[str] = None # e.g., "stop_hook_summary" level: Optional[str] = None # e.g., "warning", "info", "error" + # Hook summary fields (for subtype="stop_hook_summary") + hasOutput: Optional[bool] = None + hookErrors: Optional[List[str]] = None + hookInfos: Optional[List[Dict[str, Any]]] = None + preventedContinuation: Optional[bool] = None class QueueOperationTranscriptEntry(BaseModel): diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 33698535..d59686f1 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -2030,6 +2030,48 @@ def __init__(self, project_summaries: List[Dict[str, Any]]): self.token_summary = " | ".join(token_parts) +def _render_hook_summary(message: "SystemTranscriptEntry") -> str: + """Render a hook summary as collapsible details. + + Shows a compact summary with expandable hook commands and error output. + """ + # Extract command names from hookInfos + commands = [info.get("command", "unknown") for info in (message.hookInfos or [])] + + # Determine if this is a failure or just output + has_errors = bool(message.hookErrors) + summary_icon = "đŸĒ" + summary_text = "Hook failed" if has_errors else "Hook output" + + # Build the command section + command_html = "" + if commands: + command_html = '
' + for cmd in commands: + # Truncate very long commands + display_cmd = cmd if len(cmd) <= 100 else cmd[:97] + "..." + command_html += f"{html.escape(display_cmd)}" + command_html += "
" + + # Build the error output section + error_html = "" + if message.hookErrors: + error_html = '
' + for err in message.hookErrors: + # Convert ANSI codes in error output + formatted_err = _convert_ansi_to_html(err) + error_html += f'
{formatted_err}
' + error_html += "
" + + return f"""
+{summary_icon} {summary_text} +
+{command_html} +{error_html} +
+
""" + + def _convert_ansi_to_html(text: str) -> str: """Convert ANSI escape codes to HTML spans with CSS classes. @@ -2394,6 +2436,7 @@ def _process_bash_output(text_content: str) -> tuple[str, str, str, str]: import re css_class = "bash-output" + COLLAPSE_THRESHOLD = 10 # Collapse if more than this many lines stdout_match = re.search( r"(.*?)", @@ -2406,21 +2449,57 @@ def _process_bash_output(text_content: str) -> tuple[str, str, str, str]: re.DOTALL, ) - output_parts: List[str] = [] + output_parts: List[tuple[str, str, int, str]] = [] + total_lines = 0 + if stdout_match: stdout_content = stdout_match.group(1).strip() if stdout_content: escaped_stdout = _convert_ansi_to_html(stdout_content) - output_parts.append(f"
{escaped_stdout}
") + stdout_lines = stdout_content.count("\n") + 1 + total_lines += stdout_lines + output_parts.append( + ("stdout", escaped_stdout, stdout_lines, stdout_content) + ) if stderr_match: stderr_content = stderr_match.group(1).strip() if stderr_content: escaped_stderr = _convert_ansi_to_html(stderr_content) - output_parts.append(f"
{escaped_stderr}
") + stderr_lines = stderr_content.count("\n") + 1 + total_lines += stderr_lines + output_parts.append( + ("stderr", escaped_stderr, stderr_lines, stderr_content) + ) if output_parts: - content_html = "".join(output_parts) + # Build the HTML parts + html_parts: List[str] = [] + for output_type, escaped_content, _, _ in output_parts: + css_name = f"bash-{output_type}" + html_parts.append(f"
{escaped_content}
") + + full_html = "".join(html_parts) + + # Wrap in collapsible if output is large + if total_lines > COLLAPSE_THRESHOLD: + # Create preview (first few lines) + preview_lines = 3 + first_output = output_parts[0] + raw_preview = "\n".join(first_output[3].split("\n")[:preview_lines]) + preview_html = html.escape(raw_preview) + if total_lines > preview_lines: + preview_html += "\n..." + + content_html = f"""
+ + {total_lines} lines +
{preview_html}
+
+
{full_html}
+
""" + else: + content_html = full_html else: # Empty output content_html = ( @@ -2927,6 +3006,8 @@ def generate_html( combined_transcript_link: Optional[str] = None, ) -> str: """Generate HTML from transcript messages using Jinja2 templates.""" + from .utils import get_warmup_session_ids + # Performance timing t_start = time.time() @@ -2934,6 +3015,16 @@ def generate_html( if not title: title = "Claude Transcript" + # Filter out warmup-only sessions + with log_timing("Filter warmup sessions", t_start): + warmup_session_ids = get_warmup_session_ids(messages) + if warmup_session_ids: + messages = [ + msg + for msg in messages + if getattr(msg, "sessionId", None) not in warmup_session_ids + ] + # Pre-process to find and attach session summaries with log_timing("Session summary processing", t_start): session_summaries: Dict[str, str] = {} @@ -2985,6 +3076,10 @@ def generate_html( for session_id in session_order: session_info = sessions[session_id] + # Skip empty sessions (agent-only, no user messages) + if not session_info["first_user_message"]: + continue + # Format timestamp range first_ts = session_info["first_timestamp"] last_ts = session_info["last_timestamp"] @@ -3323,47 +3418,62 @@ def _process_messages_loop( timestamp = getattr(message, "timestamp", "") formatted_timestamp = format_timestamp(timestamp) if timestamp else "" - # Extract command name if present - command_name_match = re.search( - r"(.*?)", message.content, re.DOTALL - ) - # Also check for command output (child of user command) - command_output_match = re.search( - r"(.*?)", - message.content, - re.DOTALL, - ) - - # Create level-specific styling and icons - level = getattr(message, "level", "info") - level_icon = {"warning": "âš ī¸", "error": "❌", "info": "â„šī¸"}.get(level, "â„šī¸") - - # Determine CSS class: - # - Command name (user-initiated): "system" only - # - Command output (assistant response): "system system-{level}" - # - Other system messages: "system system-{level}" - if command_name_match: - # User-initiated command - level_css = "system" - else: - # Command output or other system message - level_css = f"system system-{level}" - - # Process content: extract command name or command output, or use full content - if command_name_match: - # Show just the command name - command_name = command_name_match.group(1).strip() - html_content = f"{html.escape(command_name)}" - content_html = f"{level_icon} {html_content}" - elif command_output_match: - # Extract and process command output - output = command_output_match.group(1).strip() - html_content = _convert_ansi_to_html(output) - content_html = f"{level_icon} {html_content}" + # Handle hook summaries (subtype="stop_hook_summary") + if message.subtype == "stop_hook_summary": + # Skip silent hook successes (no output, no errors) + if not message.hasOutput and not message.hookErrors: + continue + # Render hook summary with collapsible details + content_html = _render_hook_summary(message) + level_css = "system system-hook" + level = "hook" + elif not message.content: + # Skip system messages without content (shouldn't happen normally) + continue else: - # Process ANSI codes in system messages (they may contain command output) - html_content = _convert_ansi_to_html(message.content) - content_html = f"{level_icon} {html_content}" + # Extract command name if present + command_name_match = re.search( + r"(.*?)", message.content, re.DOTALL + ) + # Also check for command output (child of user command) + command_output_match = re.search( + r"(.*?)", + message.content, + re.DOTALL, + ) + + # Create level-specific styling and icons + level = getattr(message, "level", "info") + level_icon = {"warning": "âš ī¸", "error": "❌", "info": "â„šī¸"}.get( + level, "â„šī¸" + ) + + # Determine CSS class: + # - Command name (user-initiated): "system" only + # - Command output (assistant response): "system system-{level}" + # - Other system messages: "system system-{level}" + if command_name_match: + # User-initiated command + level_css = "system" + else: + # Command output or other system message + level_css = f"system system-{level}" + + # Process content: extract command name or command output, or use full content + if command_name_match: + # Show just the command name + command_name = command_name_match.group(1).strip() + html_content = f"{html.escape(command_name)}" + content_html = f"{level_icon} {html_content}" + elif command_output_match: + # Extract and process command output + output = command_output_match.group(1).strip() + html_content = _convert_ansi_to_html(output) + content_html = f"{level_icon} {html_content}" + else: + # Process ANSI codes in system messages (they may contain command output) + html_content = _convert_ansi_to_html(message.content) + content_html = f"{level_icon} {html_content}" # Store parent UUID for hierarchy rebuild (handled by _build_message_hierarchy) parent_uuid = getattr(message, "parentUuid", None) diff --git a/claude_code_log/templates/components/message_styles.css b/claude_code_log/templates/components/message_styles.css index dec81577..7fd8472b 100644 --- a/claude_code_log/templates/components/message_styles.css +++ b/claude_code_log/templates/components/message_styles.css @@ -151,11 +151,11 @@ } .fold-bar[data-border-color="bash-input"] .fold-bar-section { - border-bottom-color: var(--tool-use-color); + border-bottom-color: var(--user-color); } .fold-bar[data-border-color="bash-output"] .fold-bar-section { - border-bottom-color: var(--success-dimmed); + border-bottom-color: var(--user-dimmed); } .fold-bar[data-border-color="session-header"] .fold-bar-section { @@ -179,7 +179,9 @@ /* Right-aligned messages (user-initiated, right margin 0, left margin 33%) */ .user:not(.compacted), -.system { +.system, +.bash-input, +.bash-output { margin-left: 33%; margin-right: 0; } @@ -341,6 +343,59 @@ font-size: 80%; } +/* Hook summary styling */ +.system-hook { + border-left-color: var(--warning-dimmed); + background-color: var(--highlight-dimmed); + font-size: 90%; +} + +.hook-summary { + cursor: pointer; +} + +.hook-summary summary { + display: flex; + align-items: center; + gap: 0.5em; +} + +.hook-details { + margin-top: 0.5em; + padding: 0.5em; + background-color: var(--code-bg); + border-radius: 4px; +} + +.hook-commands { + margin-bottom: 0.5em; +} + +.hook-commands code { + display: block; + padding: 0.25em 0.5em; + font-size: 0.85em; + word-break: break-all; + white-space: pre-wrap; +} + +.hook-errors { + margin-top: 0.5em; +} + +.hook-error { + margin: 0.25em 0; + padding: 0.5em; + background-color: var(--error-semi); + border-left: 3px solid var(--system-error-color); + font-size: 0.85em; + white-space: pre-wrap; + word-wrap: break-word; + overflow-x: auto; + max-height: 300px; + overflow-y: auto; +} + /* Command output styling */ .command-output { background-color: #1e1e1e11; @@ -371,14 +426,14 @@ padding: 0 1em; } -/* Bash command styling */ +/* Bash command styling (user-initiated, right-aligned) */ .bash-input { - background-color: #1e1e1e08; - border-left-color: var(--tool-use-color); + background-color: var(--highlight-light); + border-left-color: var(--user-color); } .bash-prompt { - color: var(--tool-use-color); + color: var(--user-color); font-weight: bold; font-size: 1.1em; margin-right: 8px; @@ -393,40 +448,36 @@ border-radius: 3px; } -/* Bash output styling */ +/* Bash output styling (user-initiated, right-aligned) */ .bash-output { - background-color: var(--neutral-dimmed); - border-left-color: #607d8b; + background-color: var(--highlight-light); + border-left-color: var(--user-dimmed); } -.bash-stdout { - background-color: #1e1e1e05; - padding: 12px; +.bash-output pre.bash-stdout, +.bash-output pre.bash-stderr { + padding: 8px; border-radius: 4px; - border: 1px solid #00000011; - margin: 8px 0; + margin: 4px 0; font-family: var(--font-monospace); - font-size: 0.9em; - line-height: 1.4; - white-space: pre-wrap; - word-wrap: break-word; - color: var(--text-primary); + font-size: 80%; + line-height: 1.3; + white-space: pre; overflow-x: auto; + overflow-y: auto; + max-height: 300px; +} + +.bash-output pre.bash-stdout { + background-color: #1e1e1e05; + border: 1px solid #00000011; + color: var(--text-primary); } -.bash-stderr { +.bash-output pre.bash-stderr { background-color: #ffebee; - padding: 12px; - border-radius: 4px; border: 1px solid #ffcdd2; - margin: 8px 0; - font-family: var(--font-monospace); - font-size: 0.9em; - line-height: 1.4; - white-space: pre-wrap; - word-wrap: break-word; color: #c62828; - overflow-x: auto; } .bash-empty { @@ -756,9 +807,10 @@ details summary { margin-top: 4px; } -/* Tool use/result preview content with gradient fade */ +/* Tool use/result/bash-output preview content with gradient fade */ .tool_use .preview-content, -.tool_result .preview-content { +.tool_result .preview-content, +.bash-output .preview-content { opacity: 0.7; mask-image: linear-gradient(to bottom, black 80%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); diff --git a/claude_code_log/templates/transcript.html b/claude_code_log/templates/transcript.html index b8adeeaf..e01e2300 100644 --- a/claude_code_log/templates/transcript.html +++ b/claude_code_log/templates/transcript.html @@ -297,8 +297,8 @@

🔍 Search & Filter

} }); - // Handle combined "tool" filter (tool_use + tool_result) - const toolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header)`); + // Handle combined "tool" filter (tool_use + tool_result + bash messages) + const toolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header), .message.bash-input:not(.session-header), .message.bash-output:not(.session-header)`); const toolCount = toolMessages.length; const toolToggle = document.querySelector(`[data-type="tool"]`); const toolCountSpan = toolToggle ? toolToggle.querySelector('.count') : null; @@ -319,11 +319,11 @@

🔍 Search & Filter

.filter(toggle => toggle.classList.contains('active')) .map(toggle => toggle.dataset.type); - // Expand "tool" to include both tool_use and tool_result + // Expand "tool" to include tool_use, tool_result, and bash messages const expandedTypes = []; activeTypes.forEach(type => { if (type === 'tool') { - expandedTypes.push('tool_use', 'tool_result'); + expandedTypes.push('tool_use', 'tool_result', 'bash-input', 'bash-output'); } else { expandedTypes.push(type); } @@ -394,9 +394,9 @@

🔍 Search & Filter

} }); - // Handle combined "tool" filter separately - const visibleToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header):not(.filtered-hidden), .message.tool_result:not(.session-header):not(.filtered-hidden)`); - const totalToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header)`); + // Handle combined "tool" filter separately (includes bash messages) + const visibleToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header):not(.filtered-hidden), .message.tool_result:not(.session-header):not(.filtered-hidden), .message.bash-input:not(.session-header):not(.filtered-hidden), .message.bash-output:not(.session-header):not(.filtered-hidden)`); + const totalToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header), .message.bash-input:not(.session-header), .message.bash-output:not(.session-header)`); const visibleToolCount = visibleToolMessages.length; const totalToolCount = totalToolMessages.length; diff --git a/claude_code_log/utils.py b/claude_code_log/utils.py index dcbc01f4..b146fc3c 100644 --- a/claude_code_log/utils.py +++ b/claude_code_log/utils.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 """Utility functions for message filtering and processing.""" -from typing import Union, List +import re +from typing import Dict, List, Union from claude_code_log.cache import SessionCacheData -from .models import ContentItem, TextContent, TranscriptEntry +from .models import ContentItem, TextContent, TranscriptEntry, UserTranscriptEntry def is_system_message(text_content: str) -> bool: @@ -67,9 +68,13 @@ def should_use_as_session_starter(text_content: str) -> bool: """ Determine if a user message should be used as a session starter preview. - This filters out system messages and most command messages, except for 'init' commands - which are typically the start of a new session. + This filters out system messages, warmup messages, and most command messages, + except for 'init' commands which are typically the start of a new session. """ + # Skip warmup messages + if text_content.strip() == "Warmup": + return False + # Skip system messages if is_system_message(text_content): return False @@ -93,9 +98,16 @@ def create_session_preview(text_content: str) -> str: Returns: A preview string, truncated to FIRST_USER_MESSAGE_PREVIEW_LENGTH with - ellipsis if needed, and with init commands converted to friendly descriptions. + ellipsis if needed, with init commands converted to friendly descriptions, + and IDE tags replaced with compact emoji indicators. """ + # Apply init command transformation first preview_content = extract_init_command_description(text_content) + + # Apply compact IDE tag indicators BEFORE truncation + preview_content = _compact_ide_tags_for_preview(preview_content) + + # Then truncate if needed if len(preview_content) > FIRST_USER_MESSAGE_PREVIEW_LENGTH: return preview_content[:FIRST_USER_MESSAGE_PREVIEW_LENGTH] + "..." return preview_content @@ -149,3 +161,188 @@ def extract_working_directories( # Sort by timestamp (most recent first) and return just the paths sorted_dirs = sorted(working_directories.items(), key=lambda x: x[1], reverse=True) return [path for path, _ in sorted_dirs] + + +# IDE tag patterns for compact preview rendering (same as renderer.py) +IDE_OPENED_FILE_PATTERN = re.compile( + r"(.*?)", re.DOTALL +) +IDE_SELECTION_PATTERN = re.compile(r"(.*?)", re.DOTALL) +IDE_DIAGNOSTICS_PATTERN = re.compile( + r"\s*(.*?)\s*", + re.DOTALL, +) + + +def _compact_ide_tags_for_preview(text_content: str) -> str: + """Replace verbose IDE/system tags with compact emoji indicators for previews. + + Only processes tags at the START of the content (where VS Code places them). + Tags appearing later in the text (e.g., inside quoted JSONL) are left unchanged. + + Transforms: + - ...path/to/file... -> 📎 /path/to/file + - ...path/to/file... -> âœ‚ī¸ /path/to/file + - ... -> đŸŠē diagnostics + - command -> đŸ’ģ command + + Args: + text_content: Raw text content that may contain IDE/system tags + + Returns: + Text with leading tags replaced by compact indicators + """ + + def _extract_file_path(content: str) -> str | None: + """Extract file path from IDE tag content.""" + # Try to find an absolute path (starts with /) + # Stop at: whitespace, colon followed by newline, or "in the IDE" + path_match = re.search( + r"(/[^\s:]+(?:\.[^\s:]+)?)(?::\s|\s+in\s+the\s+IDE|\s*$|\s)", content + ) + if path_match: + return path_match.group(1).rstrip(".:") + + # Fallback: look for "file" or "from" followed by a path + path_match = re.search(r"(?:file|from)\s+(/[^\s:]+)", content) + if path_match: + return path_match.group(1).rstrip(".:") + + return None + + # Process only LEADING IDE tags - stop when we hit non-IDE content + # This prevents replacing tags inside quoted strings/JSONL content + compact_parts: list[str] = [] + remaining = text_content + + while remaining: + # Try to match each IDE tag type at the start of remaining text + # Check for at start + match = re.match( + r"^\s*(.*?)", remaining, re.DOTALL + ) + if match: + content = match.group(1).strip() + filepath = _extract_file_path(content) + compact_parts.append(f"📎 {filepath}" if filepath else "📎 file") + remaining = remaining[match.end() :] + continue + + # Check for at start + match = re.match( + r"^\s*(.*?)", remaining, re.DOTALL + ) + if match: + content = match.group(1).strip() + filepath = _extract_file_path(content) + compact_parts.append(f"âœ‚ī¸ {filepath}" if filepath else "âœ‚ī¸ selection") + remaining = remaining[match.end() :] + continue + + # Check for ... at start + match = re.match( + r"^\s*\s*.*?\s*", + remaining, + re.DOTALL, + ) + if match: + compact_parts.append("đŸŠē diagnostics") + remaining = remaining[match.end() :] + continue + + # Check for command at start + match = re.match( + r"^\s*(.*?)", + remaining, + re.DOTALL, + ) + if match: + command = match.group(1).strip() + # Truncate very long commands + if len(command) > 50: + command = command[:47] + "..." + compact_parts.append(f"đŸ’ģ {command}") + remaining = remaining[match.end() :] + continue + + # No more tags at start - stop processing + break + + # Combine compact indicators with remaining content + if compact_parts: + # Add newline between indicators and content if there's remaining text + prefix = "\n".join(compact_parts) + if remaining.strip(): + return f"{prefix}\n{remaining.lstrip()}" + return prefix + + return text_content + + +def is_warmup_only_session(messages: List[TranscriptEntry], session_id: str) -> bool: + """Check if a session contains only warmup user messages. + + A warmup session is one where ALL user messages are literally just "Warmup". + Sessions with no user messages return False (not considered warmup). + + Args: + messages: List of all transcript entries + session_id: The session ID to check + + Returns: + True if ALL user messages in the session are "Warmup", False otherwise + """ + from .parser import extract_text_content + + user_messages_in_session: List[str] = [] + + for message in messages: + if ( + isinstance(message, UserTranscriptEntry) + and getattr(message, "sessionId", "") == session_id + and hasattr(message, "message") + ): + text_content = extract_text_content(message.message.content).strip() + user_messages_in_session.append(text_content) + + # No user messages = not a warmup session + if not user_messages_in_session: + return False + + # All user messages must be exactly "Warmup" + return all(msg == "Warmup" for msg in user_messages_in_session) + + +def get_warmup_session_ids(messages: List[TranscriptEntry]) -> set[str]: + """Get set of session IDs that are warmup-only sessions. + + Pre-computes warmup status for all sessions for efficiency (O(n) once, + then O(1) lookup per session). + + Args: + messages: List of all transcript entries + + Returns: + Set of session IDs that contain only warmup messages + """ + from .parser import extract_text_content + + # Group user message text by session + session_user_messages: Dict[str, List[str]] = {} + + for message in messages: + if isinstance(message, UserTranscriptEntry) and hasattr(message, "message"): + session_id = getattr(message, "sessionId", "") + if session_id: + text_content = extract_text_content(message.message.content).strip() + if session_id not in session_user_messages: + session_user_messages[session_id] = [] + session_user_messages[session_id].append(text_content) + + # Find sessions where ALL user messages are "Warmup" + warmup_sessions: set[str] = set() + for session_id, user_msgs in session_user_messages.items(): + if user_msgs and all(msg == "Warmup" for msg in user_msgs): + warmup_sessions.add(session_id) + + return warmup_sessions diff --git a/scripts/style_guide_output/index_style_guide.html b/scripts/style_guide_output/index_style_guide.html index 4f5e9bf3..0e85dece 100644 --- a/scripts/style_guide_output/index_style_guide.html +++ b/scripts/style_guide_output/index_style_guide.html @@ -48,10 +48,39 @@ --system-error-color: #f44336; --tool-use-color: #4caf50; + /* Question/answer tool colors */ + --question-accent: #f5a623; + --question-bg: #fffbf0; + --answer-accent: #4caf50; + --answer-bg: #f0fff4; + + /* Priority colors for todos */ + --priority-high: #dc3545; + --priority-medium: #ffc107; + --priority-low: #28a745; + + /* Status colors */ + --status-in-progress: #fff3cd; + --status-completed: #d4edda; + + /* Plan/todo accent color */ + --plan-accent: #6c63ff; + --todo-accent: #4169e1; + /* Solid colors for text and accents */ + --text-primary: #333; --text-muted: #666; --text-secondary: #495057; + /* Border colors */ + --border-light: #e0e0e0; + --border-separator: #f0f3f6; + + /* Background colors */ + --bg-card: #ffffff66; + --bg-hover: #f8f9fa; + --bg-neutral: #f8f9fa; + /* Layout spacing */ --message-padding: 1em; @@ -67,7 +96,7 @@ margin: 0 auto; padding: 10px; background: linear-gradient(90deg, #f3d6d2, #f1dcce, #f0e4ca, #eeecc7, #e3ecc3, #d5eac0, #c6e8bd, #b9e6bc, #b6e3c5, #b3e1cf); - color: #333; + color: var(--text-primary); } h1 { @@ -99,6 +128,12 @@ line-height: 1.5; } +/* Summary stats layout (used in index.html) */ +.summary-stats-flex { + display: flex; + gap: 3em; +} + /* Common card styling */ .card-base { background-color: #ffffff66; @@ -147,7 +182,7 @@ /* Timestamps */ .timestamp { font-size: 0.85em; - color: #666; + color: var(--text-muted); font-weight: normal; } @@ -155,8 +190,8 @@ .floating-btn { position: fixed; right: 20px; - background-color: #e8f4fd66; - color: #666; + background-color: var(--session-bg-dimmed); + color: var(--text-muted); border: none; border-radius: 50%; width: 50px; @@ -179,7 +214,7 @@ } .floating-btn:visited { - color: #666; + color: var(--text-muted); } /* Floating buttons positioning */ @@ -200,13 +235,13 @@ } /* Session navigation styles */ .navigation { - background-color: #f8f9fa66; + background-color: var(--bg-neutral); border-radius: 8px; padding: 16px; margin-bottom: 24px; box-shadow: -7px -7px 10px #eeeeee44, 7px 7px 10px #00000011; - border-left: #ffffff66 1px solid; - border-top: #ffffff66 1px solid; + border-left: var(--white-dimmed) 1px solid; + border-top: var(--white-dimmed) 1px solid; border-bottom: #00000017 1px solid; border-right: #00000017 1px solid; } @@ -214,7 +249,7 @@ .navigation h2 { margin: 0 0 12px 0; font-size: 1.2em; - color: #495057; + color: var(--text-secondary); } .session-nav { @@ -225,11 +260,11 @@ .session-link { padding: 8px 12px; - background-color: #ffffff66; + background-color: var(--white-dimmed); border: 1px solid #dee2e6; border-radius: 4px; text-decoration: none; - color: #495057; + color: var(--text-secondary); transition: background-color 0.2s; } @@ -258,7 +293,7 @@ .project-sessions h4 { margin: 0 0 10px 0; font-size: 0.9em; - color: #495057; + color: var(--text-secondary); font-weight: 600; } @@ -280,11 +315,11 @@ .combined-transcript-link { display: inline-block; padding: 8px 12px; - background-color: #ffffff66; + background-color: var(--white-dimmed); border: 1px solid #dee2e6; border-radius: 4px; text-decoration: none; - color: #495057; + color: var(--text-secondary); font-weight: 500; transition: background-color 0.2s; } @@ -293,6 +328,17 @@ background-color: #ffffff99; text-decoration: none; } + +/* Navigation hints */ +.nav-hint { + font-size: 0.75em; +} + +/* Session preview styling */ +.session-preview { + font-size: 0.75em; + line-height: 1.3; +} /* Project card styles for index page */ .project-list { display: grid; @@ -325,15 +371,20 @@ .project-name a { text-decoration: none; - color: #2196f3; + color: var(--system-warning-color); } .project-name a:hover { text-decoration: underline; } +.transcript-link-hint { + font-size: 0.6em; + color: var(--text-muted); +} + .project-stats { - color: #666; + color: var(--text-muted); font-size: 0.9em; display: flex; gap: 20px; @@ -374,11 +425,11 @@ .summary-stat .number { font-size: 1.5em; font-weight: 600; - color: #2196f3; + color: var(--system-warning-color); } .summary-stat .label { - color: #666; + color: var(--text-muted); font-size: 0.9em; } @@ -389,14 +440,14 @@ .project-sessions details summary { cursor: pointer; - color: #666; + color: var(--text-muted); font-size: 1.1em; font-weight: 600; padding: 8px 0; } .project-sessions details summary:hover { - color: #2196f3; + color: var(--system-warning-color); } .project-sessions details[open] summary { @@ -414,7 +465,7 @@ right: 45px; top: 50%; transform: translateY(-50%); - color: #999; + color: var(--text-muted); font-size: 0.75em; pointer-events: none; transition: opacity 0.2s; @@ -449,7 +500,7 @@ background: #ffffff88; border: 1px solid #dee2e6; border-radius: 20px; - color: #495057; + color: var(--text-secondary); font-size: 0.85em; font-family: inherit; transition: all 0.2s; @@ -457,13 +508,13 @@ .search-input:focus { outline: none; - border-color: #2196f3; + border-color: var(--system-warning-color); background: #ffffff99; box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2); } .search-input::placeholder { - color: #666; + color: var(--text-muted); } .search-clear { @@ -471,7 +522,7 @@ right: 15px; background: none; border: none; - color: #666; + color: var(--text-muted); cursor: pointer; font-size: 18px; padding: 5px; @@ -481,8 +532,8 @@ } .search-clear:hover { - color: #333; - background: #ffffff66; + color: var(--text-primary); + background: var(--white-dimmed); } .search-clear.visible { @@ -493,7 +544,7 @@ .search-results-info { margin-top: 8px; font-size: 12px; - color: #666; + color: var(--text-muted); display: none; } @@ -521,11 +572,11 @@ align-items: center; gap: 4px; cursor: pointer; - color: #666; + color: var(--text-muted); } .search-option-inline:hover { - color: #333; + color: var(--text-primary); } .search-option-inline input[type="checkbox"] { @@ -537,7 +588,7 @@ background: #ffffff88; border: 1px solid #dee2e6; border-radius: 4px; - color: #333; + color: var(--text-primary); padding: 4px 8px; cursor: pointer; font-size: 12px; @@ -546,7 +597,7 @@ .search-nav-btn:hover:not(:disabled) { background: #ffffff99; - border-color: #2196f3; + border-color: var(--system-warning-color); transform: translateY(-1px); } @@ -580,7 +631,7 @@ display: flex; align-items: center; gap: 5px; - color: #666; + color: var(--text-muted); transition: color 0.2s; } @@ -595,18 +646,18 @@ } .search-option:hover { - color: #333; + color: var(--text-primary); } /* Index Page Search Results */ .search-results-panel { margin: 20px 0; padding: 20px; - background-color: #ffffff66; + background-color: var(--white-dimmed); border-radius: 8px; box-shadow: -7px -7px 10px #eeeeee44, 7px 7px 10px #00000011; - border-left: #ffffff66 1px solid; - border-top: #ffffff66 1px solid; + border-left: var(--white-dimmed) 1px solid; + border-top: var(--white-dimmed) 1px solid; border-bottom: #00000017 1px solid; border-right: #00000017 1px solid; display: none; @@ -634,7 +685,7 @@ .search-close-btn { background: none; border: none; - color: #666; + color: var(--text-muted); cursor: pointer; font-size: 20px; padding: 5px; @@ -643,8 +694,8 @@ } .search-close-btn:hover { - color: #333; - background: #ffffff66; + color: var(--text-primary); + background: var(--white-dimmed); } .search-result-group { @@ -663,7 +714,7 @@ .search-result-count { background: #ffffff88; - color: #666; + color: var(--text-muted); padding: 2px 8px; border-radius: 12px; font-size: 11px; @@ -699,14 +750,14 @@ .search-result-session { font-size: 12px; - color: #666; + color: var(--text-muted); margin-bottom: 5px; font-weight: 500; } .search-result-excerpt { font-size: 13px; - color: #495057; + color: var(--text-secondary); line-height: 1.4; } @@ -721,7 +772,7 @@ .search-result-meta { margin-top: 8px; font-size: 11px; - color: #666; + color: var(--text-muted); display: flex; gap: 15px; padding-top: 5px; @@ -734,7 +785,7 @@ .search-no-results { text-align: center; padding: 30px; - color: #666; + color: var(--text-muted); font-size: 14px; background-color: #ffffff44; border-radius: 6px; @@ -745,7 +796,7 @@ .search-loading { text-align: center; padding: 20px; - color: #666; + color: var(--text-muted); background-color: #ffffff44; border-radius: 6px; } @@ -1562,7 +1613,7 @@

Claude Code Projects (from last week to today)

-
+
4
Projects
@@ -1586,7 +1637,7 @@

Claude Code Projects (from last week to today)

org/internal/tools/automation - (← open combined transcript) + (← open combined transcript)
📁 12 transcript files
@@ -1603,7 +1654,7 @@

Claude Code Projects (from last week to today)

user/workspace/my/web/app - (← open combined transcript) + (← open combined transcript)
📁 8 transcript files
@@ -1620,7 +1671,7 @@

Claude Code Projects (from last week to today)

data-analysis-project - (← open combined transcript) + (← open combined transcript)
📁 3 transcript files
@@ -1637,7 +1688,7 @@

Claude Code Projects (from last week to today)

claude-code-assistant - (← open combined transcript) + (← open combined transcript)
📁 5 transcript files
diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 897b51c5..ed1796fb 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -2233,11 +2233,11 @@ } .fold-bar[data-border-color="bash-input"] .fold-bar-section { - border-bottom-color: var(--tool-use-color); + border-bottom-color: var(--user-color); } .fold-bar[data-border-color="bash-output"] .fold-bar-section { - border-bottom-color: var(--success-dimmed); + border-bottom-color: var(--user-dimmed); } .fold-bar[data-border-color="session-header"] .fold-bar-section { @@ -2261,7 +2261,9 @@ /* Right-aligned messages (user-initiated, right margin 0, left margin 33%) */ .user:not(.compacted), - .system { + .system, + .bash-input, + .bash-output { margin-left: 33%; margin-right: 0; } @@ -2423,6 +2425,59 @@ font-size: 80%; } + /* Hook summary styling */ + .system-hook { + border-left-color: var(--warning-dimmed); + background-color: var(--highlight-dimmed); + font-size: 90%; + } + + .hook-summary { + cursor: pointer; + } + + .hook-summary summary { + display: flex; + align-items: center; + gap: 0.5em; + } + + .hook-details { + margin-top: 0.5em; + padding: 0.5em; + background-color: var(--code-bg); + border-radius: 4px; + } + + .hook-commands { + margin-bottom: 0.5em; + } + + .hook-commands code { + display: block; + padding: 0.25em 0.5em; + font-size: 0.85em; + word-break: break-all; + white-space: pre-wrap; + } + + .hook-errors { + margin-top: 0.5em; + } + + .hook-error { + margin: 0.25em 0; + padding: 0.5em; + background-color: var(--error-semi); + border-left: 3px solid var(--system-error-color); + font-size: 0.85em; + white-space: pre-wrap; + word-wrap: break-word; + overflow-x: auto; + max-height: 300px; + overflow-y: auto; + } + /* Command output styling */ .command-output { background-color: #1e1e1e11; @@ -2453,14 +2508,14 @@ padding: 0 1em; } - /* Bash command styling */ + /* Bash command styling (user-initiated, right-aligned) */ .bash-input { - background-color: #1e1e1e08; - border-left-color: var(--tool-use-color); + background-color: var(--highlight-light); + border-left-color: var(--user-color); } .bash-prompt { - color: var(--tool-use-color); + color: var(--user-color); font-weight: bold; font-size: 1.1em; margin-right: 8px; @@ -2475,40 +2530,36 @@ border-radius: 3px; } - /* Bash output styling */ + /* Bash output styling (user-initiated, right-aligned) */ .bash-output { - background-color: var(--neutral-dimmed); - border-left-color: #607d8b; + background-color: var(--highlight-light); + border-left-color: var(--user-dimmed); } - .bash-stdout { - background-color: #1e1e1e05; - padding: 12px; + .bash-output pre.bash-stdout, + .bash-output pre.bash-stderr { + padding: 8px; border-radius: 4px; - border: 1px solid #00000011; - margin: 8px 0; + margin: 4px 0; font-family: var(--font-monospace); - font-size: 0.9em; - line-height: 1.4; - white-space: pre-wrap; - word-wrap: break-word; - color: var(--text-primary); + font-size: 80%; + line-height: 1.3; + white-space: pre; overflow-x: auto; + overflow-y: auto; + max-height: 300px; + } + + .bash-output pre.bash-stdout { + background-color: #1e1e1e05; + border: 1px solid #00000011; + color: var(--text-primary); } - .bash-stderr { + .bash-output pre.bash-stderr { background-color: #ffebee; - padding: 12px; - border-radius: 4px; border: 1px solid #ffcdd2; - margin: 8px 0; - font-family: var(--font-monospace); - font-size: 0.9em; - line-height: 1.4; - white-space: pre-wrap; - word-wrap: break-word; color: #c62828; - overflow-x: auto; } .bash-empty { @@ -2838,9 +2889,10 @@ margin-top: 4px; } - /* Tool use/result preview content with gradient fade */ + /* Tool use/result/bash-output preview content with gradient fade */ .tool_use .preview-content, - .tool_result .preview-content { + .tool_result .preview-content, + .bash-output .preview-content { opacity: 0.7; mask-image: linear-gradient(to bottom, black 80%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); @@ -5317,8 +5369,8 @@ } }); - // Handle combined "tool" filter (tool_use + tool_result) - const toolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header)`); + // Handle combined "tool" filter (tool_use + tool_result + bash messages) + const toolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header), .message.bash-input:not(.session-header), .message.bash-output:not(.session-header)`); const toolCount = toolMessages.length; const toolToggle = document.querySelector(`[data-type="tool"]`); const toolCountSpan = toolToggle ? toolToggle.querySelector('.count') : null; @@ -5339,11 +5391,11 @@ .filter(toggle => toggle.classList.contains('active')) .map(toggle => toggle.dataset.type); - // Expand "tool" to include both tool_use and tool_result + // Expand "tool" to include tool_use, tool_result, and bash messages const expandedTypes = []; activeTypes.forEach(type => { if (type === 'tool') { - expandedTypes.push('tool_use', 'tool_result'); + expandedTypes.push('tool_use', 'tool_result', 'bash-input', 'bash-output'); } else { expandedTypes.push(type); } @@ -5414,9 +5466,9 @@ } }); - // Handle combined "tool" filter separately - const visibleToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header):not(.filtered-hidden), .message.tool_result:not(.session-header):not(.filtered-hidden)`); - const totalToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header)`); + // Handle combined "tool" filter separately (includes bash messages) + const visibleToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header):not(.filtered-hidden), .message.tool_result:not(.session-header):not(.filtered-hidden), .message.bash-input:not(.session-header):not(.filtered-hidden), .message.bash-output:not(.session-header):not(.filtered-hidden)`); + const totalToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header), .message.bash-input:not(.session-header), .message.bash-output:not(.session-header)`); const visibleToolCount = visibleToolMessages.length; const totalToolCount = totalToolMessages.length; @@ -6912,11 +6964,11 @@ } .fold-bar[data-border-color="bash-input"] .fold-bar-section { - border-bottom-color: var(--tool-use-color); + border-bottom-color: var(--user-color); } .fold-bar[data-border-color="bash-output"] .fold-bar-section { - border-bottom-color: var(--success-dimmed); + border-bottom-color: var(--user-dimmed); } .fold-bar[data-border-color="session-header"] .fold-bar-section { @@ -6940,7 +6992,9 @@ /* Right-aligned messages (user-initiated, right margin 0, left margin 33%) */ .user:not(.compacted), - .system { + .system, + .bash-input, + .bash-output { margin-left: 33%; margin-right: 0; } @@ -7102,6 +7156,59 @@ font-size: 80%; } + /* Hook summary styling */ + .system-hook { + border-left-color: var(--warning-dimmed); + background-color: var(--highlight-dimmed); + font-size: 90%; + } + + .hook-summary { + cursor: pointer; + } + + .hook-summary summary { + display: flex; + align-items: center; + gap: 0.5em; + } + + .hook-details { + margin-top: 0.5em; + padding: 0.5em; + background-color: var(--code-bg); + border-radius: 4px; + } + + .hook-commands { + margin-bottom: 0.5em; + } + + .hook-commands code { + display: block; + padding: 0.25em 0.5em; + font-size: 0.85em; + word-break: break-all; + white-space: pre-wrap; + } + + .hook-errors { + margin-top: 0.5em; + } + + .hook-error { + margin: 0.25em 0; + padding: 0.5em; + background-color: var(--error-semi); + border-left: 3px solid var(--system-error-color); + font-size: 0.85em; + white-space: pre-wrap; + word-wrap: break-word; + overflow-x: auto; + max-height: 300px; + overflow-y: auto; + } + /* Command output styling */ .command-output { background-color: #1e1e1e11; @@ -7132,14 +7239,14 @@ padding: 0 1em; } - /* Bash command styling */ + /* Bash command styling (user-initiated, right-aligned) */ .bash-input { - background-color: #1e1e1e08; - border-left-color: var(--tool-use-color); + background-color: var(--highlight-light); + border-left-color: var(--user-color); } .bash-prompt { - color: var(--tool-use-color); + color: var(--user-color); font-weight: bold; font-size: 1.1em; margin-right: 8px; @@ -7154,40 +7261,36 @@ border-radius: 3px; } - /* Bash output styling */ + /* Bash output styling (user-initiated, right-aligned) */ .bash-output { - background-color: var(--neutral-dimmed); - border-left-color: #607d8b; + background-color: var(--highlight-light); + border-left-color: var(--user-dimmed); } - .bash-stdout { - background-color: #1e1e1e05; - padding: 12px; + .bash-output pre.bash-stdout, + .bash-output pre.bash-stderr { + padding: 8px; border-radius: 4px; - border: 1px solid #00000011; - margin: 8px 0; + margin: 4px 0; font-family: var(--font-monospace); - font-size: 0.9em; - line-height: 1.4; - white-space: pre-wrap; - word-wrap: break-word; - color: var(--text-primary); + font-size: 80%; + line-height: 1.3; + white-space: pre; overflow-x: auto; + overflow-y: auto; + max-height: 300px; + } + + .bash-output pre.bash-stdout { + background-color: #1e1e1e05; + border: 1px solid #00000011; + color: var(--text-primary); } - .bash-stderr { + .bash-output pre.bash-stderr { background-color: #ffebee; - padding: 12px; - border-radius: 4px; border: 1px solid #ffcdd2; - margin: 8px 0; - font-family: var(--font-monospace); - font-size: 0.9em; - line-height: 1.4; - white-space: pre-wrap; - word-wrap: break-word; color: #c62828; - overflow-x: auto; } .bash-empty { @@ -7517,9 +7620,10 @@ margin-top: 4px; } - /* Tool use/result preview content with gradient fade */ + /* Tool use/result/bash-output preview content with gradient fade */ .tool_use .preview-content, - .tool_result .preview-content { + .tool_result .preview-content, + .bash-output .preview-content { opacity: 0.7; mask-image: linear-gradient(to bottom, black 80%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); @@ -9441,57 +9545,6 @@ - - - - - - @@ -10154,8 +10207,8 @@ } }); - // Handle combined "tool" filter (tool_use + tool_result) - const toolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header)`); + // Handle combined "tool" filter (tool_use + tool_result + bash messages) + const toolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header), .message.bash-input:not(.session-header), .message.bash-output:not(.session-header)`); const toolCount = toolMessages.length; const toolToggle = document.querySelector(`[data-type="tool"]`); const toolCountSpan = toolToggle ? toolToggle.querySelector('.count') : null; @@ -10176,11 +10229,11 @@ .filter(toggle => toggle.classList.contains('active')) .map(toggle => toggle.dataset.type); - // Expand "tool" to include both tool_use and tool_result + // Expand "tool" to include tool_use, tool_result, and bash messages const expandedTypes = []; activeTypes.forEach(type => { if (type === 'tool') { - expandedTypes.push('tool_use', 'tool_result'); + expandedTypes.push('tool_use', 'tool_result', 'bash-input', 'bash-output'); } else { expandedTypes.push(type); } @@ -10251,9 +10304,9 @@ } }); - // Handle combined "tool" filter separately - const visibleToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header):not(.filtered-hidden), .message.tool_result:not(.session-header):not(.filtered-hidden)`); - const totalToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header)`); + // Handle combined "tool" filter separately (includes bash messages) + const visibleToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header):not(.filtered-hidden), .message.tool_result:not(.session-header):not(.filtered-hidden), .message.bash-input:not(.session-header):not(.filtered-hidden), .message.bash-output:not(.session-header):not(.filtered-hidden)`); + const totalToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header), .message.bash-input:not(.session-header), .message.bash-output:not(.session-header)`); const visibleToolCount = visibleToolMessages.length; const totalToolCount = totalToolMessages.length; @@ -11749,11 +11802,11 @@ } .fold-bar[data-border-color="bash-input"] .fold-bar-section { - border-bottom-color: var(--tool-use-color); + border-bottom-color: var(--user-color); } .fold-bar[data-border-color="bash-output"] .fold-bar-section { - border-bottom-color: var(--success-dimmed); + border-bottom-color: var(--user-dimmed); } .fold-bar[data-border-color="session-header"] .fold-bar-section { @@ -11777,7 +11830,9 @@ /* Right-aligned messages (user-initiated, right margin 0, left margin 33%) */ .user:not(.compacted), - .system { + .system, + .bash-input, + .bash-output { margin-left: 33%; margin-right: 0; } @@ -11939,6 +11994,59 @@ font-size: 80%; } + /* Hook summary styling */ + .system-hook { + border-left-color: var(--warning-dimmed); + background-color: var(--highlight-dimmed); + font-size: 90%; + } + + .hook-summary { + cursor: pointer; + } + + .hook-summary summary { + display: flex; + align-items: center; + gap: 0.5em; + } + + .hook-details { + margin-top: 0.5em; + padding: 0.5em; + background-color: var(--code-bg); + border-radius: 4px; + } + + .hook-commands { + margin-bottom: 0.5em; + } + + .hook-commands code { + display: block; + padding: 0.25em 0.5em; + font-size: 0.85em; + word-break: break-all; + white-space: pre-wrap; + } + + .hook-errors { + margin-top: 0.5em; + } + + .hook-error { + margin: 0.25em 0; + padding: 0.5em; + background-color: var(--error-semi); + border-left: 3px solid var(--system-error-color); + font-size: 0.85em; + white-space: pre-wrap; + word-wrap: break-word; + overflow-x: auto; + max-height: 300px; + overflow-y: auto; + } + /* Command output styling */ .command-output { background-color: #1e1e1e11; @@ -11969,14 +12077,14 @@ padding: 0 1em; } - /* Bash command styling */ + /* Bash command styling (user-initiated, right-aligned) */ .bash-input { - background-color: #1e1e1e08; - border-left-color: var(--tool-use-color); + background-color: var(--highlight-light); + border-left-color: var(--user-color); } .bash-prompt { - color: var(--tool-use-color); + color: var(--user-color); font-weight: bold; font-size: 1.1em; margin-right: 8px; @@ -11991,40 +12099,36 @@ border-radius: 3px; } - /* Bash output styling */ + /* Bash output styling (user-initiated, right-aligned) */ .bash-output { - background-color: var(--neutral-dimmed); - border-left-color: #607d8b; + background-color: var(--highlight-light); + border-left-color: var(--user-dimmed); } - .bash-stdout { - background-color: #1e1e1e05; - padding: 12px; + .bash-output pre.bash-stdout, + .bash-output pre.bash-stderr { + padding: 8px; border-radius: 4px; - border: 1px solid #00000011; - margin: 8px 0; + margin: 4px 0; font-family: var(--font-monospace); - font-size: 0.9em; - line-height: 1.4; - white-space: pre-wrap; - word-wrap: break-word; - color: var(--text-primary); + font-size: 80%; + line-height: 1.3; + white-space: pre; overflow-x: auto; + overflow-y: auto; + max-height: 300px; + } + + .bash-output pre.bash-stdout { + background-color: #1e1e1e05; + border: 1px solid #00000011; + color: var(--text-primary); } - .bash-stderr { + .bash-output pre.bash-stderr { background-color: #ffebee; - padding: 12px; - border-radius: 4px; border: 1px solid #ffcdd2; - margin: 8px 0; - font-family: var(--font-monospace); - font-size: 0.9em; - line-height: 1.4; - white-space: pre-wrap; - word-wrap: break-word; color: #c62828; - overflow-x: auto; } .bash-empty { @@ -12354,9 +12458,10 @@ margin-top: 4px; } - /* Tool use/result preview content with gradient fade */ + /* Tool use/result/bash-output preview content with gradient fade */ .tool_use .preview-content, - .tool_result .preview-content { + .tool_result .preview-content, + .bash-output .preview-content { opacity: 0.7; mask-image: linear-gradient(to bottom, black 80%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); @@ -14971,8 +15076,8 @@ } }); - // Handle combined "tool" filter (tool_use + tool_result) - const toolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header)`); + // Handle combined "tool" filter (tool_use + tool_result + bash messages) + const toolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header), .message.bash-input:not(.session-header), .message.bash-output:not(.session-header)`); const toolCount = toolMessages.length; const toolToggle = document.querySelector(`[data-type="tool"]`); const toolCountSpan = toolToggle ? toolToggle.querySelector('.count') : null; @@ -14993,11 +15098,11 @@ .filter(toggle => toggle.classList.contains('active')) .map(toggle => toggle.dataset.type); - // Expand "tool" to include both tool_use and tool_result + // Expand "tool" to include tool_use, tool_result, and bash messages const expandedTypes = []; activeTypes.forEach(type => { if (type === 'tool') { - expandedTypes.push('tool_use', 'tool_result'); + expandedTypes.push('tool_use', 'tool_result', 'bash-input', 'bash-output'); } else { expandedTypes.push(type); } @@ -15068,9 +15173,9 @@ } }); - // Handle combined "tool" filter separately - const visibleToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header):not(.filtered-hidden), .message.tool_result:not(.session-header):not(.filtered-hidden)`); - const totalToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header)`); + // Handle combined "tool" filter separately (includes bash messages) + const visibleToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header):not(.filtered-hidden), .message.tool_result:not(.session-header):not(.filtered-hidden), .message.bash-input:not(.session-header):not(.filtered-hidden), .message.bash-output:not(.session-header):not(.filtered-hidden)`); + const totalToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header), .message.bash-input:not(.session-header), .message.bash-output:not(.session-header)`); const visibleToolCount = visibleToolMessages.length; const totalToolCount = totalToolMessages.length; @@ -16566,11 +16671,11 @@ } .fold-bar[data-border-color="bash-input"] .fold-bar-section { - border-bottom-color: var(--tool-use-color); + border-bottom-color: var(--user-color); } .fold-bar[data-border-color="bash-output"] .fold-bar-section { - border-bottom-color: var(--success-dimmed); + border-bottom-color: var(--user-dimmed); } .fold-bar[data-border-color="session-header"] .fold-bar-section { @@ -16594,7 +16699,9 @@ /* Right-aligned messages (user-initiated, right margin 0, left margin 33%) */ .user:not(.compacted), - .system { + .system, + .bash-input, + .bash-output { margin-left: 33%; margin-right: 0; } @@ -16756,6 +16863,59 @@ font-size: 80%; } + /* Hook summary styling */ + .system-hook { + border-left-color: var(--warning-dimmed); + background-color: var(--highlight-dimmed); + font-size: 90%; + } + + .hook-summary { + cursor: pointer; + } + + .hook-summary summary { + display: flex; + align-items: center; + gap: 0.5em; + } + + .hook-details { + margin-top: 0.5em; + padding: 0.5em; + background-color: var(--code-bg); + border-radius: 4px; + } + + .hook-commands { + margin-bottom: 0.5em; + } + + .hook-commands code { + display: block; + padding: 0.25em 0.5em; + font-size: 0.85em; + word-break: break-all; + white-space: pre-wrap; + } + + .hook-errors { + margin-top: 0.5em; + } + + .hook-error { + margin: 0.25em 0; + padding: 0.5em; + background-color: var(--error-semi); + border-left: 3px solid var(--system-error-color); + font-size: 0.85em; + white-space: pre-wrap; + word-wrap: break-word; + overflow-x: auto; + max-height: 300px; + overflow-y: auto; + } + /* Command output styling */ .command-output { background-color: #1e1e1e11; @@ -16786,14 +16946,14 @@ padding: 0 1em; } - /* Bash command styling */ + /* Bash command styling (user-initiated, right-aligned) */ .bash-input { - background-color: #1e1e1e08; - border-left-color: var(--tool-use-color); + background-color: var(--highlight-light); + border-left-color: var(--user-color); } .bash-prompt { - color: var(--tool-use-color); + color: var(--user-color); font-weight: bold; font-size: 1.1em; margin-right: 8px; @@ -16808,40 +16968,36 @@ border-radius: 3px; } - /* Bash output styling */ + /* Bash output styling (user-initiated, right-aligned) */ .bash-output { - background-color: var(--neutral-dimmed); - border-left-color: #607d8b; + background-color: var(--highlight-light); + border-left-color: var(--user-dimmed); } - .bash-stdout { - background-color: #1e1e1e05; - padding: 12px; + .bash-output pre.bash-stdout, + .bash-output pre.bash-stderr { + padding: 8px; border-radius: 4px; - border: 1px solid #00000011; - margin: 8px 0; + margin: 4px 0; font-family: var(--font-monospace); - font-size: 0.9em; - line-height: 1.4; - white-space: pre-wrap; - word-wrap: break-word; - color: var(--text-primary); + font-size: 80%; + line-height: 1.3; + white-space: pre; overflow-x: auto; + overflow-y: auto; + max-height: 300px; + } + + .bash-output pre.bash-stdout { + background-color: #1e1e1e05; + border: 1px solid #00000011; + color: var(--text-primary); } - .bash-stderr { + .bash-output pre.bash-stderr { background-color: #ffebee; - padding: 12px; - border-radius: 4px; border: 1px solid #ffcdd2; - margin: 8px 0; - font-family: var(--font-monospace); - font-size: 0.9em; - line-height: 1.4; - white-space: pre-wrap; - word-wrap: break-word; color: #c62828; - overflow-x: auto; } .bash-empty { @@ -17171,9 +17327,10 @@ margin-top: 4px; } - /* Tool use/result preview content with gradient fade */ + /* Tool use/result/bash-output preview content with gradient fade */ .tool_use .preview-content, - .tool_result .preview-content { + .tool_result .preview-content, + .bash-output .preview-content { opacity: 0.7; mask-image: linear-gradient(to bottom, black 80%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); @@ -19650,8 +19807,8 @@ } }); - // Handle combined "tool" filter (tool_use + tool_result) - const toolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header)`); + // Handle combined "tool" filter (tool_use + tool_result + bash messages) + const toolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header), .message.bash-input:not(.session-header), .message.bash-output:not(.session-header)`); const toolCount = toolMessages.length; const toolToggle = document.querySelector(`[data-type="tool"]`); const toolCountSpan = toolToggle ? toolToggle.querySelector('.count') : null; @@ -19672,11 +19829,11 @@ .filter(toggle => toggle.classList.contains('active')) .map(toggle => toggle.dataset.type); - // Expand "tool" to include both tool_use and tool_result + // Expand "tool" to include tool_use, tool_result, and bash messages const expandedTypes = []; activeTypes.forEach(type => { if (type === 'tool') { - expandedTypes.push('tool_use', 'tool_result'); + expandedTypes.push('tool_use', 'tool_result', 'bash-input', 'bash-output'); } else { expandedTypes.push(type); } @@ -19747,9 +19904,9 @@ } }); - // Handle combined "tool" filter separately - const visibleToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header):not(.filtered-hidden), .message.tool_result:not(.session-header):not(.filtered-hidden)`); - const totalToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header)`); + // Handle combined "tool" filter separately (includes bash messages) + const visibleToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header):not(.filtered-hidden), .message.tool_result:not(.session-header):not(.filtered-hidden), .message.bash-input:not(.session-header):not(.filtered-hidden), .message.bash-output:not(.session-header):not(.filtered-hidden)`); + const totalToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header), .message.bash-input:not(.session-header), .message.bash-output:not(.session-header)`); const visibleToolCount = visibleToolMessages.length; const totalToolCount = totalToolMessages.length; diff --git a/test/test_hook_summary.py b/test/test_hook_summary.py new file mode 100644 index 00000000..ee62df7c --- /dev/null +++ b/test/test_hook_summary.py @@ -0,0 +1,249 @@ +"""Tests for hook summary (stop_hook_summary) parsing and rendering.""" + +from claude_code_log.models import parse_transcript_entry, SystemTranscriptEntry +from claude_code_log.renderer import generate_html + + +class TestHookSummaryParsing: + """Test parsing of stop_hook_summary system entries.""" + + def test_parse_hook_summary_without_content(self): + """Test that hook summary without content field parses successfully.""" + data = { + "parentUuid": "test-parent", + "isSidechain": False, + "userType": "external", + "cwd": "/home/user", + "sessionId": "test-session", + "version": "2.0.56", + "type": "system", + "subtype": "stop_hook_summary", + "hookCount": 1, + "hookInfos": [{"command": "uv run ruff format && uv run ruff check"}], + "hookErrors": [], + "preventedContinuation": False, + "stopReason": "", + "hasOutput": False, + "level": "suggestion", + "timestamp": "2025-12-02T23:05:58.427Z", + "uuid": "test-uuid", + } + + entry = parse_transcript_entry(data) + + assert isinstance(entry, SystemTranscriptEntry) + assert entry.subtype == "stop_hook_summary" + assert entry.content is None + assert entry.hasOutput is False + assert entry.hookErrors == [] + assert entry.hookInfos == [ + {"command": "uv run ruff format && uv run ruff check"} + ] + assert entry.preventedContinuation is False + + def test_parse_hook_summary_with_errors(self): + """Test that hook summary with errors parses successfully.""" + data = { + "parentUuid": "test-parent", + "isSidechain": False, + "userType": "external", + "cwd": "/home/user", + "sessionId": "test-session", + "version": "2.0.56", + "type": "system", + "subtype": "stop_hook_summary", + "hookCount": 1, + "hookInfos": [{"command": "pnpm lint"}], + "hookErrors": [ + "Error: TypeScript compilation failed\nTS2307: Cannot find module" + ], + "preventedContinuation": False, + "stopReason": "", + "hasOutput": True, + "level": "suggestion", + "timestamp": "2025-12-02T23:05:58.427Z", + "uuid": "test-uuid", + } + + entry = parse_transcript_entry(data) + + assert isinstance(entry, SystemTranscriptEntry) + assert entry.subtype == "stop_hook_summary" + assert entry.hasOutput is True + assert entry.hookErrors is not None + assert len(entry.hookErrors) == 1 + assert "TypeScript compilation failed" in entry.hookErrors[0] + + def test_parse_system_message_with_content_still_works(self): + """Test that regular system messages with content still parse correctly.""" + data = { + "parentUuid": "test-parent", + "isSidechain": False, + "userType": "external", + "cwd": "/home/user", + "sessionId": "test-session", + "version": "2.0.56", + "type": "system", + "content": "init", + "level": "info", + "timestamp": "2025-12-02T23:05:58.427Z", + "uuid": "test-uuid", + } + + entry = parse_transcript_entry(data) + + assert isinstance(entry, SystemTranscriptEntry) + assert entry.content == "init" + assert entry.subtype is None + + +class TestHookSummaryRendering: + """Test rendering of stop_hook_summary system entries.""" + + def test_silent_hook_success_not_rendered(self): + """Test that silent hook successes (no output, no errors) are not rendered.""" + messages = [ + { + "parentUuid": None, + "isSidechain": False, + "userType": "external", + "cwd": "/home/user", + "sessionId": "test-session", + "version": "2.0.56", + "type": "system", + "subtype": "stop_hook_summary", + "hookCount": 1, + "hookInfos": [{"command": "uv run ruff format"}], + "hookErrors": [], + "preventedContinuation": False, + "hasOutput": False, + "level": "suggestion", + "timestamp": "2025-12-02T23:05:58.427Z", + "uuid": "test-uuid", + } + ] + + parsed_messages = [parse_transcript_entry(msg) for msg in messages] + html = generate_html(parsed_messages) + + # Should not contain actual hook content (skipped) + # Note: CSS class definitions for .hook-summary will still be in the HTML + assert "Hook failed" not in html + assert "Hook output" not in html + assert "uv run ruff format" not in html # The hook command should not appear + + def test_hook_with_errors_rendered(self): + """Test that hooks with errors are rendered as collapsible details.""" + messages = [ + { + "parentUuid": None, + "isSidechain": False, + "userType": "external", + "cwd": "/home/user", + "sessionId": "test-session", + "version": "2.0.56", + "type": "system", + "subtype": "stop_hook_summary", + "hookCount": 1, + "hookInfos": [{"command": "pnpm lint"}], + "hookErrors": ["Error: lint failed"], + "preventedContinuation": False, + "hasOutput": True, + "level": "suggestion", + "timestamp": "2025-12-02T23:05:58.427Z", + "uuid": "test-uuid", + } + ] + + parsed_messages = [parse_transcript_entry(msg) for msg in messages] + html = generate_html(parsed_messages) + + # Should contain hook summary elements + assert "hook-summary" in html + assert "Hook failed" in html + assert "pnpm lint" in html + assert "Error: lint failed" in html + + def test_hook_with_output_but_no_errors_rendered(self): + """Test that hooks with output but no errors are rendered.""" + messages = [ + { + "parentUuid": None, + "isSidechain": False, + "userType": "external", + "cwd": "/home/user", + "sessionId": "test-session", + "version": "2.0.56", + "type": "system", + "subtype": "stop_hook_summary", + "hookCount": 1, + "hookInfos": [{"command": "echo 'formatted'"}], + "hookErrors": [], + "preventedContinuation": False, + "hasOutput": True, + "level": "suggestion", + "timestamp": "2025-12-02T23:05:58.427Z", + "uuid": "test-uuid", + } + ] + + parsed_messages = [parse_transcript_entry(msg) for msg in messages] + html = generate_html(parsed_messages) + + # Should contain hook summary elements + assert "hook-summary" in html + assert "Hook output" in html # Not "Hook failed" since no errors + + def test_hook_with_ansi_errors_rendered(self): + """Test that ANSI codes in hook errors are converted to HTML.""" + messages = [ + { + "parentUuid": None, + "isSidechain": False, + "userType": "external", + "cwd": "/home/user", + "sessionId": "test-session", + "version": "2.0.56", + "type": "system", + "subtype": "stop_hook_summary", + "hookCount": 1, + "hookInfos": [{"command": "pnpm lint"}], + "hookErrors": ["\x1b[31mError:\x1b[0m Something went wrong"], + "preventedContinuation": False, + "hasOutput": True, + "level": "suggestion", + "timestamp": "2025-12-02T23:05:58.427Z", + "uuid": "test-uuid", + } + ] + + parsed_messages = [parse_transcript_entry(msg) for msg in messages] + html = generate_html(parsed_messages) + + # ANSI codes should be converted, not present raw + assert "\x1b[" not in html + assert "Something went wrong" in html + + def test_regular_system_message_still_renders(self): + """Test that regular system messages with content still render correctly.""" + messages = [ + { + "parentUuid": None, + "isSidechain": False, + "userType": "external", + "cwd": "/home/user", + "sessionId": "test-session", + "version": "2.0.56", + "type": "system", + "content": "init", + "level": "info", + "timestamp": "2025-12-02T23:05:58.427Z", + "uuid": "test-uuid", + } + ] + + parsed_messages = [parse_transcript_entry(msg) for msg in messages] + html = generate_html(parsed_messages) + + # Should render the command name + assert "init" in html diff --git a/test/test_utils.py b/test/test_utils.py index ec018f6c..2404862a 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -11,8 +11,20 @@ should_skip_message, should_use_as_session_starter, extract_text_content_length, + create_session_preview, + is_warmup_only_session, + get_warmup_session_ids, + _compact_ide_tags_for_preview, + FIRST_USER_MESSAGE_PREVIEW_LENGTH, +) +from claude_code_log.models import ( + TextContent, + ToolUseContent, + UserTranscriptEntry, + UserMessage, + AssistantTranscriptEntry, + AssistantMessage, ) -from claude_code_log.models import TextContent, ToolUseContent class TestSystemMessageDetection: @@ -361,3 +373,409 @@ def test_session_starter_edge_cases(self): # Test with init in the middle of command name init_middle = "reinitReinitialize" assert should_use_as_session_starter(init_middle) is False + + +class TestWarmupMessageFiltering: + """Test warmup message filtering in session starters.""" + + def test_should_not_use_warmup_as_starter(self): + """Test that 'Warmup' messages are filtered out from session starters.""" + assert should_use_as_session_starter("Warmup") is False + + def test_should_not_use_warmup_with_whitespace_as_starter(self): + """Test that 'Warmup' with whitespace is filtered out.""" + assert should_use_as_session_starter(" Warmup ") is False + assert should_use_as_session_starter("\nWarmup\n") is False + assert should_use_as_session_starter("\t Warmup \t") is False + + def test_should_use_warmup_in_sentence_as_starter(self): + """Test that messages containing 'Warmup' in a sentence are not filtered.""" + assert ( + should_use_as_session_starter("Let's warmup with a simple example") is True + ) + assert should_use_as_session_starter("Warmup exercises are important") is True + + def test_should_not_use_case_sensitive_warmup(self): + """Test that warmup filtering is case-sensitive (only exact 'Warmup').""" + # Only exact "Warmup" is filtered, not "warmup" or "WARMUP" + assert should_use_as_session_starter("warmup") is True + assert should_use_as_session_starter("WARMUP") is True + assert should_use_as_session_starter("WarmUp") is True + + +class TestCompactIDETagsForPreview: + """Test compact IDE tag rendering for session previews.""" + + def test_compact_ide_opened_file(self): + """Test that is replaced with compact indicator showing full path.""" + text = "The user opened the file /path/to/myfile.py in the IDE.What does this do?" + result = _compact_ide_tags_for_preview(text) + + assert "📎 /path/to/myfile.py" in result + assert "" not in result + + def test_compact_ide_opened_file_without_extension(self): + """Test that files without extensions are handled correctly.""" + text = "The user opened the file /Users/dain/workspace/claude-code-log/justfile in the IDE.Question" + result = _compact_ide_tags_for_preview(text) + + assert "📎 /Users/dain/workspace/claude-code-log/justfile" in result + assert "" not in result + + def test_compact_ide_selection_with_path(self): + """Test that shows file path when present.""" + text = "The user selected lines 1 to 10 from /path/to/file.pyCan you explain this?" + result = _compact_ide_tags_for_preview(text) + + assert "âœ‚ī¸ /path/to/file.py" in result + assert "" not in result + + def test_compact_ide_selection_strips_trailing_colon(self): + """Test that trailing colons are stripped from file paths in selections.""" + text = "The user selected the lines 194 to 194 from /path/to/justfile:\nrelease-push\n\nThis may or may not be related.Question" + result = _compact_ide_tags_for_preview(text) + + assert "âœ‚ī¸ /path/to/justfile" in result + assert "âœ‚ī¸ /path/to/justfile:" not in result # No trailing colon + assert "" not in result + + def test_compact_ide_selection_without_path(self): + """Test that falls back to 'selection' when no path.""" + text = "some selected code hereCan you explain this?" + result = _compact_ide_tags_for_preview(text) + + assert "âœ‚ī¸ selection" in result + assert "" not in result + + def test_compact_ide_diagnostics(self): + """Test that is replaced with stethoscope emoji.""" + text = '[{"severity": "error"}]Please fix this.' + result = _compact_ide_tags_for_preview(text) + + assert "đŸŠē diagnostics" in result + assert "" not in result + + def test_compact_multiple_ide_tags(self): + """Test multiple leading IDE tags are all compacted.""" + text = ( + "The user opened the file /src/file.py in the IDE." + "code" + "Question here" + ) + result = _compact_ide_tags_for_preview(text) + + assert "📎 /src/file.py" in result + assert "âœ‚ī¸" in result + assert "Question here" in result + + def test_compact_ide_tags_no_file_path(self): + """Test fallback when no file path can be extracted.""" + text = "Some content without a file pathQuestion" + result = _compact_ide_tags_for_preview(text) + + assert "📎 file" in result + assert "" not in result + + def test_compact_ide_tags_preserves_other_content(self): + """Test that content without IDE tags is preserved.""" + text = "This is a normal message without any IDE tags" + result = _compact_ide_tags_for_preview(text) + + assert result == text + + def test_embedded_ide_tags_not_replaced(self): + """Test that IDE tags embedded in message content (e.g., JSONL) are NOT replaced.""" + text = ( + "The user opened the file /path/to/file.py in the IDE." + 'Error: {"content":[{"text":"embedded tag"}]}' + ) + result = _compact_ide_tags_for_preview(text) + + # Leading tag should be compacted + assert "📎 /path/to/file.py" in result + assert "" not in result + + # Embedded tag should be preserved (not replaced) + assert "embedded tag" in result + + def test_only_leading_ide_tags_processed(self): + """Test that only IDE tags at the start are processed, not tags later in text.""" + text = "Some text first not at start more text" + result = _compact_ide_tags_for_preview(text) + + # Since there's no leading IDE tag, text should be unchanged + assert result == text + assert "" in result # Tag preserved + + def test_compact_bash_input(self): + """Test that is replaced with terminal emoji and command.""" + text = "uv run ty check" + result = _compact_ide_tags_for_preview(text) + + assert "đŸ’ģ uv run ty check" in result + assert "" not in result + + def test_compact_bash_input_with_following_text(self): + """Test bash-input followed by other text.""" + text = "git statusWhat does this show?" + result = _compact_ide_tags_for_preview(text) + + assert "đŸ’ģ git status" in result + assert "What does this show?" in result + assert "" not in result + + def test_compact_bash_input_truncates_long_commands(self): + """Test that very long commands are truncated.""" + long_command = "very-long-command-that-exceeds-fifty-characters-in-total-length" + text = f"{long_command}" + result = _compact_ide_tags_for_preview(text) + + # Should be truncated to 47 chars + "..." + assert "đŸ’ģ " in result + assert len(result.replace("đŸ’ģ ", "")) <= 50 + assert result.endswith("...") + + def test_compact_bash_input_with_ide_tags(self): + """Test bash-input combined with IDE tags.""" + text = ( + "The user opened the file /src/test.py in the IDE." + "pytest test_file.py" + "Run this test" + ) + result = _compact_ide_tags_for_preview(text) + + assert "📎 /src/test.py" in result + assert "đŸ’ģ pytest test_file.py" in result + assert "Run this test" in result + + def test_embedded_bash_input_not_replaced(self): + """Test that bash-input tags embedded in content are NOT replaced.""" + text = 'Error message: {"content":"embedded"}' + result = _compact_ide_tags_for_preview(text) + + # No leading tag, so text should be unchanged + assert result == text + assert "embedded" in result + + +class TestCreateSessionPreview: + """Test session preview creation with IDE tags and truncation.""" + + def test_create_session_preview_uses_compact_ide_tags(self): + """Test that create_session_preview uses compact IDE tags with full path.""" + text = "The user selected lines 1 to 10 from /src/utils.pyCan you refactor this function?" + preview = create_session_preview(text) + + assert "âœ‚ī¸ /src/utils.py" in preview + assert "Can you refactor this function?" in preview + assert "" not in preview + + def test_create_session_preview_selection_fallback(self): + """Test that selection without path shows 'selection'.""" + text = "some code hereCan you explain?" + preview = create_session_preview(text) + + assert "âœ‚ī¸ selection" in preview + assert "" not in preview + + def test_create_session_preview_handles_truncation(self): + """Test that preview is truncated after IDE tag compacting.""" + long_message = "x" * (FIRST_USER_MESSAGE_PREVIEW_LENGTH + 100) + preview = create_session_preview(long_message) + + assert preview.endswith("...") + assert len(preview) == FIRST_USER_MESSAGE_PREVIEW_LENGTH + 3 # +3 for "..." + + def test_create_session_preview_multiple_ide_tags(self): + """Test preview creation with multiple IDE tags.""" + text = ( + "The user opened the file /src/test.py in the IDE." + "Lines 1-10" + "Please review this code for bugs" + ) + preview = create_session_preview(text) + + assert "📎 /src/test.py" in preview + assert "âœ‚ī¸" in preview + assert "Please review this code for bugs" in preview + + +class TestWarmupOnlySessionDetection: + """Test detection of warmup-only sessions.""" + + def _create_user_entry( + self, session_id: str, content: str, uuid: str, timestamp: str + ) -> UserTranscriptEntry: + """Helper to create a UserTranscriptEntry with all required fields.""" + return UserTranscriptEntry( + type="user", + sessionId=session_id, + parentUuid=None, + isSidechain=False, + userType="external", + cwd="/test", + version="1.0.0", + message=UserMessage(role="user", content=content), + uuid=uuid, + timestamp=timestamp, + ) + + def _create_assistant_entry( + self, + session_id: str, + content: str, + uuid: str, + timestamp: str, + parent_uuid: str, + ) -> AssistantTranscriptEntry: + """Helper to create an AssistantTranscriptEntry with all required fields.""" + return AssistantTranscriptEntry( + type="assistant", + sessionId=session_id, + parentUuid=parent_uuid, + isSidechain=False, + userType="external", + cwd="/test", + version="1.0.0", + message=AssistantMessage( + id="msg-id", + type="message", + role="assistant", + model="claude-3-5-sonnet", + content=[TextContent(type="text", text=content)], + ), + uuid=uuid, + timestamp=timestamp, + ) + + def test_session_with_only_warmup_messages(self): + """Test that a session with only warmup messages is detected.""" + session_id = "test-session-1" + messages = [ + self._create_user_entry( + session_id, "Warmup", "msg-1", "2025-01-01T10:00:00Z" + ), + self._create_assistant_entry( + session_id, + "I'm ready to help!", + "msg-2", + "2025-01-01T10:00:01Z", + "msg-1", + ), + ] + + assert is_warmup_only_session(messages, session_id) is True + + def test_session_with_real_messages(self): + """Test that a session with real messages is not detected as warmup-only.""" + session_id = "test-session-2" + messages = [ + self._create_user_entry( + session_id, "Hello, can you help me?", "msg-1", "2025-01-01T10:00:00Z" + ), + self._create_assistant_entry( + session_id, "Sure!", "msg-2", "2025-01-01T10:00:01Z", "msg-1" + ), + ] + + assert is_warmup_only_session(messages, session_id) is False + + def test_session_with_warmup_and_real_messages(self): + """Test that a session with both warmup and real messages is not warmup-only.""" + session_id = "test-session-3" + messages = [ + self._create_user_entry( + session_id, "Warmup", "msg-1", "2025-01-01T10:00:00Z" + ), + self._create_assistant_entry( + session_id, "Ready!", "msg-2", "2025-01-01T10:00:01Z", "msg-1" + ), + self._create_user_entry( + session_id, + "Now help me debug this code", + "msg-3", + "2025-01-01T10:00:02Z", + ), + ] + + assert is_warmup_only_session(messages, session_id) is False + + def test_session_with_multiple_warmup_messages(self): + """Test session with multiple warmup messages.""" + session_id = "test-session-4" + messages = [ + self._create_user_entry( + session_id, " Warmup ", "msg-1", "2025-01-01T10:00:00Z" + ), + self._create_user_entry( + session_id, "Warmup", "msg-2", "2025-01-01T10:00:01Z" + ), + ] + + assert is_warmup_only_session(messages, session_id) is True + + def test_nonexistent_session(self): + """Test checking a session ID that doesn't exist.""" + messages = [ + self._create_user_entry( + "different-session", "Hello", "msg-1", "2025-01-01T10:00:00Z" + ), + ] + + # Should return False (no user messages may mean system messages exist) + assert is_warmup_only_session(messages, "nonexistent-session") is False + + def test_empty_messages_list(self): + """Test with empty messages list.""" + # Should return False (no user messages may mean system messages exist) + assert is_warmup_only_session([], "any-session") is False + + +class TestGetWarmupSessionIds: + """Test bulk warmup session ID detection.""" + + def _create_user_entry( + self, session_id: str, content: str, uuid: str + ) -> UserTranscriptEntry: + """Helper to create a UserTranscriptEntry.""" + return UserTranscriptEntry( + type="user", + sessionId=session_id, + parentUuid=None, + isSidechain=False, + userType="external", + cwd="/test", + version="1.0.0", + message=UserMessage(role="user", content=content), + uuid=uuid, + timestamp="2025-01-01T10:00:00Z", + ) + + def test_get_warmup_session_ids_multiple_sessions(self): + """Test get_warmup_session_ids correctly identifies warmup sessions.""" + messages = [ + self._create_user_entry("warmup-session", "Warmup", "msg-1"), + self._create_user_entry("warmup-session", "Warmup", "msg-2"), + self._create_user_entry("normal-session", "Hello", "msg-3"), + self._create_user_entry("mixed-session", "Warmup", "msg-4"), + self._create_user_entry("mixed-session", "Can you help?", "msg-5"), + ] + warmup_ids = get_warmup_session_ids(messages) + + assert warmup_ids == {"warmup-session"} + + def test_get_warmup_session_ids_no_warmup(self): + """Test when there are no warmup sessions.""" + messages = [ + self._create_user_entry("session-1", "Hello", "msg-1"), + self._create_user_entry("session-2", "Help me", "msg-2"), + ] + warmup_ids = get_warmup_session_ids(messages) + + assert warmup_ids == set() + + def test_get_warmup_session_ids_empty(self): + """Test with empty messages list.""" + warmup_ids = get_warmup_session_ids([]) + + assert warmup_ids == set()