From f22f2aa08f3aea8eddb7beebbdee40146f422749 Mon Sep 17 00:00:00 2001 From: Chris Kolbegger Date: Fri, 9 Jan 2026 14:09:10 -0500 Subject: [PATCH 1/4] Example of /context output on card, and plan to implement changes --- .../examples/context-index-with-parsed.html | 71 +++++++++++++++++++ .../plan.md | 58 +++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 specs/001-display-readable-context-output/examples/context-index-with-parsed.html create mode 100644 specs/001-display-readable-context-output/plan.md diff --git a/specs/001-display-readable-context-output/examples/context-index-with-parsed.html b/specs/001-display-readable-context-output/examples/context-index-with-parsed.html new file mode 100644 index 0000000..c50d0fe --- /dev/null +++ b/specs/001-display-readable-context-output/examples/context-index-with-parsed.html @@ -0,0 +1,71 @@ + + + + +/context Parsed Preview + + + +
+

/context parsed preview

+
 Context Usage
+⛁ ⛁ ⛁ ⛁ ⛁ ⛁ ⛁ ⛁   claude-opus-4-5-20251101 · 24k/200k tokens (12%)
+⛶ ⛶ ⛶ ⛶ ⛶ ⛶    System prompt: 2.9k tokens (1.4%)
+⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶    System tools: 15.6k tokens (7.8%)
+⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶    MCP tools: 4.7k tokens (2.3%)
+⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶    Memory files: 303 tokens (0.2%)
+⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶    Skills: 270 tokens (0.1%)
+⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶    Messages: 8 tokens (0.0%)
+⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛝ ⛝ ⛝    Free space: 131k (65.6%)
+⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝   ⛝ Autocompact buffer: 45.0k tokens (22.5%)
+⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ 
+
+MCP tools · /mcp
+└ mcp__chrome-devtools__click: 136 tokens
+└ mcp__chrome-devtools__close_page: 124 tokens
+└ mcp__chrome-devtools__drag: 138 tokens
+└ mcp__chrome-devtools__emulate: 355 tokens
+└ mcp__chrome-devtools__evaluate_script: 280 tokens
+└ mcp__chrome-devtools__fill: 144 tokens
+└ mcp__chrome-devtools__fill_form: 176 tokens
+└ mcp__chrome-devtools__get_console_message: 131 tokens
+└ mcp__chrome-devtools__get_network_request: 135 tokens
+└ mcp__chrome-devtools__handle_dialog: 145 tokens
+└ mcp__chrome-devtools__hover: 109 tokens
+└ mcp__chrome-devtools__list_console_messages: 323 tokens
+└ mcp__chrome-devtools__list_network_requests: 329 tokens
+└ mcp__chrome-devtools__list_pages: 75 tokens
+└ mcp__chrome-devtools__navigate_page: 204 tokens
+└ mcp__chrome-devtools__new_page: 137 tokens
+└ mcp__chrome-devtools__performance_analyze_insight: 197 tokens
+└ mcp__chrome-devtools__performance_start_trace: 189 tokens
+└ mcp__chrome-devtools__performance_stop_trace: 79 tokens
+└ mcp__chrome-devtools__press_key: 173 tokens
+└ mcp__chrome-devtools__resize_page: 129 tokens
+└ mcp__chrome-devtools__select_page: 150 tokens
+└ mcp__chrome-devtools__take_screenshot: 303 tokens
+└ mcp__chrome-devtools__take_snapshot: 213 tokens
+└ mcp__chrome-devtools__upload_file: 151 tokens
+└ mcp__chrome-devtools__wait_for: 143 tokens
+
+Memory files · /memory
+└ ~/.claude/CLAUDE.md: 303 tokens
+
+Skills and slash commands · /skills
+
+User
+└ soc-design: 78 tokens
+└ soc-review: 53 tokens
+└ jit-plan-audit: 25 tokens
+
+Plugin
+└ using-tmux-for-interactive-commands: 71 tokens
+└ mcp-cli: 43 tokens
+
+
+ + diff --git a/specs/001-display-readable-context-output/plan.md b/specs/001-display-readable-context-output/plan.md new file mode 100644 index 0000000..239d8f8 --- /dev/null +++ b/specs/001-display-readable-context-output/plan.md @@ -0,0 +1,58 @@ +# Plan: Display readable /context output + +## Goals +- Detect /context output in user messages using the wrapper plus the "Context Usage" header. +- Convert ANSI escape sequences to styled HTML so the output renders like the CLI. +- Preserve exact spacing with a monospace font and white-space: pre. +- Approximate the CLI look only for the /context block. +- Simulate relevant non-SGR control sequences (cursor moves and erases) to keep layout accurate. +- Keep /context prompts visible in the index. + +## Non-goals +- Do not apply ANSI parsing to other messages or tool results. +- Do not change the global transcript styling outside the /context block. + +## Implementation steps +1. Detection and extraction + - Add a helper to identify /context outputs: content is a string containing and "Context Usage". + - Strip the wrapper tags before rendering. + - Tests: unit tests for detection/extraction (positive match, negative match, wrapper removal). + +2. ANSI parsing: SGR + colors (no new dependency) + - Implement SGR parsing for reset, bold, dim, italic, underline, reverse, default fg/bg. + - Support 256-color (38;5 / 48;5) and truecolor (38;2 / 48;2). + - Tests: unit tests covering SGR toggles, 256-color, truecolor, reverse. + +3. ANSI parsing: non-SGR CSI simulation + - Add cursor moves (A/B/C/D), absolute positioning (H/f), horizontal position (G), save/restore (s/u). + - Add erases: erase in line (K) and erase in display (J). + - Ignore bracketed-paste toggles (?2026h/l) and other unsupported sequences gracefully. + - Maintain a screen buffer with cursor position to preserve layout. + - Tests: unit tests for cursor movement, overwrite behavior, and erase semantics. + +4. HTML rendering + - Emit HTML with
 containing spans for styled runs.
+   - Escape text content before wrapping in spans.
+   - Apply reverse by swapping fg/bg at render time.
+   - Tests: unit tests asserting no raw escape codes remain and spans are emitted with expected inline styles.
+
+5. Integration points
+   - In user rendering, if /context is detected, render ANSI HTML instead of Markdown.
+   - In index rendering, if the prompt is /context, use the same ANSI HTML so it remains visible in the index.
+   - Tests: snapshot updates covering /context in both page output and index output.
+
+6. Styling
+   - Add .ansi-context CSS for a dark terminal-like background, monospace font, padding, and white-space: pre.
+   - Keep existing pre styling untouched for non-/context blocks.
+   - Tests: verify snapshots include the new class and styles without affecting other pre blocks.
+
+7. Integration test (end-to-end)
+   - Add a focused integration/snapshot test that renders a /context fixture and confirms the output matches the CLI look (no visible ANSI sequences, correct spacing, and colors applied).
+
+8. Validation and commits
+   - Run: uv run pytest
+   - Run: uv run black .
+   - Commit after tests and implementation are green.
+
+## Open questions to confirm during implementation
+- Whether to keep or strip the  wrapper in the displayed output (current plan: strip).

From 5cccb21592b66a933c4583721df88e5d65166dcf Mon Sep 17 00:00:00 2001
From: Chris Kolbegger 
Date: Fri, 9 Jan 2026 14:32:00 -0500
Subject: [PATCH 2/4] Including a sample session with context command

---
 .../examples/sample-context-session.json                  | 8 ++++++++
 1 file changed, 8 insertions(+)
 create mode 100644 specs/001-display-readable-context-output/examples/sample-context-session.json

diff --git a/specs/001-display-readable-context-output/examples/sample-context-session.json b/specs/001-display-readable-context-output/examples/sample-context-session.json
new file mode 100644
index 0000000..5e8674e
--- /dev/null
+++ b/specs/001-display-readable-context-output/examples/sample-context-session.json
@@ -0,0 +1,8 @@
+{"type":"file-history-snapshot","messageId":"c1194a47-bab2-4ec4-ae6a-1b69629ae68d","snapshot":{"messageId":"c1194a47-bab2-4ec4-ae6a-1b69629ae68d","trackedFileBackups":{},"timestamp":"2026-01-09T16:16:29.769Z"},"isSnapshotUpdate":false}
+{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/home/ckolbegger/.claude/projects/-home-ckolbegger-src-trade-journal-v2-worktree-claude-code","sessionId":"44baca89-ab7a-4952-98b7-f3ad7f46c72c","version":"2.1.2","gitBranch":"","type":"user","message":{"role":"user","content":"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."},"isMeta":true,"uuid":"0b80df89-ae39-428d-8e09-9b9d25616e65","timestamp":"2026-01-09T16:16:29.769Z"}
+{"parentUuid":"0b80df89-ae39-428d-8e09-9b9d25616e65","isSidechain":false,"userType":"external","cwd":"/home/ckolbegger/.claude/projects/-home-ckolbegger-src-trade-journal-v2-worktree-claude-code","sessionId":"44baca89-ab7a-4952-98b7-f3ad7f46c72c","version":"2.1.2","gitBranch":"","type":"user","message":{"role":"user","content":"/context\n            context\n            "},"uuid":"c1194a47-bab2-4ec4-ae6a-1b69629ae68d","timestamp":"2026-01-09T16:16:29.768Z"}
+{"parentUuid":"c1194a47-bab2-4ec4-ae6a-1b69629ae68d","isSidechain":false,"userType":"external","cwd":"/home/ckolbegger/.claude/projects/-home-ckolbegger-src-trade-journal-v2-worktree-claude-code","sessionId":"44baca89-ab7a-4952-98b7-f3ad7f46c72c","version":"2.1.2","gitBranch":"","type":"user","message":{"role":"user","content":"\u001b[?2026h\u001b[?2026l\u001b[?2026h\u001b[?2026l\u001b[?2026h \u001b[1mContext Usage\u001b[22m\n\u001b[38;2;136;136;136m⛁ \u001b[38;2;153;153;153m⛁ ⛁ ⛁ ⛁ ⛁ ⛁ ⛁ ⛁ \u001b[38;2;8;145;178m⛁ \u001b[39m  \u001b[38;2;153;153;153mclaude-opus-4-5-20251101 · 24k/200k tokens (12%)\u001b[39m\n\u001b[38;2;8;145;178m⛁ \u001b[38;2;215;119;87m⛀ \u001b[38;2;255;193;7m⛀ \u001b[38;2;147;51;234m⛀ \u001b[38;2;153;153;153m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m  \u001b[38;2;136;136;136m⛁\u001b[39m System prompt: \u001b[38;2;153;153;153m2.9k tokens (1.4%)\u001b[39m\n\u001b[38;2;153;153;153m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m  \u001b[38;2;153;153;153m⛁\u001b[39m System tools: \u001b[38;2;153;153;153m15.6k tokens (7.8%)\u001b[39m\n\u001b[38;2;153;153;153m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m  \u001b[38;2;8;145;178m⛁\u001b[39m MCP tools: \u001b[38;2;153;153;153m4.7k tokens (2.3%)\u001b[39m\n\u001b[38;2;153;153;153m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m  \u001b[38;2;215;119;87m⛁\u001b[39m Memory files: \u001b[38;2;153;153;153m303 tokens (0.2%)\u001b[39m\n\u001b[38;2;153;153;153m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m  \u001b[38;2;255;193;7m⛁\u001b[39m Skills: \u001b[38;2;153;153;153m270 tokens (0.1%)\u001b[39m\n\u001b[38;2;153;153;153m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m  \u001b[38;2;147;51;234m⛁\u001b[39m Messages: \u001b[38;2;153;153;153m8 tokens (0.0%)\u001b[39m\n\u001b[38;2;153;153;153m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛝ ⛝ ⛝ \u001b[39m  \u001b[38;2;153;153;153m⛶\u001b[39m Free space: \u001b[38;2;153;153;153m131k (65.6%)\u001b[39m\n\u001b[38;2;153;153;153m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m  \u001b[38;2;153;153;153m⛝ Autocompact buffer: 45.0k tokens (22.5%)\u001b[39m\n\u001b[38;2;153;153;153m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\u001b[1mMCP tools\u001b[22m\u001b[38;2;153;153;153m · /mcp\u001b[39m\n└ mcp__chrome-devtools__click: \u001b[38;2;153;153;153m136 tokens\u001b[39m\n└ mcp__chrome-devtools__close_page: \u001b[38;2;153;153;153m124 tokens\u001b[39m\n└ mcp__chrome-devtools__drag: \u001b[38;2;153;153;153m138 tokens\u001b[39m\n└ mcp__chrome-devtools__emulate: \u001b[38;2;153;153;153m355 tokens\u001b[39m\n└ mcp__chrome-devtools__evaluate_script: \u001b[38;2;153;153;153m280 tokens\u001b[39m\n└ mcp__chrome-devtools__fill: \u001b[38;2;153;153;153m144 tokens\u001b[39m\n└ mcp__chrome-devtools__fill_form: \u001b[38;2;153;153;153m176 tokens\u001b[39m\n└ mcp__chrome-devtools__get_console_message: \u001b[38;2;153;153;153m131 tokens\u001b[39m\n└ mcp__chrome-devtools__get_network_request: \u001b[38;2;153;153;153m135 tokens\u001b[39m\n└ mcp__chrome-devtools__handle_dialog: \u001b[38;2;153;153;153m145 tokens\u001b[39m\n└ mcp__chrome-devtools__hover: \u001b[38;2;153;153;153m109 tokens\u001b[39m\n└ mcp__chrome-devtools__list_console_messages: \u001b[38;2;153;153;153m323 tokens\u001b[39m\n└ mcp__chrome-devtools__list_network_requests: \u001b[38;2;153;153;153m329 tokens\u001b[39m\n└ mcp__chrome-devtools__list_pages: \u001b[38;2;153;153;153m75 tokens\u001b[39m\n└ mcp__chrome-devtools__navigate_page: \u001b[38;2;153;153;153m204 tokens\u001b[39m\n└ mcp__chrome-devtools__new_page: \u001b[38;2;153;153;153m137 tokens\u001b[39m\n└ mcp__chrome-devtools__performance_analyze_insight: \u001b[38;2;153;153;153m197 tokens\u001b[39m\n└ mcp__chrome-devtools__performance_start_trace: \u001b[38;2;153;153;153m189 tokens\u001b[39m\n└ mcp__chrome-devtools__performance_stop_trace: \u001b[38;2;153;153;153m79 tokens\u001b[39m\n└ mcp__chrome-devtools__press_key: \u001b[38;2;153;153;153m173 tokens\u001b[39m\n└ mcp__chrome-devtools__resize_page: \u001b[38;2;153;153;153m129 tokens\u001b[39m\n└ mcp__chrome-devtools__select_page: \u001b[38;2;153;153;153m150 tokens\u001b[39m\n└ mcp__chrome-devtools__take_screenshot: \u001b[38;2;153;153;153m303 tokens\u001b[39m\n└ mcp__chrome-devtools__take_snapshot: \u001b[38;2;153;153;153m213 tokens\u001b[39m\n└ mcp__chrome-devtools__upload_file: \u001b[38;2;153;153;153m151 tokens\u001b[39m\n└ mcp__chrome-devtools__wait_for: \u001b[38;2;153;153;153m143 tokens\u001b[39m\n\n\u001b[1mMemory files\u001b[22m\u001b[38;2;153;153;153m · /memory\u001b[39m\n└ ~/.claude/CLAUDE.md: \u001b[38;2;153;153;153m303 tokens\u001b[39m\n\n\u001b[1mSkills and slash commands\u001b[22m\u001b[38;2;153;153;153m · /skills\u001b[39m\n\n\u001b[38;2;153;153;153mUser\u001b[39m\n└ soc-design: \u001b[38;2;153;153;153m78 tokens\u001b[39m\n└ soc-review: \u001b[38;2;153;153;153m53 tokens\u001b[39m\n└ jit-plan-audit: \u001b[38;2;153;153;153m25 tokens\u001b[39m\n\n\u001b[38;2;153;153;153mPlugin\u001b[39m\n└ using-tmux-for-interactive-commands: \u001b[38;2;153;153;153m71 tokens\u001b[39m\n└ mcp-cli: \u001b[38;2;153;153;153m43 tokens\u001b[39m\n\u001b[?2026l"},"uuid":"a16c7023-7ee6-4922-8e9b-414715a2c988","timestamp":"2026-01-09T16:16:29.768Z"}
+{"type":"file-history-snapshot","messageId":"134e791a-ff9d-40c2-bdfc-8cf8b5ae848e","snapshot":{"messageId":"134e791a-ff9d-40c2-bdfc-8cf8b5ae848e","trackedFileBackups":{},"timestamp":"2026-01-09T16:16:31.977Z"},"isSnapshotUpdate":false}
+{"parentUuid":"a16c7023-7ee6-4922-8e9b-414715a2c988","isSidechain":false,"userType":"external","cwd":"/home/ckolbegger/.claude/projects/-home-ckolbegger-src-trade-journal-v2-worktree-claude-code","sessionId":"44baca89-ab7a-4952-98b7-f3ad7f46c72c","version":"2.1.2","gitBranch":"","type":"user","message":{"role":"user","content":"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."},"isMeta":true,"uuid":"d8f0ed33-dade-494b-99f7-11c37af56d2f","timestamp":"2026-01-09T16:16:31.970Z"}
+{"parentUuid":"d8f0ed33-dade-494b-99f7-11c37af56d2f","isSidechain":false,"userType":"external","cwd":"/home/ckolbegger/.claude/projects/-home-ckolbegger-src-trade-journal-v2-worktree-claude-code","sessionId":"44baca89-ab7a-4952-98b7-f3ad7f46c72c","version":"2.1.2","gitBranch":"","type":"user","message":{"role":"user","content":"/exit\n            exit\n            "},"uuid":"134e791a-ff9d-40c2-bdfc-8cf8b5ae848e","timestamp":"2026-01-09T16:16:31.969Z"}
+{"parentUuid":"134e791a-ff9d-40c2-bdfc-8cf8b5ae848e","isSidechain":false,"userType":"external","cwd":"/home/ckolbegger/.claude/projects/-home-ckolbegger-src-trade-journal-v2-worktree-claude-code","sessionId":"44baca89-ab7a-4952-98b7-f3ad7f46c72c","version":"2.1.2","gitBranch":"","type":"user","message":{"role":"user","content":"Goodbye!"},"uuid":"45e94c1b-a2cf-45b6-bf35-b03a69f5833d","timestamp":"2026-01-09T16:16:31.969Z"}

From ade133d9607fcf791a86d190eb6e0c147f406d99 Mon Sep 17 00:00:00 2001
From: Chris Kolbegger 
Date: Sat, 10 Jan 2026 19:07:34 -0500
Subject: [PATCH 3/4] Add ANSI rendering support for /context output

Detect /context command output and render ANSI escape sequences as
styled HTML with colors, bold, italic, and other formatting preserved.
Includes full screen buffer simulation for cursor movement and erase
commands. Tested with 51 unit and integration tests.

Co-Authored-By: Claude Sonnet 4.5 
---
 .../examples/sample-context-session.json      |   8 +
 src/claude_code_transcripts/__init__.py       | 540 +++++++++++++++++-
 ...enerateHtml.test_generates_index_html.html |   1 +
 ...rateHtml.test_generates_page_001_html.html |   1 +
 ...rateHtml.test_generates_page_002_html.html |   1 +
 ...SessionFile.test_jsonl_generates_html.html |   1 +
 tests/test_ansi_rendering.py                  | 460 +++++++++++++++
 7 files changed, 1009 insertions(+), 3 deletions(-)
 create mode 100644 specs/001-display-readable-context-output/examples/sample-context-session.json
 create mode 100644 tests/test_ansi_rendering.py

diff --git a/specs/001-display-readable-context-output/examples/sample-context-session.json b/specs/001-display-readable-context-output/examples/sample-context-session.json
new file mode 100644
index 0000000..5e8674e
--- /dev/null
+++ b/specs/001-display-readable-context-output/examples/sample-context-session.json
@@ -0,0 +1,8 @@
+{"type":"file-history-snapshot","messageId":"c1194a47-bab2-4ec4-ae6a-1b69629ae68d","snapshot":{"messageId":"c1194a47-bab2-4ec4-ae6a-1b69629ae68d","trackedFileBackups":{},"timestamp":"2026-01-09T16:16:29.769Z"},"isSnapshotUpdate":false}
+{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/home/ckolbegger/.claude/projects/-home-ckolbegger-src-trade-journal-v2-worktree-claude-code","sessionId":"44baca89-ab7a-4952-98b7-f3ad7f46c72c","version":"2.1.2","gitBranch":"","type":"user","message":{"role":"user","content":"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."},"isMeta":true,"uuid":"0b80df89-ae39-428d-8e09-9b9d25616e65","timestamp":"2026-01-09T16:16:29.769Z"}
+{"parentUuid":"0b80df89-ae39-428d-8e09-9b9d25616e65","isSidechain":false,"userType":"external","cwd":"/home/ckolbegger/.claude/projects/-home-ckolbegger-src-trade-journal-v2-worktree-claude-code","sessionId":"44baca89-ab7a-4952-98b7-f3ad7f46c72c","version":"2.1.2","gitBranch":"","type":"user","message":{"role":"user","content":"/context\n            context\n            "},"uuid":"c1194a47-bab2-4ec4-ae6a-1b69629ae68d","timestamp":"2026-01-09T16:16:29.768Z"}
+{"parentUuid":"c1194a47-bab2-4ec4-ae6a-1b69629ae68d","isSidechain":false,"userType":"external","cwd":"/home/ckolbegger/.claude/projects/-home-ckolbegger-src-trade-journal-v2-worktree-claude-code","sessionId":"44baca89-ab7a-4952-98b7-f3ad7f46c72c","version":"2.1.2","gitBranch":"","type":"user","message":{"role":"user","content":"\u001b[?2026h\u001b[?2026l\u001b[?2026h\u001b[?2026l\u001b[?2026h \u001b[1mContext Usage\u001b[22m\n\u001b[38;2;136;136;136m⛁ \u001b[38;2;153;153;153m⛁ ⛁ ⛁ ⛁ ⛁ ⛁ ⛁ ⛁ \u001b[38;2;8;145;178m⛁ \u001b[39m  \u001b[38;2;153;153;153mclaude-opus-4-5-20251101 · 24k/200k tokens (12%)\u001b[39m\n\u001b[38;2;8;145;178m⛁ \u001b[38;2;215;119;87m⛀ \u001b[38;2;255;193;7m⛀ \u001b[38;2;147;51;234m⛀ \u001b[38;2;153;153;153m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m  \u001b[38;2;136;136;136m⛁\u001b[39m System prompt: \u001b[38;2;153;153;153m2.9k tokens (1.4%)\u001b[39m\n\u001b[38;2;153;153;153m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m  \u001b[38;2;153;153;153m⛁\u001b[39m System tools: \u001b[38;2;153;153;153m15.6k tokens (7.8%)\u001b[39m\n\u001b[38;2;153;153;153m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m  \u001b[38;2;8;145;178m⛁\u001b[39m MCP tools: \u001b[38;2;153;153;153m4.7k tokens (2.3%)\u001b[39m\n\u001b[38;2;153;153;153m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m  \u001b[38;2;215;119;87m⛁\u001b[39m Memory files: \u001b[38;2;153;153;153m303 tokens (0.2%)\u001b[39m\n\u001b[38;2;153;153;153m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m  \u001b[38;2;255;193;7m⛁\u001b[39m Skills: \u001b[38;2;153;153;153m270 tokens (0.1%)\u001b[39m\n\u001b[38;2;153;153;153m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m  \u001b[38;2;147;51;234m⛁\u001b[39m Messages: \u001b[38;2;153;153;153m8 tokens (0.0%)\u001b[39m\n\u001b[38;2;153;153;153m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛝ ⛝ ⛝ \u001b[39m  \u001b[38;2;153;153;153m⛶\u001b[39m Free space: \u001b[38;2;153;153;153m131k (65.6%)\u001b[39m\n\u001b[38;2;153;153;153m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m  \u001b[38;2;153;153;153m⛝ Autocompact buffer: 45.0k tokens (22.5%)\u001b[39m\n\u001b[38;2;153;153;153m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\u001b[1mMCP tools\u001b[22m\u001b[38;2;153;153;153m · /mcp\u001b[39m\n└ mcp__chrome-devtools__click: \u001b[38;2;153;153;153m136 tokens\u001b[39m\n└ mcp__chrome-devtools__close_page: \u001b[38;2;153;153;153m124 tokens\u001b[39m\n└ mcp__chrome-devtools__drag: \u001b[38;2;153;153;153m138 tokens\u001b[39m\n└ mcp__chrome-devtools__emulate: \u001b[38;2;153;153;153m355 tokens\u001b[39m\n└ mcp__chrome-devtools__evaluate_script: \u001b[38;2;153;153;153m280 tokens\u001b[39m\n└ mcp__chrome-devtools__fill: \u001b[38;2;153;153;153m144 tokens\u001b[39m\n└ mcp__chrome-devtools__fill_form: \u001b[38;2;153;153;153m176 tokens\u001b[39m\n└ mcp__chrome-devtools__get_console_message: \u001b[38;2;153;153;153m131 tokens\u001b[39m\n└ mcp__chrome-devtools__get_network_request: \u001b[38;2;153;153;153m135 tokens\u001b[39m\n└ mcp__chrome-devtools__handle_dialog: \u001b[38;2;153;153;153m145 tokens\u001b[39m\n└ mcp__chrome-devtools__hover: \u001b[38;2;153;153;153m109 tokens\u001b[39m\n└ mcp__chrome-devtools__list_console_messages: \u001b[38;2;153;153;153m323 tokens\u001b[39m\n└ mcp__chrome-devtools__list_network_requests: \u001b[38;2;153;153;153m329 tokens\u001b[39m\n└ mcp__chrome-devtools__list_pages: \u001b[38;2;153;153;153m75 tokens\u001b[39m\n└ mcp__chrome-devtools__navigate_page: \u001b[38;2;153;153;153m204 tokens\u001b[39m\n└ mcp__chrome-devtools__new_page: \u001b[38;2;153;153;153m137 tokens\u001b[39m\n└ mcp__chrome-devtools__performance_analyze_insight: \u001b[38;2;153;153;153m197 tokens\u001b[39m\n└ mcp__chrome-devtools__performance_start_trace: \u001b[38;2;153;153;153m189 tokens\u001b[39m\n└ mcp__chrome-devtools__performance_stop_trace: \u001b[38;2;153;153;153m79 tokens\u001b[39m\n└ mcp__chrome-devtools__press_key: \u001b[38;2;153;153;153m173 tokens\u001b[39m\n└ mcp__chrome-devtools__resize_page: \u001b[38;2;153;153;153m129 tokens\u001b[39m\n└ mcp__chrome-devtools__select_page: \u001b[38;2;153;153;153m150 tokens\u001b[39m\n└ mcp__chrome-devtools__take_screenshot: \u001b[38;2;153;153;153m303 tokens\u001b[39m\n└ mcp__chrome-devtools__take_snapshot: \u001b[38;2;153;153;153m213 tokens\u001b[39m\n└ mcp__chrome-devtools__upload_file: \u001b[38;2;153;153;153m151 tokens\u001b[39m\n└ mcp__chrome-devtools__wait_for: \u001b[38;2;153;153;153m143 tokens\u001b[39m\n\n\u001b[1mMemory files\u001b[22m\u001b[38;2;153;153;153m · /memory\u001b[39m\n└ ~/.claude/CLAUDE.md: \u001b[38;2;153;153;153m303 tokens\u001b[39m\n\n\u001b[1mSkills and slash commands\u001b[22m\u001b[38;2;153;153;153m · /skills\u001b[39m\n\n\u001b[38;2;153;153;153mUser\u001b[39m\n└ soc-design: \u001b[38;2;153;153;153m78 tokens\u001b[39m\n└ soc-review: \u001b[38;2;153;153;153m53 tokens\u001b[39m\n└ jit-plan-audit: \u001b[38;2;153;153;153m25 tokens\u001b[39m\n\n\u001b[38;2;153;153;153mPlugin\u001b[39m\n└ using-tmux-for-interactive-commands: \u001b[38;2;153;153;153m71 tokens\u001b[39m\n└ mcp-cli: \u001b[38;2;153;153;153m43 tokens\u001b[39m\n\u001b[?2026l"},"uuid":"a16c7023-7ee6-4922-8e9b-414715a2c988","timestamp":"2026-01-09T16:16:29.768Z"}
+{"type":"file-history-snapshot","messageId":"134e791a-ff9d-40c2-bdfc-8cf8b5ae848e","snapshot":{"messageId":"134e791a-ff9d-40c2-bdfc-8cf8b5ae848e","trackedFileBackups":{},"timestamp":"2026-01-09T16:16:31.977Z"},"isSnapshotUpdate":false}
+{"parentUuid":"a16c7023-7ee6-4922-8e9b-414715a2c988","isSidechain":false,"userType":"external","cwd":"/home/ckolbegger/.claude/projects/-home-ckolbegger-src-trade-journal-v2-worktree-claude-code","sessionId":"44baca89-ab7a-4952-98b7-f3ad7f46c72c","version":"2.1.2","gitBranch":"","type":"user","message":{"role":"user","content":"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."},"isMeta":true,"uuid":"d8f0ed33-dade-494b-99f7-11c37af56d2f","timestamp":"2026-01-09T16:16:31.970Z"}
+{"parentUuid":"d8f0ed33-dade-494b-99f7-11c37af56d2f","isSidechain":false,"userType":"external","cwd":"/home/ckolbegger/.claude/projects/-home-ckolbegger-src-trade-journal-v2-worktree-claude-code","sessionId":"44baca89-ab7a-4952-98b7-f3ad7f46c72c","version":"2.1.2","gitBranch":"","type":"user","message":{"role":"user","content":"/exit\n            exit\n            "},"uuid":"134e791a-ff9d-40c2-bdfc-8cf8b5ae848e","timestamp":"2026-01-09T16:16:31.969Z"}
+{"parentUuid":"134e791a-ff9d-40c2-bdfc-8cf8b5ae848e","isSidechain":false,"userType":"external","cwd":"/home/ckolbegger/.claude/projects/-home-ckolbegger-src-trade-journal-v2-worktree-claude-code","sessionId":"44baca89-ab7a-4952-98b7-f3ad7f46c72c","version":"2.1.2","gitBranch":"","type":"user","message":{"role":"user","content":"Goodbye!"},"uuid":"45e94c1b-a2cf-45b6-bf35-b03a69f5833d","timestamp":"2026-01-09T16:16:31.969Z"}
diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py
index f2246a2..3cfdd58 100644
--- a/src/claude_code_transcripts/__init__.py
+++ b/src/claude_code_transcripts/__init__.py
@@ -75,6 +75,524 @@ def extract_text_from_content(content):
     return ""
 
 
+def is_context_output(content):
+    """Detect if content is /context command output.
+
+    /context output is identified by:
+    - Being wrapped in  tags
+    - Containing the "Context Usage" header
+
+    Args:
+        content: String content to check
+
+    Returns:
+        True if this is /context output, False otherwise
+    """
+    if not isinstance(content, str):
+        return False
+    return (
+        "" in content
+        and "" in content
+        and "Context Usage" in content
+    )
+
+
+def extract_context_content(content):
+    """Extract /context output by removing wrapper tags.
+
+    Args:
+        content: String content with  wrapper
+
+    Returns:
+        Content with wrapper tags removed
+    """
+    if not isinstance(content, str):
+        return content
+
+    # Remove opening tag
+    content = re.sub(r"", "", content)
+    # Remove closing tag
+    content = re.sub(r"", "", content)
+
+    return content
+
+
+class AnsiState:
+    """State for ANSI SGR (Select Graphic Rendition) attributes."""
+
+    def __init__(self):
+        self.bold = False
+        self.dim = False
+        self.italic = False
+        self.underline = False
+        self.reverse = False
+        self.fg_color = None  # None or tuple: (r,g,b) for truecolor, (idx,) for 256-color
+        self.bg_color = None  # None or tuple: (r,g,b) for truecolor, (idx,) for 256-color
+
+    def reset(self):
+        """Reset all attributes to default."""
+        self.bold = False
+        self.dim = False
+        self.italic = False
+        self.underline = False
+        self.reverse = False
+        self.fg_color = None
+        self.bg_color = None
+
+    def copy(self):
+        """Create a copy of this state."""
+        new_state = AnsiState()
+        new_state.bold = self.bold
+        new_state.dim = self.dim
+        new_state.italic = self.italic
+        new_state.underline = self.underline
+        new_state.reverse = self.reverse
+        new_state.fg_color = self.fg_color
+        new_state.bg_color = self.bg_color
+        return new_state
+
+
+def parse_ansi_sgr(params, state):
+    """Parse SGR (Select Graphic Rendition) parameters and update state.
+
+    Supports:
+    - Reset (0)
+    - Bold (1), dim (2), italic (3), underline (4), reverse (7)
+    - Unbold/undim (22), unitalic (23), no underline (24), unreverse (27)
+    - 256-color foreground (38;5;n) and background (48;5;n)
+    - Truecolor foreground (38;2;r;g;b) and background (48;2;r;g;b)
+    - Default foreground (39) and background (49)
+
+    Args:
+        params: List of integer SGR parameters
+        state: AnsiState object to update
+    """
+    i = 0
+    while i < len(params):
+        code = params[i]
+
+        if code == 0:  # Reset
+            state.reset()
+        elif code == 1:  # Bold
+            state.bold = True
+        elif code == 2:  # Dim
+            state.dim = True
+        elif code == 3:  # Italic
+            state.italic = True
+        elif code == 4:  # Underline
+            state.underline = True
+        elif code == 7:  # Reverse
+            state.reverse = True
+        elif code == 22:  # Unbold/undim
+            state.bold = False
+            state.dim = False
+        elif code == 23:  # Unitalic
+            state.italic = False
+        elif code == 24:  # No underline
+            state.underline = False
+        elif code == 27:  # Unreverse
+            state.reverse = False
+        elif code == 39:  # Default foreground
+            state.fg_color = None
+        elif code == 49:  # Default background
+            state.bg_color = None
+        elif code == 38:  # Foreground color
+            if i + 2 < len(params) and params[i + 1] == 5:
+                # 256-color: 38;5;n
+                state.fg_color = (params[i + 2],)
+                i += 2
+            elif i + 4 < len(params) and params[i + 1] == 2:
+                # Truecolor: 38;2;r;g;b
+                state.fg_color = (params[i + 2], params[i + 3], params[i + 4])
+                i += 4
+        elif code == 48:  # Background color
+            if i + 2 < len(params) and params[i + 1] == 5:
+                # 256-color: 48;5;n
+                state.bg_color = (params[i + 2],)
+                i += 2
+            elif i + 4 < len(params) and params[i + 1] == 2:
+                # Truecolor: 48;2;r;g;b
+                state.bg_color = (params[i + 2], params[i + 3], params[i + 4])
+                i += 4
+
+        i += 1
+
+
+# 256-color palette (xterm colors)
+# Colors 0-15: standard colors, 16-231: 6x6x6 RGB cube, 232-255: grayscale
+ANSI_256_PALETTE = None  # Lazy-loaded
+
+
+def get_256_color_palette():
+    """Get or create the 256-color palette."""
+    global ANSI_256_PALETTE
+    if ANSI_256_PALETTE is None:
+        palette = []
+        # Colors 0-15: standard colors (simplified)
+        standard = [
+            (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0),
+            (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
+            (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0),
+            (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
+        ]
+        palette.extend(standard)
+
+        # Colors 16-231: 6x6x6 RGB cube
+        for r in range(6):
+            for g in range(6):
+                for b in range(6):
+                    palette.append((
+                        0 if r == 0 else 55 + r * 40,
+                        0 if g == 0 else 55 + g * 40,
+                        0 if b == 0 else 55 + b * 40,
+                    ))
+
+        # Colors 232-255: grayscale ramp
+        for i in range(24):
+            gray = 8 + i * 10
+            palette.append((gray, gray, gray))
+
+        ANSI_256_PALETTE = palette
+    return ANSI_256_PALETTE
+
+
+class AnsiParser:
+    """Parser for ANSI escape sequences with screen buffer simulation."""
+
+    # ANSI CSI pattern: ESC [ params letter
+    CSI_PATTERN = re.compile(r'\x1b\[([0-9;?]*?)([A-Za-z])')
+
+    def __init__(self):
+        self.buffer = [[]]  # List of lists: each inner list is a line of (char, state) tuples
+        self.cursor_row = 0
+        self.cursor_col = 0
+        self.current_state = AnsiState()
+        self.saved_cursor = (0, 0)
+
+    def parse(self, text):
+        """Parse text with ANSI codes and return plain text representation.
+
+        This simulates a terminal screen buffer, processing cursor movements
+        and erases to produce the final output.
+
+        Args:
+            text: Text with ANSI escape sequences
+
+        Returns:
+            Plain text with ANSI codes processed (for now - will add HTML later)
+        """
+        pos = 0
+        while pos < len(text):
+            # Look for CSI sequence
+            match = self.CSI_PATTERN.search(text, pos)
+            if match:
+                # Process text before the escape sequence
+                before_text = text[pos:match.start()]
+                self._write_text(before_text)
+
+                # Process the escape sequence
+                params_str = match.group(1)
+                command = match.group(2)
+                self._process_csi(params_str, command)
+
+                pos = match.end()
+            else:
+                # No more escape sequences, write remaining text
+                self._write_text(text[pos:])
+                break
+
+        return self._render_buffer()
+
+    def _write_text(self, text):
+        """Write text to buffer at current cursor position."""
+        for char in text:
+            if char == '\n':
+                self.cursor_row += 1
+                self.cursor_col = 0
+                self._ensure_row(self.cursor_row)
+            elif char == '\r':
+                self.cursor_col = 0
+            else:
+                # Ensure row exists
+                self._ensure_row(self.cursor_row)
+                # Ensure column exists (fill with spaces if needed)
+                while len(self.buffer[self.cursor_row]) < self.cursor_col:
+                    self.buffer[self.cursor_row].append((' ', self.current_state.copy()))
+                # Write or overwrite character
+                if self.cursor_col < len(self.buffer[self.cursor_row]):
+                    self.buffer[self.cursor_row][self.cursor_col] = (char, self.current_state.copy())
+                else:
+                    self.buffer[self.cursor_row].append((char, self.current_state.copy()))
+                self.cursor_col += 1
+
+    def _ensure_row(self, row):
+        """Ensure buffer has enough rows."""
+        while len(self.buffer) <= row:
+            self.buffer.append([])
+
+    def _process_csi(self, params_str, command):
+        """Process a CSI escape sequence."""
+        # Parse parameters
+        if params_str == '':
+            params = []
+        elif '?' in params_str:
+            # Private mode sequences (e.g., ?2026h) - ignore
+            params = []
+        else:
+            params = [int(p) if p else 0 for p in params_str.split(';')]
+
+        if command == 'm':  # SGR - Select Graphic Rendition
+            if not params:
+                params = [0]
+            parse_ansi_sgr(params, self.current_state)
+        elif command == 'A':  # Cursor Up
+            n = params[0] if params else 1
+            self.cursor_row = max(0, self.cursor_row - n)
+        elif command == 'B':  # Cursor Down
+            n = params[0] if params else 1
+            self.cursor_row += n
+            self._ensure_row(self.cursor_row)
+        elif command == 'C':  # Cursor Forward
+            n = params[0] if params else 1
+            self.cursor_col += n
+        elif command == 'D':  # Cursor Backward
+            n = params[0] if params else 1
+            self.cursor_col = max(0, self.cursor_col - n)
+        elif command in ('H', 'f'):  # Cursor Position
+            row = (params[0] - 1) if params and params[0] > 0 else 0
+            col = (params[1] - 1) if len(params) > 1 and params[1] > 0 else 0
+            self.cursor_row = row
+            self.cursor_col = col
+            self._ensure_row(self.cursor_row)
+        elif command == 'G':  # Cursor Horizontal Absolute
+            col = (params[0] - 1) if params and params[0] > 0 else 0
+            self.cursor_col = col
+        elif command == 's':  # Save Cursor Position
+            self.saved_cursor = (self.cursor_row, self.cursor_col)
+        elif command == 'u':  # Restore Cursor Position
+            self.cursor_row, self.cursor_col = self.saved_cursor
+            self._ensure_row(self.cursor_row)
+        elif command == 'K':  # Erase in Line
+            mode = params[0] if params else 0
+            self._erase_in_line(mode)
+        elif command == 'J':  # Erase in Display
+            mode = params[0] if params else 0
+            self._erase_in_display(mode)
+        elif command == 'h' or command == 'l':
+            # Mode set/reset - ignore (e.g., bracketed paste ?2026h/l)
+            pass
+
+    def _erase_in_line(self, mode):
+        """Erase in line: 0=to end, 1=to beginning, 2=entire line."""
+        self._ensure_row(self.cursor_row)
+        line = self.buffer[self.cursor_row]
+
+        if mode == 0:  # Erase to end of line
+            self.buffer[self.cursor_row] = line[:self.cursor_col]
+        elif mode == 1:  # Erase to beginning of line
+            # Replace beginning with spaces
+            for i in range(min(self.cursor_col + 1, len(line))):
+                line[i] = (' ', self.current_state.copy())
+        elif mode == 2:  # Erase entire line
+            self.buffer[self.cursor_row] = []
+
+    def _erase_in_display(self, mode):
+        """Erase in display: 0=below, 1=above, 2=entire screen."""
+        if mode == 0:  # Erase below
+            # Erase from cursor to end of current line
+            self._erase_in_line(0)
+            # Erase all lines below
+            self.buffer = self.buffer[:self.cursor_row + 1]
+        elif mode == 1:  # Erase above
+            # Erase all lines above
+            self.buffer = [[]] * self.cursor_row + [self.buffer[self.cursor_row]]
+            # Erase from beginning of current line to cursor
+            self._erase_in_line(1)
+        elif mode == 2:  # Erase entire screen
+            self.buffer = [[]]
+            self.cursor_row = 0
+            self.cursor_col = 0
+
+    def _render_buffer(self, html_mode=False):
+        """Render buffer to plain text or HTML.
+
+        Args:
+            html_mode: If True, render as HTML with styled spans
+
+        Returns:
+            Rendered output as string
+        """
+        if not html_mode:
+            # Plain text mode
+            lines = []
+            for row in self.buffer:
+                line_chars = []
+                for char, state in row:
+                    line_chars.append(char)
+                lines.append(''.join(line_chars))
+            return '\n'.join(lines)
+
+        # HTML mode
+        lines = []
+        for row in self.buffer:
+            if not row:
+                lines.append('')
+                continue
+
+            line_html = []
+            current_run = []
+            current_state = None
+
+            for char, state in row:
+                # Check if state changed
+                if current_state is None:
+                    current_state = state
+                elif not self._states_equal(current_state, state):
+                    # Flush current run
+                    if current_run:
+                        line_html.append(self._render_run(current_run, current_state))
+                        current_run = []
+                    current_state = state
+
+                current_run.append(char)
+
+            # Flush remaining run
+            if current_run:
+                line_html.append(self._render_run(current_run, current_state))
+
+            lines.append(''.join(line_html))
+
+        return '\n'.join(lines)
+
+    def _states_equal(self, s1, s2):
+        """Check if two AnsiState objects are equal."""
+        if s1 is None or s2 is None:
+            return s1 == s2
+        return (
+            s1.bold == s2.bold and
+            s1.dim == s2.dim and
+            s1.italic == s2.italic and
+            s1.underline == s2.underline and
+            s1.reverse == s2.reverse and
+            s1.fg_color == s2.fg_color and
+            s1.bg_color == s2.bg_color
+        )
+
+    def _render_run(self, chars, state):
+        """Render a run of characters with the same style.
+
+        Args:
+            chars: List of characters
+            state: AnsiState for this run
+
+        Returns:
+            HTML string
+        """
+        text = ''.join(chars)
+        # Escape HTML
+        text = html.escape(text)
+
+        # If no styles, return plain text
+        if not state or not self._has_any_style(state):
+            return text
+
+        # Build inline style
+        styles = []
+
+        # Handle reverse video by swapping fg/bg
+        fg = state.bg_color if state.reverse else state.fg_color
+        bg = state.fg_color if state.reverse else state.bg_color
+
+        if fg:
+            if len(fg) == 1:
+                # 256-color
+                palette = get_256_color_palette()
+                if 0 <= fg[0] < len(palette):
+                    r, g, b = palette[fg[0]]
+                    styles.append(f'color: rgb({r}, {g}, {b})')
+            else:
+                # Truecolor
+                r, g, b = fg
+                styles.append(f'color: rgb({r}, {g}, {b})')
+
+        if bg:
+            if len(bg) == 1:
+                # 256-color
+                palette = get_256_color_palette()
+                if 0 <= bg[0] < len(palette):
+                    r, g, b = palette[bg[0]]
+                    styles.append(f'background-color: rgb({r}, {g}, {b})')
+            else:
+                # Truecolor
+                r, g, b = bg
+                styles.append(f'background-color: rgb({r}, {g}, {b})')
+
+        if state.bold:
+            styles.append('font-weight: bold')
+
+        if state.dim:
+            styles.append('opacity: 0.6')
+
+        if state.italic:
+            styles.append('font-style: italic')
+
+        if state.underline:
+            styles.append('text-decoration: underline')
+
+        style_str = '; '.join(styles)
+        return f'{text}'
+
+    def _has_any_style(self, state):
+        """Check if state has any non-default styling."""
+        return (
+            state.bold or
+            state.dim or
+            state.italic or
+            state.underline or
+            state.reverse or
+            state.fg_color is not None or
+            state.bg_color is not None
+        )
+
+
+def render_ansi_to_html(text):
+    """Render text with ANSI escape sequences as styled HTML.
+
+    Args:
+        text: Text with ANSI escape sequences
+
+    Returns:
+        HTML string with 
 wrapper and styled spans
+    """
+    parser = AnsiParser()
+    # Override parse to return HTML
+    pos = 0
+    while pos < len(text):
+        # Look for CSI sequence
+        match = parser.CSI_PATTERN.search(text, pos)
+        if match:
+            # Process text before the escape sequence
+            before_text = text[pos:match.start()]
+            parser._write_text(before_text)
+
+            # Process the escape sequence
+            params_str = match.group(1)
+            command = match.group(2)
+            parser._process_csi(params_str, command)
+
+            pos = match.end()
+        else:
+            # No more escape sequences, write remaining text
+            parser._write_text(text[pos:])
+            break
+
+    # Render as HTML
+    content = parser._render_buffer(html_mode=True)
+
+    # Wrap in pre tag with ansi-context class
+    return f'
{content}
' + + # Module-level variable for GitHub repo (set by generate_html) _github_repo = None @@ -749,7 +1267,12 @@ def render_content_block(block): def render_user_message_content(message_data): content = message_data.get("content", "") if isinstance(content, str): - if is_json_like(content): + # Check if this is /context output + if is_context_output(content): + extracted = extract_context_content(content) + ansi_html = render_ansi_to_html(extracted) + return _macros.user_content(ansi_html) + elif is_json_like(content): return _macros.user_content(format_json(content)) return _macros.user_content(render_markdown_text(content)) elif isinstance(content, list): @@ -949,6 +1472,7 @@ def render_message(log_type, message_json, timestamp): .todo-pending .todo-content { color: #616161; } pre { background: var(--code-bg); color: var(--code-text); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 0.85rem; line-height: 1.5; margin: 8px 0; white-space: pre-wrap; word-wrap: break-word; } pre.json { color: #e0e0e0; } +pre.ansi-context { background: #0d1117; color: #c9d1d9; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace; white-space: pre; padding: 16px; font-size: 0.8rem; line-height: 1.4; border: 1px solid #30363d; overflow-x: auto; } code { background: rgba(0,0,0,0.08); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } pre code { background: none; padding: 0; } .user-content { margin: 0; } @@ -1319,7 +1843,12 @@ def generate_html(json_path, output_dir, github_repo=None): page_num = (i // PROMPTS_PER_PAGE) + 1 msg_id = make_msg_id(conv["timestamp"]) link = f"page-{page_num:03d}.html#{msg_id}" - rendered_content = render_markdown_text(conv["user_text"]) + # Check if this is /context output and render appropriately + if is_context_output(conv["user_text"]): + extracted = extract_context_content(conv["user_text"]) + rendered_content = render_ansi_to_html(extracted) + else: + rendered_content = render_markdown_text(conv["user_text"]) # Collect all messages including from subsequent continuation conversations # This ensures long_texts from continuations appear with the original prompt @@ -1789,7 +2318,12 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): page_num = (i // PROMPTS_PER_PAGE) + 1 msg_id = make_msg_id(conv["timestamp"]) link = f"page-{page_num:03d}.html#{msg_id}" - rendered_content = render_markdown_text(conv["user_text"]) + # Check if this is /context output and render appropriately + if is_context_output(conv["user_text"]): + extracted = extract_context_content(conv["user_text"]) + rendered_content = render_ansi_to_html(extracted) + else: + rendered_content = render_markdown_text(conv["user_text"]) # Collect all messages including from subsequent continuation conversations # This ensures long_texts from continuations appear with the original prompt 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 693c48f..9530d5f 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 @@ -76,6 +76,7 @@ .todo-pending .todo-content { color: #616161; } pre { background: var(--code-bg); color: var(--code-text); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 0.85rem; line-height: 1.5; margin: 8px 0; white-space: pre-wrap; word-wrap: break-word; } pre.json { color: #e0e0e0; } +pre.ansi-context { background: #0d1117; color: #c9d1d9; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace; white-space: pre; padding: 16px; font-size: 0.8rem; line-height: 1.4; border: 1px solid #30363d; overflow-x: auto; } code { background: rgba(0,0,0,0.08); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } pre code { background: none; padding: 0; } .user-content { margin: 0; } 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..ffb47d6 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 @@ -76,6 +76,7 @@ .todo-pending .todo-content { color: #616161; } pre { background: var(--code-bg); color: var(--code-text); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 0.85rem; line-height: 1.5; margin: 8px 0; white-space: pre-wrap; word-wrap: break-word; } pre.json { color: #e0e0e0; } +pre.ansi-context { background: #0d1117; color: #c9d1d9; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace; white-space: pre; padding: 16px; font-size: 0.8rem; line-height: 1.4; border: 1px solid #30363d; overflow-x: auto; } code { background: rgba(0,0,0,0.08); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } pre code { background: none; padding: 0; } .user-content { margin: 0; } 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..9886342 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 @@ -76,6 +76,7 @@ .todo-pending .todo-content { color: #616161; } pre { background: var(--code-bg); color: var(--code-text); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 0.85rem; line-height: 1.5; margin: 8px 0; white-space: pre-wrap; word-wrap: break-word; } pre.json { color: #e0e0e0; } +pre.ansi-context { background: #0d1117; color: #c9d1d9; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace; white-space: pre; padding: 16px; font-size: 0.8rem; line-height: 1.4; border: 1px solid #30363d; overflow-x: auto; } code { background: rgba(0,0,0,0.08); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } pre code { background: none; padding: 0; } .user-content { margin: 0; } 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 e83424a..78fd5af 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 @@ -76,6 +76,7 @@ .todo-pending .todo-content { color: #616161; } pre { background: var(--code-bg); color: var(--code-text); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 0.85rem; line-height: 1.5; margin: 8px 0; white-space: pre-wrap; word-wrap: break-word; } pre.json { color: #e0e0e0; } +pre.ansi-context { background: #0d1117; color: #c9d1d9; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace; white-space: pre; padding: 16px; font-size: 0.8rem; line-height: 1.4; border: 1px solid #30363d; overflow-x: auto; } code { background: rgba(0,0,0,0.08); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } pre code { background: none; padding: 0; } .user-content { margin: 0; } diff --git a/tests/test_ansi_rendering.py b/tests/test_ansi_rendering.py new file mode 100644 index 0000000..218e347 --- /dev/null +++ b/tests/test_ansi_rendering.py @@ -0,0 +1,460 @@ +"""Tests for ANSI rendering of /context output.""" + +import pytest +from claude_code_transcripts import ( + is_context_output, + extract_context_content, + parse_ansi_sgr, + AnsiState, + AnsiParser, + render_ansi_to_html, +) + + +class TestDetectionAndExtraction: + """Test detection and extraction of /context output.""" + + def test_detects_context_output_with_wrapper_and_header(self): + """Detect /context output with wrapper tags and Context Usage header.""" + content = '\x1b[?2026h Context Usage\n⛁ ⛁ ⛁' + assert is_context_output(content) is True + + def test_does_not_detect_without_wrapper(self): + """Do not detect content without wrapper tags.""" + content = 'Context Usage\n⛁ ⛁ ⛁' + assert is_context_output(content) is False + + def test_does_not_detect_without_context_usage_header(self): + """Do not detect content without Context Usage header.""" + content = 'Some other output' + assert is_context_output(content) is False + + def test_does_not_detect_regular_command_output(self): + """Do not detect regular command output.""" + content = 'npm install completed' + assert is_context_output(content) is False + + def test_extracts_content_by_stripping_wrapper(self): + """Extract content by removing wrapper tags.""" + content = 'Context Usage\n⛁ ⛁ ⛁' + expected = 'Context Usage\n⛁ ⛁ ⛁' + assert extract_context_content(content) == expected + + def test_extracts_content_with_ansi_codes(self): + """Extract content preserving ANSI escape sequences.""" + content = '\x1b[1mContext Usage\x1b[22m\n\x1b[38;2;136;136;136m⛁\x1b[39m' + expected = '\x1b[1mContext Usage\x1b[22m\n\x1b[38;2;136;136;136m⛁\x1b[39m' + assert extract_context_content(content) == expected + + def test_extracts_multiline_context_output(self): + """Extract multiline /context output.""" + content = """ Context Usage +⛁ ⛁ ⛁ ⛁ ⛁ ⛁ ⛁ ⛁ ⛁ ⛁ claude-opus-4-5-20251101 · 24k/200k tokens (12%) + +MCP tools · /mcp +└ mcp__chrome-devtools__click: 136 tokens""" + expected = """ Context Usage +⛁ ⛁ ⛁ ⛁ ⛁ ⛁ ⛁ ⛁ ⛁ ⛁ claude-opus-4-5-20251101 · 24k/200k tokens (12%) + +MCP tools · /mcp +└ mcp__chrome-devtools__click: 136 tokens""" + assert extract_context_content(content) == expected + + +class TestSGRParsing: + """Test SGR (Select Graphic Rendition) parsing.""" + + def test_reset_clears_all_styles(self): + """Reset (0) clears all styles.""" + state = AnsiState() + state.bold = True + state.fg_color = (255, 0, 0) + parse_ansi_sgr([0], state) + assert state.bold is False + assert state.dim is False + assert state.italic is False + assert state.underline is False + assert state.reverse is False + assert state.fg_color is None + assert state.bg_color is None + + def test_bold_and_unbold(self): + """Bold (1) and unbold (22) toggle bold state.""" + state = AnsiState() + parse_ansi_sgr([1], state) + assert state.bold is True + parse_ansi_sgr([22], state) + assert state.bold is False + + def test_dim_and_undim(self): + """Dim (2) and undim (22) toggle dim state.""" + state = AnsiState() + parse_ansi_sgr([2], state) + assert state.dim is True + parse_ansi_sgr([22], state) + assert state.dim is False + + def test_italic_and_unitalic(self): + """Italic (3) and unitalic (23) toggle italic state.""" + state = AnsiState() + parse_ansi_sgr([3], state) + assert state.italic is True + parse_ansi_sgr([23], state) + assert state.italic is False + + def test_underline_and_no_underline(self): + """Underline (4) and no underline (24) toggle underline state.""" + state = AnsiState() + parse_ansi_sgr([4], state) + assert state.underline is True + parse_ansi_sgr([24], state) + assert state.underline is False + + def test_reverse_and_unreverse(self): + """Reverse (7) and unreverse (27) toggle reverse state.""" + state = AnsiState() + parse_ansi_sgr([7], state) + assert state.reverse is True + parse_ansi_sgr([27], state) + assert state.reverse is False + + def test_256_color_foreground(self): + """256-color foreground (38;5;n).""" + state = AnsiState() + parse_ansi_sgr([38, 5, 136], state) + # 256-color palette color 136 should be stored + assert state.fg_color == (136,) # Stored as single-element tuple for 256-color + + def test_256_color_background(self): + """256-color background (48;5;n).""" + state = AnsiState() + parse_ansi_sgr([48, 5, 234], state) + assert state.bg_color == (234,) + + def test_truecolor_foreground(self): + """Truecolor foreground (38;2;r;g;b).""" + state = AnsiState() + parse_ansi_sgr([38, 2, 136, 136, 136], state) + assert state.fg_color == (136, 136, 136) + + def test_truecolor_background(self): + """Truecolor background (48;2;r;g;b).""" + state = AnsiState() + parse_ansi_sgr([48, 2, 215, 119, 87], state) + assert state.bg_color == (215, 119, 87) + + def test_default_foreground_color(self): + """Default foreground (39) removes foreground color.""" + state = AnsiState() + state.fg_color = (255, 0, 0) + parse_ansi_sgr([39], state) + assert state.fg_color is None + + def test_default_background_color(self): + """Default background (49) removes background color.""" + state = AnsiState() + state.bg_color = (0, 255, 0) + parse_ansi_sgr([49], state) + assert state.bg_color is None + + def test_combined_sgr_codes(self): + """Multiple SGR codes in one sequence.""" + state = AnsiState() + # Bold + foreground truecolor + background 256-color + parse_ansi_sgr([1, 38, 2, 255, 193, 7, 48, 5, 234], state) + assert state.bold is True + assert state.fg_color == (255, 193, 7) + assert state.bg_color == (234,) + + def test_partial_color_sequences_ignored(self): + """Incomplete color sequences are ignored gracefully.""" + state = AnsiState() + # Incomplete 38;2 (missing RGB values) + parse_ansi_sgr([38, 2], state) + assert state.fg_color is None + # Incomplete 48;5 (missing index) + parse_ansi_sgr([48, 5], state) + assert state.bg_color is None + + +class TestNonSGRCSI: + """Test non-SGR CSI sequences (cursor movement, erases).""" + + def test_simple_text_output(self): + """Simple text without ANSI codes.""" + parser = AnsiParser() + result = parser.parse("Hello World") + assert "Hello World" in result + + def test_cursor_up_moves_position(self): + """Cursor up (A) moves cursor up.""" + parser = AnsiParser() + # Write "Line1", newline, "Line2", then move up and overwrite with "Over" + result = parser.parse("Line1\nLine2\x1b[1AOver") + # The "Over" should overwrite the beginning of "Line1" + assert "Over" in result + + def test_cursor_down_moves_position(self): + """Cursor down (B) moves cursor down.""" + parser = AnsiParser() + # Write first line, move down, write second line + result = parser.parse("Line1\x1b[1BLine2") + lines = result.split('\n') + # Should have created a blank line between + assert len(lines) >= 2 + + def test_cursor_forward_moves_right(self): + """Cursor forward (C) moves cursor right.""" + parser = AnsiParser() + # Write "ab", move right 3, write "c" + result = parser.parse("ab\x1b[3Cc") + # Should have "ab c" + assert "ab c" in result + + def test_cursor_backward_moves_left(self): + """Cursor backward (D) moves cursor left.""" + parser = AnsiParser() + # Write "abcde", move back 2, write "X" + result = parser.parse("abcde\x1b[2DX") + # Should overwrite 'd' with 'X': "abcXe" + assert "abcXe" in result + + def test_cursor_position_absolute(self): + """Cursor position (H) moves to absolute position.""" + parser = AnsiParser() + # Move to row 2, col 5 (1-indexed) + result = parser.parse("\x1b[2;5HX") + lines = result.split('\n') + # Should have at least 2 lines + assert len(lines) >= 2 + # Second line should have X at position 4 (0-indexed) + assert 'X' in lines[1] + + def test_horizontal_position_absolute(self): + """Horizontal position (G) moves to column.""" + parser = AnsiParser() + # Write "abc", move to column 10, write "X" + result = parser.parse("abc\x1b[10GX") + # Should have spaces between 'c' and 'X' + assert "abc" in result and "X" in result + + def test_erase_in_line_to_end(self): + """Erase in line to end (K or 0K).""" + parser = AnsiParser() + # Write "Hello World", move back, erase to end + result = parser.parse("Hello World\x1b[6D\x1b[KX") + # Should have "Hello X" (erased " World", added X) + assert "Hello" in result + assert "World" not in result + + def test_erase_in_line_to_beginning(self): + """Erase in line to beginning (1K).""" + parser = AnsiParser() + # Write text, erase to beginning + result = parser.parse("Hello World\x1b[1K") + # Should erase from start to cursor + assert "World" in result or result.strip() == "" + + def test_erase_entire_line(self): + """Erase entire line (2K).""" + parser = AnsiParser() + # Write text, erase entire line + result = parser.parse("Hello World\x1b[2K") + # Line should be empty + assert "Hello" not in result or result.strip() == "" + + def test_erase_in_display_below(self): + """Erase in display below cursor (J or 0J).""" + parser = AnsiParser() + # Write multiple lines, erase below + result = parser.parse("Line1\nLine2\nLine3\x1b[1A\x1b[J") + # Should keep Line1 and Line2, erase Line3 + assert "Line1" in result + + def test_bracketed_paste_ignored(self): + """Bracketed paste toggles (?2026h/l) are ignored.""" + parser = AnsiParser() + result = parser.parse("\x1b[?2026hHello\x1b[?2026l") + # Should just have "Hello" + assert "Hello" in result + # Should not have the escape sequences + assert "?2026" not in result + + def test_overwrite_with_cursor_movement(self): + """Test that cursor movement allows overwriting.""" + parser = AnsiParser() + # Write "AAAA", move to start, write "BB" + result = parser.parse("AAAA\x1b[4DBB") + # Should have "BBAA" + assert "BBAA" in result + + def test_newline_advances_cursor(self): + """Newline advances to next line.""" + parser = AnsiParser() + result = parser.parse("Line1\nLine2\nLine3") + lines = result.split('\n') + assert len(lines) == 3 + assert lines[0].strip() == "Line1" + assert lines[1].strip() == "Line2" + assert lines[2].strip() == "Line3" + + def test_carriage_return_moves_to_start(self): + """Carriage return moves to start of line.""" + parser = AnsiParser() + # Write "Hello", carriage return, write "Bye" + result = parser.parse("Hello\rBye") + # Should overwrite: "Byelo" + assert "Bye" in result + + +class TestHTMLRendering: + """Test HTML rendering of ANSI sequences.""" + + def test_plain_text_no_spans(self): + """Plain text without styles should not have spans.""" + html = render_ansi_to_html("Hello World") + assert "Hello World" in html + assert "alert('xss')") + # Should escape < and > + assert "<" in html + assert ">" in html + assert "