From e2e876daeb8bb7ed41ceda6f05ad083ec15d1b6b Mon Sep 17 00:00:00 2001 From: Toby Backstrom Date: Sun, 28 Dec 2025 15:41:16 +0100 Subject: [PATCH] Add nice rendering for slash commands in transcripts - Detect slash command messages (/context) and merge with their stdout output - Convert ANSI terminal colors to HTML spans with proper colors - Render as expandable
widget styled like other tools - Skip slash commands from index timeline (they're meta, not prompts) --- src/claude_code_transcripts/__init__.py | 218 +++++++++++++++++- .../templates/macros.html | 8 + ...enerateHtml.test_generates_index_html.html | 8 + ...rateHtml.test_generates_page_001_html.html | 8 + ...rateHtml.test_generates_page_002_html.html | 8 + ...SessionFile.test_jsonl_generates_html.html | 8 + tests/test_generate_html.py | 121 ++++++++++ 7 files changed, 377 insertions(+), 2 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 6318120..ab3248d 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -43,12 +43,167 @@ def get_template(name): r"github\.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/pull/new/" ) +# Regex to strip ANSI escape codes from terminal output +ANSI_ESCAPE_PATTERN = re.compile( + r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\[\?[0-9]+[hl]" +) + +# Regex patterns for slash command detection +COMMAND_NAME_PATTERN = re.compile(r"([^<]+)") +LOCAL_STDOUT_PATTERN = re.compile( + r"(.*?)", re.DOTALL +) + PROMPTS_PER_PAGE = 5 LONG_TEXT_THRESHOLD = ( 300 # Characters - text blocks longer than this are shown in index ) +# 256-color palette to hex mapping for common colors used in Claude Code output +ANSI_256_COLORS = { + 37: "#00afaf", # cyan/teal + 135: "#af5fd7", # purple + 174: "#d78787", # pink/salmon + 244: "#808080", # gray + 246: "#949494", # lighter gray +} + +# Icons used in Claude Code context display that need fixed-width rendering +CONTEXT_ICONS = {"⛁", "⛀", "⛶"} + + +def ansi_to_html(text): + """Convert ANSI escape codes to HTML with colored spans. + + Converts 256-color ANSI codes to HTML spans with appropriate colors. + Converts bold codes to tags. + Strips other ANSI codes (reset, private mode sequences). + """ + if not text: + return text + + # First strip private mode sequences using the compiled pattern + text = ANSI_ESCAPE_PATTERN.sub( + lambda m: m.group() if m.group().endswith("m") else "", text + ) + + result = [] + current_color = None + in_bold = False + i = 0 + + while i < len(text): + # Check for ANSI escape sequence + if text[i : i + 2] == "\x1b[": + # Find the end of the sequence + end = i + 2 + while end < len(text) and text[end] not in "mABCDHJKfnsu": + end += 1 + if end < len(text): + seq = text[i + 2 : end] + code_char = text[end] + + if code_char == "m": # Color/style code + # Parse the sequence + if seq == "0" or seq == "": + # Full reset - close any open tags + if current_color: + result.append("") + current_color = None + if in_bold: + result.append("") + in_bold = False + elif seq == "39": + # Reset foreground color only + if current_color: + result.append("") + current_color = None + elif seq.startswith("38;5;"): + # 256-color foreground - close previous color span first + if current_color: + result.append("") + current_color = None + try: + color_num = int(seq[5:]) + if color_num in ANSI_256_COLORS: + hex_color = ANSI_256_COLORS[color_num] + result.append(f'') + current_color = hex_color + except ValueError: + pass + elif seq == "1": + # Bold - only open if not already bold + if not in_bold: + result.append("") + in_bold = True + elif seq == "22": + # End bold - only close if currently bold + if in_bold: + result.append("") + in_bold = False + + i = end + 1 + continue + + # Regular character - escape HTML and handle fixed-width icons + char = text[i] + if char == "<": + result.append("<") + elif char == ">": + result.append(">") + elif char == "&": + result.append("&") + elif char in CONTEXT_ICONS: + # Wrap icons in fixed-width span for grid alignment + result.append(f'{char}') + else: + result.append(char) + i += 1 + + # Close any open tags + if current_color: + result.append("") + if in_bold: + result.append("") + + return "".join(result) + + +def is_slash_command_message(content): + """Check if content is a slash command invocation message.""" + if not isinstance(content, str): + return False + return bool(COMMAND_NAME_PATTERN.search(content)) + + +def parse_slash_command(content): + """Parse slash command name from content. + + Returns the command name (e.g., '/context') or None if not a command. + """ + match = COMMAND_NAME_PATTERN.search(content) + return match.group(1) if match else None + + +def is_command_stdout_message(content): + """Check if content is command stdout output.""" + if not isinstance(content, str): + return False + return "" in content + + +def extract_command_stdout(content): + """Extract and clean command stdout content. + + Removes the XML wrapper and converts ANSI escape codes to HTML colors. + """ + match = LOCAL_STDOUT_PATTERN.search(content) + if not match: + return content + return ansi_to_html(match.group(1).strip()) + + def extract_text_from_content(content): """Extract plain text from message content. @@ -466,7 +621,7 @@ def parse_session_file(filepath): def _parse_jsonl_file(filepath): """Parse JSONL file and convert to standard format.""" - loglines = [] + raw_entries = [] with open(filepath, "r", encoding="utf-8") as f: for line in f: @@ -492,10 +647,42 @@ def _parse_jsonl_file(filepath): if obj.get("isCompactSummary"): entry["isCompactSummary"] = True - loglines.append(entry) + raw_entries.append(entry) except json.JSONDecodeError: continue + # Merge slash command entries with their stdout output + loglines = [] + i = 0 + while i < len(raw_entries): + entry = raw_entries[i] + content = entry.get("message", {}).get("content", "") + + if isinstance(content, str) and is_slash_command_message(content): + cmd_name = parse_slash_command(content) + stdout = "" + + # Look ahead for stdout entry + if i + 1 < len(raw_entries): + next_content = raw_entries[i + 1].get("message", {}).get("content", "") + if isinstance(next_content, str) and is_command_stdout_message( + next_content + ): + stdout = extract_command_stdout(next_content) + i += 1 # Skip the stdout entry + + # Store merged slash command info + entry["_slash_command"] = {"name": cmd_name, "output": stdout} + loglines.append(entry) + elif isinstance(content, str) and is_command_stdout_message(content): + # Standalone stdout without preceding command - skip it + # (normally shouldn't happen, but handle gracefully) + pass + else: + loglines.append(entry) + + i += 1 + return {"loglines": loglines} @@ -747,6 +934,11 @@ def render_content_block(block): def render_user_message_content(message_data): + # Check for slash command (merged from parsing) + slash_cmd = message_data.get("_slash_command") + if slash_cmd: + return _macros.slash_command(slash_cmd["name"], slash_cmd["output"]) + content = message_data.get("content", "") if isinstance(content, str): if is_json_like(content): @@ -1013,6 +1205,14 @@ def render_message(log_type, message_json, timestamp): .search-result-page { padding: 6px 12px; background: rgba(0,0,0,0.03); font-size: 0.8rem; color: var(--text-muted); border-bottom: 1px solid rgba(0,0,0,0.06); } .search-result-content { padding: 12px; } .search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; } +.slash-command { margin: 8px 0; } +.slash-command-summary { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: var(--tool-bg); border: 1px solid var(--tool-border); border-radius: 6px; font-family: monospace; cursor: pointer; list-style: none; } +.slash-command-summary::-webkit-details-marker { display: none; } +.slash-command-summary:hover { background: #e1bee7; } +.slash-command-icon { color: var(--tool-border); } +.slash-command-name { font-weight: 600; color: var(--tool-border); } +.slash-command-output { margin: 8px 0 0 0; font-size: 0.85rem; max-height: 400px; overflow-y: auto; } +.ctx-icon { display: inline-block; width: 1.2em; text-align: center; } @media (max-width: 600px) { body { padding: 8px; } .message, .index-item { border-radius: 8px; } .message-content, .index-item-content { padding: 12px; } pre { font-size: 0.8rem; padding: 8px; } #search-box input { width: 120px; } #search-modal[open] { width: 95vw; height: 90vh; } } """ @@ -1185,6 +1385,10 @@ def generate_html(json_path, output_dir, github_repo=None): message_data = entry.get("message", {}) if not message_data: continue + # Include slash command info if present + if entry.get("_slash_command"): + message_data = dict(message_data) # Don't mutate original + message_data["_slash_command"] = entry["_slash_command"] # Convert message dict to JSON string for compatibility with existing render functions message_json = json.dumps(message_data) is_user_prompt = False @@ -1267,6 +1471,9 @@ def generate_html(json_path, output_dir, github_repo=None): continue if conv["user_text"].startswith("Stop hook feedback:"): continue + # Skip slash command entries from index timeline + if is_slash_command_message(conv["user_text"]): + continue prompt_num += 1 page_num = (i // PROMPTS_PER_PAGE) + 1 msg_id = make_msg_id(conv["timestamp"]) @@ -1600,6 +1807,10 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): message_data = entry.get("message", {}) if not message_data: continue + # Include slash command info if present + if entry.get("_slash_command"): + message_data = dict(message_data) # Don't mutate original + message_data["_slash_command"] = entry["_slash_command"] # Convert message dict to JSON string for compatibility with existing render functions message_json = json.dumps(message_data) is_user_prompt = False @@ -1682,6 +1893,9 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): continue if conv["user_text"].startswith("Stop hook feedback:"): continue + # Skip slash command entries from index timeline + if is_slash_command_message(conv["user_text"]): + continue prompt_num += 1 page_num = (i // PROMPTS_PER_PAGE) + 1 msg_id = make_msg_id(conv["timestamp"]) diff --git a/src/claude_code_transcripts/templates/macros.html b/src/claude_code_transcripts/templates/macros.html index 66866e5..24406a1 100644 --- a/src/claude_code_transcripts/templates/macros.html +++ b/src/claude_code_transcripts/templates/macros.html @@ -185,3 +185,11 @@ {% macro index_long_text(rendered_content) %}
{{ rendered_content|safe }}
{%- endmacro %} + +{# Slash command with expandable output - output contains HTML from ANSI conversion #} +{% macro slash_command(name, output) %} +
+ {{ name }} +
{{ output|safe }}
+
+{%- endmacro %} diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html index c2b6116..4c8baaf 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html @@ -140,6 +140,14 @@ .search-result-page { padding: 6px 12px; background: rgba(0,0,0,0.03); font-size: 0.8rem; color: var(--text-muted); border-bottom: 1px solid rgba(0,0,0,0.06); } .search-result-content { padding: 12px; } .search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; } +.slash-command { margin: 8px 0; } +.slash-command-summary { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: var(--tool-bg); border: 1px solid var(--tool-border); border-radius: 6px; font-family: monospace; cursor: pointer; list-style: none; } +.slash-command-summary::-webkit-details-marker { display: none; } +.slash-command-summary:hover { background: #e1bee7; } +.slash-command-icon { color: var(--tool-border); } +.slash-command-name { font-weight: 600; color: var(--tool-border); } +.slash-command-output { margin: 8px 0 0 0; font-size: 0.85rem; max-height: 400px; overflow-y: auto; } +.ctx-icon { display: inline-block; width: 1.2em; text-align: center; } @media (max-width: 600px) { body { padding: 8px; } .message, .index-item { border-radius: 8px; } .message-content, .index-item-content { padding: 12px; } pre { font-size: 0.8rem; padding: 8px; } #search-box input { width: 120px; } #search-modal[open] { width: 95vw; height: 90vh; } } diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html index cdc794b..0c227ab 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html @@ -140,6 +140,14 @@ .search-result-page { padding: 6px 12px; background: rgba(0,0,0,0.03); font-size: 0.8rem; color: var(--text-muted); border-bottom: 1px solid rgba(0,0,0,0.06); } .search-result-content { padding: 12px; } .search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; } +.slash-command { margin: 8px 0; } +.slash-command-summary { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: var(--tool-bg); border: 1px solid var(--tool-border); border-radius: 6px; font-family: monospace; cursor: pointer; list-style: none; } +.slash-command-summary::-webkit-details-marker { display: none; } +.slash-command-summary:hover { background: #e1bee7; } +.slash-command-icon { color: var(--tool-border); } +.slash-command-name { font-weight: 600; color: var(--tool-border); } +.slash-command-output { margin: 8px 0 0 0; font-size: 0.85rem; max-height: 400px; overflow-y: auto; } +.ctx-icon { display: inline-block; width: 1.2em; text-align: center; } @media (max-width: 600px) { body { padding: 8px; } .message, .index-item { border-radius: 8px; } .message-content, .index-item-content { padding: 12px; } pre { font-size: 0.8rem; padding: 8px; } #search-box input { width: 120px; } #search-modal[open] { width: 95vw; height: 90vh; } } diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html index 2d46a78..757fcdb 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html @@ -140,6 +140,14 @@ .search-result-page { padding: 6px 12px; background: rgba(0,0,0,0.03); font-size: 0.8rem; color: var(--text-muted); border-bottom: 1px solid rgba(0,0,0,0.06); } .search-result-content { padding: 12px; } .search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; } +.slash-command { margin: 8px 0; } +.slash-command-summary { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: var(--tool-bg); border: 1px solid var(--tool-border); border-radius: 6px; font-family: monospace; cursor: pointer; list-style: none; } +.slash-command-summary::-webkit-details-marker { display: none; } +.slash-command-summary:hover { background: #e1bee7; } +.slash-command-icon { color: var(--tool-border); } +.slash-command-name { font-weight: 600; color: var(--tool-border); } +.slash-command-output { margin: 8px 0 0 0; font-size: 0.85rem; max-height: 400px; overflow-y: auto; } +.ctx-icon { display: inline-block; width: 1.2em; text-align: center; } @media (max-width: 600px) { body { padding: 8px; } .message, .index-item { border-radius: 8px; } .message-content, .index-item-content { padding: 12px; } pre { font-size: 0.8rem; padding: 8px; } #search-box input { width: 120px; } #search-modal[open] { width: 95vw; height: 90vh; } } diff --git a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html index 318283c..63bf681 100644 --- a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html +++ b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html @@ -140,6 +140,14 @@ .search-result-page { padding: 6px 12px; background: rgba(0,0,0,0.03); font-size: 0.8rem; color: var(--text-muted); border-bottom: 1px solid rgba(0,0,0,0.06); } .search-result-content { padding: 12px; } .search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; } +.slash-command { margin: 8px 0; } +.slash-command-summary { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: var(--tool-bg); border: 1px solid var(--tool-border); border-radius: 6px; font-family: monospace; cursor: pointer; list-style: none; } +.slash-command-summary::-webkit-details-marker { display: none; } +.slash-command-summary:hover { background: #e1bee7; } +.slash-command-icon { color: var(--tool-border); } +.slash-command-name { font-weight: 600; color: var(--tool-border); } +.slash-command-output { margin: 8px 0 0 0; font-size: 0.85rem; max-height: 400px; overflow-y: auto; } +.ctx-icon { display: inline-block; width: 1.2em; text-align: center; } @media (max-width: 600px) { body { padding: 8px; } .message, .index-item { border-radius: 8px; } .message-content, .index-item-content { padding: 12px; } pre { font-size: 0.8rem; padding: 8px; } #search-box input { width: 120px; } #search-modal[open] { width: 95vw; height: 90vh; } } diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index b79542b..b68bd9a 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -27,6 +27,11 @@ parse_session_file, get_session_summary, find_local_sessions, + ansi_to_html, + is_slash_command_message, + parse_slash_command, + is_command_stdout_message, + extract_command_stdout, ) @@ -1537,3 +1542,119 @@ def test_search_total_pages_available(self, output_dir): # Total pages should be embedded for JS to know how many pages to fetch assert "totalPages" in index_html or "total_pages" in index_html + + +class TestSlashCommandDetection: + """Tests for slash command detection functions.""" + + def test_detects_command_message(self): + """Test detecting a slash command message.""" + content = "/context\ncontext\n" + assert is_slash_command_message(content) is True + + def test_rejects_normal_message(self): + """Test rejecting normal user messages.""" + assert is_slash_command_message("Hello world") is False + + def test_rejects_non_string(self): + """Test rejecting non-string content.""" + assert is_slash_command_message(None) is False + assert is_slash_command_message(123) is False + assert is_slash_command_message([]) is False + + def test_parses_command_name(self): + """Test parsing command name from message.""" + content = "/context\ncontext\n" + assert parse_slash_command(content) == "/context" + + def test_parses_command_with_args(self): + """Test parsing command with arguments.""" + content = "/model\nmodel\nopus" + assert parse_slash_command(content) == "/model" + + def test_parse_returns_none_for_non_command(self): + """Test parse returns None for non-command content.""" + assert parse_slash_command("Hello world") is None + + +class TestCommandStdout: + """Tests for command stdout detection and extraction.""" + + def test_detects_stdout_message(self): + """Test detecting command stdout message.""" + content = "Output here" + assert is_command_stdout_message(content) is True + + def test_rejects_normal_message(self): + """Test rejecting normal messages.""" + assert is_command_stdout_message("Hello world") is False + + def test_rejects_non_string(self): + """Test rejecting non-string content.""" + assert is_command_stdout_message(None) is False + + def test_extracts_and_cleans_stdout(self): + """Test extracting and cleaning stdout content.""" + content = "\x1b[1mContext Usage\x1b[22m: 50%" + result = extract_command_stdout(content) + # Now returns HTML with bold tags + assert result == "Context Usage: 50%" + + def test_extracts_multiline_stdout(self): + """Test extracting multiline stdout.""" + content = "Line 1\nLine 2\nLine 3" + result = extract_command_stdout(content) + assert result == "Line 1\nLine 2\nLine 3" + + def test_wraps_context_icons_in_fixed_width_spans(self): + """Test that context icons get wrapped in fixed-width spans for grid alignment.""" + content = "\x1b[38;5;135m⛁ ⛁ \x1b[38;5;246m⛶ ⛶\x1b[39m" + result = extract_command_stdout(content) + # Icons should be wrapped in ctx-icon spans with colors + assert '' in result + assert '' in result + assert 'style="color:#af5fd7"' in result # purple for 135 + assert 'style="color:#949494"' in result # gray for 246 + + +class TestAnsiToHtml: + """Tests for ANSI to HTML conversion edge cases.""" + + def test_unclosed_bold_gets_closed(self): + """Test that unclosed bold tags are automatically closed.""" + text = "\x1b[1mBold text without close" + result = ansi_to_html(text) + assert result == "Bold text without close" + + def test_nested_bold_not_duplicated(self): + """Test that nested bold codes don't create duplicate tags.""" + text = "\x1b[1m\x1b[1mDouble bold\x1b[22m" + result = ansi_to_html(text) + assert result == "Double bold" + + def test_extra_close_bold_ignored(self): + """Test that extra close bold codes are ignored.""" + text = "Normal\x1b[22m text" + result = ansi_to_html(text) + assert result == "Normal text" + + def test_reset_closes_both_color_and_bold(self): + """Test that reset code closes both color and bold.""" + text = "\x1b[1m\x1b[38;5;135mBold purple\x1b[0m normal" + result = ansi_to_html(text) + assert ( + result + == 'Bold purple normal' + ) + + def test_unclosed_color_gets_closed(self): + """Test that unclosed color spans are automatically closed.""" + text = "\x1b[38;5;244mGray text" + result = ansi_to_html(text) + assert result == 'Gray text' + + def test_html_entities_escaped(self): + """Test that HTML special characters are escaped.""" + text = " & more" + result = ansi_to_html(text) + assert result == "<script>alert('xss')</script> & more"