Skip to content

Commit e2e876d

Browse files
committed
Add nice rendering for slash commands in transcripts
- Detect slash command messages (<command-name>/context</command-name>) and merge with their stdout output - Convert ANSI terminal colors to HTML spans with proper colors - Render as expandable <details> widget styled like other tools - Skip slash commands from index timeline (they're meta, not prompts)
1 parent b7669be commit e2e876d

File tree

7 files changed

+377
-2
lines changed

7 files changed

+377
-2
lines changed

src/claude_code_transcripts/__init__.py

Lines changed: 216 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,167 @@ def get_template(name):
4343
r"github\.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/pull/new/"
4444
)
4545

46+
# Regex to strip ANSI escape codes from terminal output
47+
ANSI_ESCAPE_PATTERN = re.compile(
48+
r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\[\?[0-9]+[hl]"
49+
)
50+
51+
# Regex patterns for slash command detection
52+
COMMAND_NAME_PATTERN = re.compile(r"<command-name>([^<]+)</command-name>")
53+
LOCAL_STDOUT_PATTERN = re.compile(
54+
r"<local-command-stdout>(.*?)</local-command-stdout>", re.DOTALL
55+
)
56+
4657
PROMPTS_PER_PAGE = 5
4758
LONG_TEXT_THRESHOLD = (
4859
300 # Characters - text blocks longer than this are shown in index
4960
)
5061

5162

63+
# 256-color palette to hex mapping for common colors used in Claude Code output
64+
ANSI_256_COLORS = {
65+
37: "#00afaf", # cyan/teal
66+
135: "#af5fd7", # purple
67+
174: "#d78787", # pink/salmon
68+
244: "#808080", # gray
69+
246: "#949494", # lighter gray
70+
}
71+
72+
# Icons used in Claude Code context display that need fixed-width rendering
73+
CONTEXT_ICONS = {"⛁", "⛀", "⛶"}
74+
75+
76+
def ansi_to_html(text):
77+
"""Convert ANSI escape codes to HTML with colored spans.
78+
79+
Converts 256-color ANSI codes to HTML spans with appropriate colors.
80+
Converts bold codes to <strong> tags.
81+
Strips other ANSI codes (reset, private mode sequences).
82+
"""
83+
if not text:
84+
return text
85+
86+
# First strip private mode sequences using the compiled pattern
87+
text = ANSI_ESCAPE_PATTERN.sub(
88+
lambda m: m.group() if m.group().endswith("m") else "", text
89+
)
90+
91+
result = []
92+
current_color = None
93+
in_bold = False
94+
i = 0
95+
96+
while i < len(text):
97+
# Check for ANSI escape sequence
98+
if text[i : i + 2] == "\x1b[":
99+
# Find the end of the sequence
100+
end = i + 2
101+
while end < len(text) and text[end] not in "mABCDHJKfnsu":
102+
end += 1
103+
if end < len(text):
104+
seq = text[i + 2 : end]
105+
code_char = text[end]
106+
107+
if code_char == "m": # Color/style code
108+
# Parse the sequence
109+
if seq == "0" or seq == "":
110+
# Full reset - close any open tags
111+
if current_color:
112+
result.append("</span>")
113+
current_color = None
114+
if in_bold:
115+
result.append("</strong>")
116+
in_bold = False
117+
elif seq == "39":
118+
# Reset foreground color only
119+
if current_color:
120+
result.append("</span>")
121+
current_color = None
122+
elif seq.startswith("38;5;"):
123+
# 256-color foreground - close previous color span first
124+
if current_color:
125+
result.append("</span>")
126+
current_color = None
127+
try:
128+
color_num = int(seq[5:])
129+
if color_num in ANSI_256_COLORS:
130+
hex_color = ANSI_256_COLORS[color_num]
131+
result.append(f'<span style="color:{hex_color}">')
132+
current_color = hex_color
133+
except ValueError:
134+
pass
135+
elif seq == "1":
136+
# Bold - only open if not already bold
137+
if not in_bold:
138+
result.append("<strong>")
139+
in_bold = True
140+
elif seq == "22":
141+
# End bold - only close if currently bold
142+
if in_bold:
143+
result.append("</strong>")
144+
in_bold = False
145+
146+
i = end + 1
147+
continue
148+
149+
# Regular character - escape HTML and handle fixed-width icons
150+
char = text[i]
151+
if char == "<":
152+
result.append("&lt;")
153+
elif char == ">":
154+
result.append("&gt;")
155+
elif char == "&":
156+
result.append("&amp;")
157+
elif char in CONTEXT_ICONS:
158+
# Wrap icons in fixed-width span for grid alignment
159+
result.append(f'<span class="ctx-icon">{char}</span>')
160+
else:
161+
result.append(char)
162+
i += 1
163+
164+
# Close any open tags
165+
if current_color:
166+
result.append("</span>")
167+
if in_bold:
168+
result.append("</strong>")
169+
170+
return "".join(result)
171+
172+
173+
def is_slash_command_message(content):
174+
"""Check if content is a slash command invocation message."""
175+
if not isinstance(content, str):
176+
return False
177+
return bool(COMMAND_NAME_PATTERN.search(content))
178+
179+
180+
def parse_slash_command(content):
181+
"""Parse slash command name from content.
182+
183+
Returns the command name (e.g., '/context') or None if not a command.
184+
"""
185+
match = COMMAND_NAME_PATTERN.search(content)
186+
return match.group(1) if match else None
187+
188+
189+
def is_command_stdout_message(content):
190+
"""Check if content is command stdout output."""
191+
if not isinstance(content, str):
192+
return False
193+
return "<local-command-stdout>" in content
194+
195+
196+
def extract_command_stdout(content):
197+
"""Extract and clean command stdout content.
198+
199+
Removes the XML wrapper and converts ANSI escape codes to HTML colors.
200+
"""
201+
match = LOCAL_STDOUT_PATTERN.search(content)
202+
if not match:
203+
return content
204+
return ansi_to_html(match.group(1).strip())
205+
206+
52207
def extract_text_from_content(content):
53208
"""Extract plain text from message content.
54209
@@ -466,7 +621,7 @@ def parse_session_file(filepath):
466621

467622
def _parse_jsonl_file(filepath):
468623
"""Parse JSONL file and convert to standard format."""
469-
loglines = []
624+
raw_entries = []
470625

471626
with open(filepath, "r", encoding="utf-8") as f:
472627
for line in f:
@@ -492,10 +647,42 @@ def _parse_jsonl_file(filepath):
492647
if obj.get("isCompactSummary"):
493648
entry["isCompactSummary"] = True
494649

495-
loglines.append(entry)
650+
raw_entries.append(entry)
496651
except json.JSONDecodeError:
497652
continue
498653

654+
# Merge slash command entries with their stdout output
655+
loglines = []
656+
i = 0
657+
while i < len(raw_entries):
658+
entry = raw_entries[i]
659+
content = entry.get("message", {}).get("content", "")
660+
661+
if isinstance(content, str) and is_slash_command_message(content):
662+
cmd_name = parse_slash_command(content)
663+
stdout = ""
664+
665+
# Look ahead for stdout entry
666+
if i + 1 < len(raw_entries):
667+
next_content = raw_entries[i + 1].get("message", {}).get("content", "")
668+
if isinstance(next_content, str) and is_command_stdout_message(
669+
next_content
670+
):
671+
stdout = extract_command_stdout(next_content)
672+
i += 1 # Skip the stdout entry
673+
674+
# Store merged slash command info
675+
entry["_slash_command"] = {"name": cmd_name, "output": stdout}
676+
loglines.append(entry)
677+
elif isinstance(content, str) and is_command_stdout_message(content):
678+
# Standalone stdout without preceding command - skip it
679+
# (normally shouldn't happen, but handle gracefully)
680+
pass
681+
else:
682+
loglines.append(entry)
683+
684+
i += 1
685+
499686
return {"loglines": loglines}
500687

501688

@@ -747,6 +934,11 @@ def render_content_block(block):
747934

748935

749936
def render_user_message_content(message_data):
937+
# Check for slash command (merged from parsing)
938+
slash_cmd = message_data.get("_slash_command")
939+
if slash_cmd:
940+
return _macros.slash_command(slash_cmd["name"], slash_cmd["output"])
941+
750942
content = message_data.get("content", "")
751943
if isinstance(content, str):
752944
if is_json_like(content):
@@ -1013,6 +1205,14 @@ def render_message(log_type, message_json, timestamp):
10131205
.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); }
10141206
.search-result-content { padding: 12px; }
10151207
.search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; }
1208+
.slash-command { margin: 8px 0; }
1209+
.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; }
1210+
.slash-command-summary::-webkit-details-marker { display: none; }
1211+
.slash-command-summary:hover { background: #e1bee7; }
1212+
.slash-command-icon { color: var(--tool-border); }
1213+
.slash-command-name { font-weight: 600; color: var(--tool-border); }
1214+
.slash-command-output { margin: 8px 0 0 0; font-size: 0.85rem; max-height: 400px; overflow-y: auto; }
1215+
.ctx-icon { display: inline-block; width: 1.2em; text-align: center; }
10161216
@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; } }
10171217
"""
10181218

@@ -1185,6 +1385,10 @@ def generate_html(json_path, output_dir, github_repo=None):
11851385
message_data = entry.get("message", {})
11861386
if not message_data:
11871387
continue
1388+
# Include slash command info if present
1389+
if entry.get("_slash_command"):
1390+
message_data = dict(message_data) # Don't mutate original
1391+
message_data["_slash_command"] = entry["_slash_command"]
11881392
# Convert message dict to JSON string for compatibility with existing render functions
11891393
message_json = json.dumps(message_data)
11901394
is_user_prompt = False
@@ -1267,6 +1471,9 @@ def generate_html(json_path, output_dir, github_repo=None):
12671471
continue
12681472
if conv["user_text"].startswith("Stop hook feedback:"):
12691473
continue
1474+
# Skip slash command entries from index timeline
1475+
if is_slash_command_message(conv["user_text"]):
1476+
continue
12701477
prompt_num += 1
12711478
page_num = (i // PROMPTS_PER_PAGE) + 1
12721479
msg_id = make_msg_id(conv["timestamp"])
@@ -1600,6 +1807,10 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None):
16001807
message_data = entry.get("message", {})
16011808
if not message_data:
16021809
continue
1810+
# Include slash command info if present
1811+
if entry.get("_slash_command"):
1812+
message_data = dict(message_data) # Don't mutate original
1813+
message_data["_slash_command"] = entry["_slash_command"]
16031814
# Convert message dict to JSON string for compatibility with existing render functions
16041815
message_json = json.dumps(message_data)
16051816
is_user_prompt = False
@@ -1682,6 +1893,9 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None):
16821893
continue
16831894
if conv["user_text"].startswith("Stop hook feedback:"):
16841895
continue
1896+
# Skip slash command entries from index timeline
1897+
if is_slash_command_message(conv["user_text"]):
1898+
continue
16851899
prompt_num += 1
16861900
page_num = (i // PROMPTS_PER_PAGE) + 1
16871901
msg_id = make_msg_id(conv["timestamp"])

src/claude_code_transcripts/templates/macros.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,11 @@
185185
{% macro index_long_text(rendered_content) %}
186186
<div class="index-item-long-text"><div class="truncatable"><div class="truncatable-content"><div class="index-item-long-text-content">{{ rendered_content|safe }}</div></div><button class="expand-btn">Show more</button></div></div>
187187
{%- endmacro %}
188+
189+
{# Slash command with expandable output - output contains HTML from ANSI conversion #}
190+
{% macro slash_command(name, output) %}
191+
<details class="slash-command">
192+
<summary class="slash-command-summary"><span class="slash-command-icon">&#x25B6;</span> <span class="slash-command-name">{{ name }}</span></summary>
193+
<pre class="slash-command-output">{{ output|safe }}</pre>
194+
</details>
195+
{%- endmacro %}

tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,14 @@
140140
.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); }
141141
.search-result-content { padding: 12px; }
142142
.search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; }
143+
.slash-command { margin: 8px 0; }
144+
.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; }
145+
.slash-command-summary::-webkit-details-marker { display: none; }
146+
.slash-command-summary:hover { background: #e1bee7; }
147+
.slash-command-icon { color: var(--tool-border); }
148+
.slash-command-name { font-weight: 600; color: var(--tool-border); }
149+
.slash-command-output { margin: 8px 0 0 0; font-size: 0.85rem; max-height: 400px; overflow-y: auto; }
150+
.ctx-icon { display: inline-block; width: 1.2em; text-align: center; }
143151
@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; } }
144152
</style>
145153
</head>

tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,14 @@
140140
.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); }
141141
.search-result-content { padding: 12px; }
142142
.search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; }
143+
.slash-command { margin: 8px 0; }
144+
.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; }
145+
.slash-command-summary::-webkit-details-marker { display: none; }
146+
.slash-command-summary:hover { background: #e1bee7; }
147+
.slash-command-icon { color: var(--tool-border); }
148+
.slash-command-name { font-weight: 600; color: var(--tool-border); }
149+
.slash-command-output { margin: 8px 0 0 0; font-size: 0.85rem; max-height: 400px; overflow-y: auto; }
150+
.ctx-icon { display: inline-block; width: 1.2em; text-align: center; }
143151
@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; } }
144152
</style>
145153
</head>

tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,14 @@
140140
.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); }
141141
.search-result-content { padding: 12px; }
142142
.search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; }
143+
.slash-command { margin: 8px 0; }
144+
.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; }
145+
.slash-command-summary::-webkit-details-marker { display: none; }
146+
.slash-command-summary:hover { background: #e1bee7; }
147+
.slash-command-icon { color: var(--tool-border); }
148+
.slash-command-name { font-weight: 600; color: var(--tool-border); }
149+
.slash-command-output { margin: 8px 0 0 0; font-size: 0.85rem; max-height: 400px; overflow-y: auto; }
150+
.ctx-icon { display: inline-block; width: 1.2em; text-align: center; }
143151
@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; } }
144152
</style>
145153
</head>

tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,14 @@
140140
.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); }
141141
.search-result-content { padding: 12px; }
142142
.search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; }
143+
.slash-command { margin: 8px 0; }
144+
.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; }
145+
.slash-command-summary::-webkit-details-marker { display: none; }
146+
.slash-command-summary:hover { background: #e1bee7; }
147+
.slash-command-icon { color: var(--tool-border); }
148+
.slash-command-name { font-weight: 600; color: var(--tool-border); }
149+
.slash-command-output { margin: 8px 0 0 0; font-size: 0.85rem; max-height: 400px; overflow-y: auto; }
150+
.ctx-icon { display: inline-block; width: 1.2em; text-align: center; }
143151
@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; } }
144152
</style>
145153
</head>

0 commit comments

Comments
 (0)