Skip to content

Commit 1c457cc

Browse files
ShlomoSteptclaude
andcommitted
Add ANSI escape code sanitization and content-block array rendering
- Add strip_ansi() function to remove ANSI escape sequences from terminal output - Add is_content_block_array() to detect JSON arrays of content blocks - Add render_content_block_array() to properly render content blocks in tool results - Tool results containing ANSI codes now display cleanly without escape sequences - Tool results containing JSON arrays of content blocks now render as markdown text instead of raw JSON This fixes two critical UX issues where terminal output was unreadable due to visible ANSI codes, and tool replies showed raw JSON instead of rendered content. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8351f24 commit 1c457cc

5 files changed

Lines changed: 152 additions & 25 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ build-backend = "uv_build"
3232

3333
[dependency-groups]
3434
dev = [
35+
"black>=24.0.0",
3536
"pytest>=9.0.2",
3637
"pytest-httpx>=0.35.0",
3738
"syrupy>=5.0.0",

src/claude_code_transcripts/__init__.py

Lines changed: 113 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,79 @@ def get_template(name):
4848
300 # Characters - text blocks longer than this are shown in index
4949
)
5050

51+
# Regex to strip ANSI escape sequences from terminal output
52+
ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
53+
54+
55+
def strip_ansi(text):
56+
"""Strip ANSI escape sequences from terminal output.
57+
58+
Args:
59+
text: String that may contain ANSI escape codes.
60+
61+
Returns:
62+
The text with all ANSI escape sequences removed.
63+
"""
64+
if not text:
65+
return text
66+
return ANSI_ESCAPE_PATTERN.sub("", text)
67+
68+
69+
def is_content_block_array(text):
70+
"""Check if a string is a JSON array of content blocks.
71+
72+
Args:
73+
text: String to check.
74+
75+
Returns:
76+
True if the string is a valid JSON array of content blocks.
77+
"""
78+
if not text or not isinstance(text, str):
79+
return False
80+
text = text.strip()
81+
if not (text.startswith("[") and text.endswith("]")):
82+
return False
83+
try:
84+
parsed = json.loads(text)
85+
if not isinstance(parsed, list):
86+
return False
87+
# Check if items look like content blocks
88+
for item in parsed:
89+
if isinstance(item, dict) and "type" in item:
90+
return True
91+
return False
92+
except (json.JSONDecodeError, TypeError):
93+
return False
94+
95+
96+
def render_content_block_array(blocks):
97+
"""Render an array of content blocks.
98+
99+
Args:
100+
blocks: List of content block dicts.
101+
102+
Returns:
103+
HTML string with all blocks rendered.
104+
"""
105+
parts = []
106+
for block in blocks:
107+
if not isinstance(block, dict):
108+
continue
109+
block_type = block.get("type", "")
110+
if block_type == "text":
111+
text = block.get("text", "")
112+
# Render as markdown
113+
parts.append(render_markdown_text(text))
114+
elif block_type == "thinking":
115+
thinking = block.get("thinking", "")
116+
parts.append(render_markdown_text(thinking))
117+
else:
118+
# For other types, just show as formatted text
119+
text = block.get("text", block.get("content", ""))
120+
if text:
121+
parts.append(f"<pre>{html.escape(str(text))}</pre>")
122+
return "".join(parts) if parts else None
123+
51124

52125
def extract_text_from_content(content):
53126
"""Extract plain text from message content.
@@ -711,32 +784,47 @@ def render_content_block(block):
711784

712785
# Check for git commits and render with styled cards
713786
if isinstance(content, str):
714-
commits_found = list(COMMIT_PATTERN.finditer(content))
715-
if commits_found:
716-
# Build commit cards + remaining content
717-
parts = []
718-
last_end = 0
719-
for match in commits_found:
720-
# Add any content before this commit
721-
before = content[last_end : match.start()].strip()
722-
if before:
723-
parts.append(f"<pre>{html.escape(before)}</pre>")
724-
725-
commit_hash = match.group(1)
726-
commit_msg = match.group(2)
727-
parts.append(
728-
_macros.commit_card(commit_hash, commit_msg, _github_repo)
729-
)
730-
last_end = match.end()
731-
732-
# Add any remaining content after last commit
733-
after = content[last_end:].strip()
734-
if after:
735-
parts.append(f"<pre>{html.escape(after)}</pre>")
736-
737-
content_html = "".join(parts)
787+
# First, check if content is a JSON array of content blocks
788+
if is_content_block_array(content):
789+
try:
790+
parsed_blocks = json.loads(content)
791+
rendered = render_content_block_array(parsed_blocks)
792+
if rendered:
793+
content_html = rendered
794+
else:
795+
content_html = format_json(content)
796+
except (json.JSONDecodeError, TypeError):
797+
content_html = format_json(content)
738798
else:
739-
content_html = f"<pre>{html.escape(content)}</pre>"
799+
# Strip ANSI escape sequences from terminal output
800+
content = strip_ansi(content)
801+
802+
commits_found = list(COMMIT_PATTERN.finditer(content))
803+
if commits_found:
804+
# Build commit cards + remaining content
805+
parts = []
806+
last_end = 0
807+
for match in commits_found:
808+
# Add any content before this commit
809+
before = content[last_end : match.start()].strip()
810+
if before:
811+
parts.append(f"<pre>{html.escape(before)}</pre>")
812+
813+
commit_hash = match.group(1)
814+
commit_msg = match.group(2)
815+
parts.append(
816+
_macros.commit_card(commit_hash, commit_msg, _github_repo)
817+
)
818+
last_end = match.end()
819+
820+
# Add any remaining content after last commit
821+
after = content[last_end:].strip()
822+
if after:
823+
parts.append(f"<pre>{html.escape(after)}</pre>")
824+
825+
content_html = "".join(parts)
826+
else:
827+
content_html = f"<pre>{html.escape(content)}</pre>"
740828
elif isinstance(content, list) or is_json_like(content):
741829
content_html = format_json(content)
742830
else:
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<div class="tool-result"><div class="truncatable"><div class="truncatable-content"><p>Here is the file content:</p>
2+
<p>Line 1
3+
Line 2</p></div><button class="expand-btn">Show more</button></div></div>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<div class="tool-result"><div class="truncatable"><div class="truncatable-content"><pre>Tests passed: ✓ All 5 tests passed
2+
Error: None</pre></div><button class="expand-btn">Show more</button></div></div>

tests/test_generate_html.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,39 @@ def test_tool_result_with_commit(self, snapshot_html):
302302
finally:
303303
claude_code_transcripts._github_repo = old_repo
304304

305+
def test_tool_result_with_ansi_codes(self, snapshot_html):
306+
"""Test that ANSI escape codes are stripped from tool results."""
307+
block = {
308+
"type": "tool_result",
309+
"content": "\x1b[38;2;166;172;186mTests passed:\x1b[0m \x1b[32m✓\x1b[0m All 5 tests passed\n\x1b[1;31mError:\x1b[0m None",
310+
"is_error": False,
311+
}
312+
result = render_content_block(block)
313+
# ANSI codes should be stripped
314+
assert "\x1b[" not in result
315+
assert "[38;2;" not in result
316+
assert "[32m" not in result
317+
assert "[0m" not in result
318+
# Content should still be present
319+
assert "Tests passed:" in result
320+
assert "All 5 tests passed" in result
321+
assert result == snapshot_html
322+
323+
def test_tool_result_content_block_array(self, snapshot_html):
324+
"""Test that tool_result with content-block array is rendered properly."""
325+
block = {
326+
"type": "tool_result",
327+
"content": '[{"type": "text", "text": "Here is the file content:\\n\\nLine 1\\nLine 2"}]',
328+
"is_error": False,
329+
}
330+
result = render_content_block(block)
331+
# Should render as text, not raw JSON
332+
assert "Here is the file content" in result
333+
assert "Line 1" in result
334+
# Should not show raw JSON structure
335+
assert '"type": "text"' not in result
336+
assert result == snapshot_html
337+
305338

306339
class TestAnalyzeConversation:
307340
"""Tests for conversation analysis."""

0 commit comments

Comments
 (0)