@@ -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+
4657PROMPTS_PER_PAGE = 5
4758LONG_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 ("<" )
153+ elif char == ">" :
154+ result .append (">" )
155+ elif char == "&" :
156+ result .append ("&" )
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+
52207def extract_text_from_content (content ):
53208 """Extract plain text from message content.
54209
@@ -466,7 +621,7 @@ def parse_session_file(filepath):
466621
467622def _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
749936def 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" ])
0 commit comments