From 7e525bec1d572fc453756c89661a7ed7c2e0e887 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 28 Feb 2026 14:42:13 +0100 Subject: [PATCH 1/6] Add --compact rendering option to show only user and assistant text Filters out tool use/result, thinking, and system messages early in the rendering pipeline via _filter_compact() in renderer.py. Threaded through CLI, converter, and both HTML/Markdown renderers. Co-Authored-By: Claude Opus 4.6 --- claude_code_log/cli.py | 8 +++++ claude_code_log/converter.py | 18 +++++++--- claude_code_log/html/renderer.py | 4 ++- claude_code_log/markdown/renderer.py | 4 ++- claude_code_log/renderer.py | 49 +++++++++++++++++++++++++--- 5 files changed, 73 insertions(+), 10 deletions(-) diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index 2c6e3af..8b09472 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -506,6 +506,11 @@ def _clear_output_files(input_path: Path, all_projects: bool, file_ext: str) -> default=2000, help="Maximum messages per page for combined transcript (default: 2000). Sessions are never split across pages.", ) +@click.option( + "--compact", + is_flag=True, + help="Render only user and assistant text messages (no tools, system, or thinking)", +) @click.option( "--debug", is_flag=True, @@ -528,6 +533,7 @@ def main( output_format: str, image_export_mode: Optional[str], page_size: int, + compact: bool, debug: bool, ) -> None: """Convert Claude transcript JSONL files to HTML or Markdown. @@ -685,6 +691,7 @@ def main( output_format, image_export_mode, page_size=page_size, + compact=compact, ) # Count processed projects @@ -737,6 +744,7 @@ def main( not no_cache, image_export_mode=image_export_mode, page_size=page_size, + compact=compact, ) if input_path.is_file(): click.echo(f"Successfully converted {input_path} to {output_path}") diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 2a67308..4db32ee 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -698,6 +698,7 @@ def _generate_paginated_html( session_data: Dict[str, SessionCacheData], working_directories: List[str], silent: bool = False, + compact: bool = False, ) -> Path: """Generate paginated HTML files for combined transcript. @@ -714,7 +715,7 @@ def _generate_paginated_html( Returns: Path to the first page (combined_transcripts.html) """ - from .html.renderer import generate_html + from .html.renderer import HtmlRenderer from .utils import format_timestamp # Check if page size changed - if so, invalidate all pages @@ -851,7 +852,9 @@ def _generate_paginated_html( # Generate HTML for this page page_title = f"{title} - Page {page_num}" if page_num > 1 else title - html_content = generate_html( + page_renderer = HtmlRenderer() + page_renderer.compact = compact + html_content = page_renderer.generate( page_messages, page_title, page_info=page_info, @@ -915,6 +918,7 @@ def convert_jsonl_to( silent: bool = False, image_export_mode: Optional[str] = None, page_size: int = 2000, + compact: bool = False, ) -> Path: """Convert JSONL transcript(s) to the specified format. @@ -930,6 +934,7 @@ def convert_jsonl_to( image_export_mode: Image export mode ("placeholder", "embedded", "referenced"). page_size: Maximum messages per page for combined transcript pagination. If None, uses format default (embedded for HTML, referenced for Markdown). + compact: If True, render only user and assistant text messages. """ if not input_path.exists(): raise FileNotFoundError(f"Input path not found: {input_path}") @@ -1018,7 +1023,7 @@ def convert_jsonl_to( # Generate combined output file (check if regeneration needed) assert output_path is not None - renderer = get_renderer(format, image_export_mode) + renderer = get_renderer(format, image_export_mode, compact=compact) # Decide whether to use pagination (HTML only, directory mode, no date filter) use_pagination = False @@ -1065,6 +1070,7 @@ def convert_jsonl_to( session_data, working_directories, silent=silent, + compact=compact, ) else: # Use single-file generation for small projects or filtered views @@ -1117,6 +1123,7 @@ def convert_jsonl_to( cache_was_updated, image_export_mode, silent=silent, + compact=compact, ) return output_path @@ -1479,6 +1486,7 @@ def _generate_individual_session_files( cache_was_updated: bool = False, image_export_mode: Optional[str] = None, silent: bool = False, + compact: bool = False, ) -> int: """Generate individual files for each session in the specified format. @@ -1514,7 +1522,7 @@ def _generate_individual_session_files( project_title = get_project_display_name(output_dir.name, working_directories) # Get renderer once outside the loop - renderer = get_renderer(format, image_export_mode) + renderer = get_renderer(format, image_export_mode, compact=compact) regenerated_count = 0 # Generate HTML file for each session @@ -1662,6 +1670,7 @@ def process_projects_hierarchy( image_export_mode: Optional[str] = None, silent: bool = True, page_size: int = 2000, + compact: bool = False, ) -> Path: """Process the entire ~/.claude/projects/ hierarchy and create linked output files. @@ -1818,6 +1827,7 @@ def process_projects_hierarchy( silent=silent, image_export_mode=image_export_mode, page_size=page_size, + compact=compact, ) # Track timing diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 8d22a37..9993cf0 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -541,7 +541,9 @@ def generate( title = "Claude Transcript" # Get root messages (tree) and session navigation from format-neutral renderer - root_messages, session_nav, _ = generate_template_messages(messages) + root_messages, session_nav, _ = generate_template_messages( + messages, compact=self.compact + ) # Flatten tree via pre-order traversal, formatting content along the way with log_timing("Content formatting (pre-order)", t_start): diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index 23c7e41..5ee476d 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -876,7 +876,9 @@ def generate( title = "Claude Transcript" # Get root messages (tree), session navigation, and rendering context - root_messages, session_nav, ctx = generate_template_messages(messages) + root_messages, session_nav, ctx = generate_template_messages( + messages, compact=self.compact + ) self._ctx = ctx parts = [f"", ""] diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 1f3c349..61e7845 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -524,6 +524,7 @@ def __init__(self, project_summaries: list[dict[str, Any]]): def generate_template_messages( messages: list[TranscriptEntry], + compact: bool = False, ) -> Tuple[list[TemplateMessage], list[dict[str, Any]], RenderingContext]: """Generate root messages and session navigation from transcript messages. @@ -562,6 +563,11 @@ def generate_template_messages( with log_timing("Filter messages", t_start): filtered_messages = _filter_messages(messages) + # Compact mode: keep only user and assistant text messages (no tools, system, thinking) + if compact: + with log_timing("Compact filter", t_start): + filtered_messages = _filter_compact(filtered_messages) + # Pass 1: Collect session metadata and token tracking with log_timing("Collect session info", t_start): sessions, session_order, show_tokens_for_message = _collect_session_info( @@ -1558,6 +1564,31 @@ def _filter_messages(messages: list[TranscriptEntry]) -> list[TranscriptEntry]: return filtered +def _filter_compact(messages: list[TranscriptEntry]) -> list[TranscriptEntry]: + """Filter messages for compact mode: keep only user and assistant text. + + Strips tool items from user entries and thinking/tool items from assistant + entries. System, summary, and queue-operation entries are removed entirely. + """ + from copy import copy + + _strip_types = (ThinkingContent, ToolUseContent, ToolResultContent) + filtered: list[TranscriptEntry] = [] + for message in messages: + if isinstance(message, (UserTranscriptEntry, AssistantTranscriptEntry)): + text_items = [ + item + for item in message.message.content + if not isinstance(item, _strip_types) + ] + if text_items: + msg_copy = copy(message) + msg_copy.message = copy(message.message) + msg_copy.message.content = text_items + filtered.append(msg_copy) + return filtered + + def _collect_session_info( messages: list[TranscriptEntry], session_summaries: dict[str, str], @@ -2014,6 +2045,8 @@ class Renderer: - Subclasses override methods to implement format-specific rendering """ + compact: bool = False + def _dispatch_format(self, obj: Any, message: TemplateMessage) -> str: """Dispatch to format_{ClassName}(obj, message) based on object type.""" for cls in type(obj).__mro__: @@ -2284,13 +2317,18 @@ def is_outdated(self, file_path: Path) -> Optional[bool]: return None -def get_renderer(format: str, image_export_mode: Optional[str] = None) -> Renderer: +def get_renderer( + format: str, + image_export_mode: Optional[str] = None, + compact: bool = False, +) -> Renderer: """Get a renderer instance for the specified format. Args: format: The output format ("html", "md", or "markdown"). image_export_mode: Image export mode ("placeholder", "embedded", "referenced"). If None, defaults to "embedded" for HTML and "referenced" for Markdown. + compact: If True, render only user and assistant text messages. Returns: A Renderer instance for the specified format. @@ -2303,14 +2341,17 @@ def get_renderer(format: str, image_export_mode: Optional[str] = None) -> Render # For HTML, default to embedded mode (current behavior) mode = image_export_mode or "embedded" - return HtmlRenderer(image_export_mode=mode) + renderer = HtmlRenderer(image_export_mode=mode) elif format in ("md", "markdown"): from .markdown.renderer import MarkdownRenderer # For Markdown, default to referenced mode mode = image_export_mode or "referenced" - return MarkdownRenderer(image_export_mode=mode) - raise ValueError(f"Unsupported format: {format}") + renderer = MarkdownRenderer(image_export_mode=mode) + else: + raise ValueError(f"Unsupported format: {format}") + renderer.compact = compact + return renderer def is_html_outdated(html_file_path: Path) -> bool: From cdc14b527e68f7693d57351f8b3769ed48f0a433 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 1 Mar 2026 19:07:20 +0100 Subject: [PATCH 2/6] Add tests for --compact rendering mode 27 tests covering unit (_filter_compact), integration (generate_template_messages), HTML/Markdown rendering, CLI flag, real project data, and bundled test data files. Co-Authored-By: Claude Opus 4.6 --- test/test_compact_mode.py | 743 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 743 insertions(+) create mode 100644 test/test_compact_mode.py diff --git a/test/test_compact_mode.py b/test/test_compact_mode.py new file mode 100644 index 0000000..44c2dd4 --- /dev/null +++ b/test/test_compact_mode.py @@ -0,0 +1,743 @@ +#!/usr/bin/env python3 +"""Tests for --compact rendering mode. + +Compact mode filters out everything except user and assistant text messages: +no tools, no thinking, no system messages. +""" + +import json +import shutil +import tempfile +import uuid +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from claude_code_log.cli import main +from claude_code_log.converter import convert_jsonl_to, load_transcript +from claude_code_log.html.renderer import HtmlRenderer +from claude_code_log.markdown.renderer import MarkdownRenderer +from claude_code_log.models import ( + AssistantTranscriptEntry, + SystemTranscriptEntry, + ThinkingContent, + ToolResultContent, + ToolUseContent, + UserTranscriptEntry, +) +from claude_code_log.renderer import _filter_compact, generate_template_messages + + +# -- Test data helpers -------------------------------------------------------- + + +def _user_entry( + text: str, + session_id: str = "sess-001", + timestamp: str = "2025-01-01T10:00:00Z", + extra_content: list | None = None, +) -> dict: + content: list = [{"type": "text", "text": text}] + if extra_content: + content.extend(extra_content) + return { + "type": "user", + "timestamp": timestamp, + "sessionId": session_id, + "uuid": f"u-{uuid.uuid4().hex[:8]}", + "parentUuid": None, + "isSidechain": False, + "userType": "external", + "cwd": "/tmp", + "version": "1.0.0", + "message": {"role": "user", "content": content}, + } + + +def _assistant_entry( + text: str, + session_id: str = "sess-001", + timestamp: str = "2025-01-01T10:00:01Z", + extra_content: list | None = None, +) -> dict: + content: list = [{"type": "text", "text": text}] + if extra_content: + content.extend(extra_content) + return { + "type": "assistant", + "timestamp": timestamp, + "sessionId": session_id, + "uuid": f"a-{uuid.uuid4().hex[:8]}", + "parentUuid": None, + "isSidechain": False, + "userType": "external", + "cwd": "/tmp", + "version": "1.0.0", + "message": { + "id": f"msg_{uuid.uuid4().hex[:16]}", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": content, + }, + } + + +def _system_entry( + text: str, + session_id: str = "sess-001", + timestamp: str = "2025-01-01T10:00:02Z", +) -> dict: + return { + "type": "system", + "timestamp": timestamp, + "sessionId": session_id, + "message": text, + } + + +def _tool_use_item(name: str = "Bash", tool_id: str = "tool_001") -> dict: + return { + "type": "tool_use", + "id": tool_id, + "name": name, + "input": {"command": "echo hello"}, + } + + +def _tool_result_item(tool_id: str = "tool_001") -> dict: + return { + "type": "tool_result", + "tool_use_id": tool_id, + "content": "hello", + "is_error": False, + } + + +def _thinking_item(text: str = "Let me think...") -> dict: + return {"type": "thinking", "thinking": text} + + +def _write_jsonl(entries: list[dict], path: Path) -> Path: + path.write_text("\n".join(json.dumps(e) for e in entries) + "\n", encoding="utf-8") + return path + + +# -- Unit tests for _filter_compact ------------------------------------------ + + +class TestFilterCompact: + """Test the _filter_compact function directly on parsed TranscriptEntry lists.""" + + def test_keeps_user_and_assistant_text(self, tmp_path): + """Plain user and assistant messages pass through.""" + entries = [ + _user_entry("Hello"), + _assistant_entry("Hi there!"), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + result = _filter_compact(messages) + assert len(result) == 2 + assert isinstance(result[0], UserTranscriptEntry) + assert isinstance(result[1], AssistantTranscriptEntry) + + def test_removes_system_entries(self, tmp_path): + """System entries are dropped entirely.""" + entries = [ + _user_entry("Hello"), + _system_entry("model changed"), + _assistant_entry("Hi"), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + result = _filter_compact(messages) + assert len(result) == 2 + assert all(not isinstance(m, SystemTranscriptEntry) for m in result) + + def test_strips_tool_use_from_assistant(self, tmp_path): + """Tool use items within assistant entries are stripped.""" + entries = [ + _user_entry("Do something"), + _assistant_entry( + "I'll run a command.", + extra_content=[_tool_use_item()], + ), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + result = _filter_compact(messages) + assert len(result) == 2 + # Assistant entry should have text but no tool_use + assistant = result[1] + assert isinstance(assistant, AssistantTranscriptEntry) + for item in assistant.message.content: + assert not isinstance(item, ToolUseContent) + + def test_strips_tool_result_from_user(self, tmp_path): + """Tool result items within user entries are stripped.""" + entries = [ + _user_entry( + "Here's the result", + extra_content=[_tool_result_item()], + ), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + result = _filter_compact(messages) + assert len(result) == 1 + user = result[0] + assert isinstance(user, UserTranscriptEntry) + for item in user.message.content: + assert not isinstance(item, ToolResultContent) + + def test_strips_thinking_from_assistant(self, tmp_path): + """Thinking items within assistant entries are stripped.""" + entries = [ + _assistant_entry( + "Here's my answer.", + extra_content=[_thinking_item()], + ), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + result = _filter_compact(messages) + assert len(result) == 1 + assistant = result[0] + for item in assistant.message.content: + assert not isinstance(item, ThinkingContent) + + def test_drops_assistant_with_only_tool_use(self, tmp_path): + """Assistant entries with only tool_use (no text) are dropped entirely.""" + # Build an entry where the only content is a tool_use (no text at all) + entry = _assistant_entry("placeholder", extra_content=[_tool_use_item()]) + # Remove the text item, keeping only tool_use + entry["message"]["content"] = [_tool_use_item()] + messages = load_transcript(_write_jsonl([entry], tmp_path / "t.jsonl")) + result = _filter_compact(messages) + assert len(result) == 0 + + def test_does_not_mutate_original(self, tmp_path): + """Filtering creates copies, not mutations of the original.""" + entries = [ + _assistant_entry( + "Some text", + extra_content=[_tool_use_item()], + ), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + original_content_count = len(messages[0].message.content) + _filter_compact(messages) + assert len(messages[0].message.content) == original_content_count + + +# -- Integration tests: generate_template_messages with compact --------------- + + +class TestCompactTemplateMessages: + """Test compact mode through the full generate_template_messages pipeline.""" + + def test_compact_removes_tool_messages(self, tmp_path): + """Compact mode should not produce tool_use or tool_result TemplateMessages.""" + entries = [ + _user_entry("Run something"), + _assistant_entry( + "Running it.", + extra_content=[_tool_use_item()], + timestamp="2025-01-01T10:00:01Z", + ), + _user_entry( + "", + extra_content=[_tool_result_item()], + timestamp="2025-01-01T10:00:02Z", + ), + _assistant_entry("Done!", timestamp="2025-01-01T10:00:03Z"), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + + root_messages, _, _ = generate_template_messages(messages, compact=True) + # Flatten tree + all_types = set() + _collect_types(root_messages, all_types) + assert "tool_use" not in all_types + assert "tool_result" not in all_types + assert "user" in all_types + assert "assistant" in all_types + + def test_compact_removes_thinking_messages(self, tmp_path): + """Compact mode should not produce thinking TemplateMessages.""" + entries = [ + _user_entry("Think about this"), + _assistant_entry( + "Here's my answer.", + extra_content=[_thinking_item("deep thoughts")], + ), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + + root_messages, _, _ = generate_template_messages(messages, compact=True) + all_types = set() + _collect_types(root_messages, all_types) + assert "thinking" not in all_types + assert "assistant" in all_types + + def test_compact_preserves_session_headers(self, tmp_path): + """Session headers are still generated in compact mode.""" + entries = [ + _user_entry("Hello"), + _assistant_entry("Hi"), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + + root_messages, session_nav, _ = generate_template_messages( + messages, compact=True + ) + assert len(root_messages) >= 1 + assert root_messages[0].is_session_header + assert len(session_nav) >= 1 + + def test_compact_vs_normal_fewer_messages(self, tmp_path): + """Compact mode produces fewer messages than normal mode.""" + entries = [ + _user_entry("Do something"), + _assistant_entry( + "OK, running bash.", + extra_content=[_tool_use_item()], + timestamp="2025-01-01T10:00:01Z", + ), + _user_entry( + "", + extra_content=[_tool_result_item()], + timestamp="2025-01-01T10:00:02Z", + ), + _assistant_entry("All done!", timestamp="2025-01-01T10:00:03Z"), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + + normal_roots, _, normal_ctx = generate_template_messages( + messages, compact=False + ) + compact_roots, _, compact_ctx = generate_template_messages( + messages, compact=True + ) + + normal_count = len(normal_ctx.messages) + compact_count = len(compact_ctx.messages) + assert compact_count < normal_count + + +# -- HTML rendering tests ----------------------------------------------------- + + +class TestCompactHtmlRendering: + """Test compact mode through the HTML renderer.""" + + def test_compact_html_no_tool_divs(self, tmp_path): + """Compact HTML should not contain tool_use or tool_result message divs.""" + entries = [ + _user_entry("Write a file"), + _assistant_entry( + "Creating the file.", + extra_content=[_tool_use_item("Write", "tool_w01")], + timestamp="2025-01-01T10:00:01Z", + ), + _user_entry( + "", + extra_content=[_tool_result_item("tool_w01")], + timestamp="2025-01-01T10:00:02Z", + ), + _assistant_entry("File created!", timestamp="2025-01-01T10:00:03Z"), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + + renderer = HtmlRenderer() + renderer.compact = True + html = renderer.generate(messages, "Compact Test") + + assert "class='message tool_use" not in html + assert "class='message tool_result" not in html + assert "Write a file" in html + assert "Creating the file" in html + assert "File created!" in html + + def test_compact_html_no_thinking(self, tmp_path): + """Compact HTML should not contain thinking message divs.""" + entries = [ + _user_entry("Explain something"), + _assistant_entry( + "Here's the explanation.", + extra_content=[_thinking_item("I need to consider...")], + ), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + + renderer = HtmlRenderer() + renderer.compact = True + html = renderer.generate(messages, "Compact Test") + + assert "class='message thinking" not in html + assert "I need to consider" not in html + assert "Here's the explanation" in html + + +# -- Markdown rendering tests -------------------------------------------------- + + +class TestCompactMarkdownRendering: + """Test compact mode through the Markdown renderer.""" + + def test_compact_markdown_no_tool_content(self, tmp_path): + """Compact Markdown should not contain tool names or tool output.""" + entries = [ + _user_entry("Write a file"), + _assistant_entry( + "Creating the file.", + extra_content=[_tool_use_item("Write", "tool_w01")], + timestamp="2025-01-01T10:00:01Z", + ), + _user_entry( + "", + extra_content=[_tool_result_item("tool_w01")], + timestamp="2025-01-01T10:00:02Z", + ), + _assistant_entry("File created!", timestamp="2025-01-01T10:00:03Z"), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + + renderer = MarkdownRenderer() + renderer.compact = True + md = renderer.generate(messages, "Compact Test") + + assert "Write a file" in md + assert "Creating the file" in md + assert "File created!" in md + # Tool-specific content should be absent + assert ( + "Write" not in md.split("File created!")[0].split("Creating the file.")[1] + ) + + def test_compact_markdown_no_thinking(self, tmp_path): + """Compact Markdown should not contain thinking blocks.""" + entries = [ + _user_entry("Explain this"), + _assistant_entry( + "Here's the explanation.", + extra_content=[_thinking_item("Let me reason about this...")], + ), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + + renderer = MarkdownRenderer() + renderer.compact = True + md = renderer.generate(messages, "Compact Test") + + assert "Here's the explanation" in md + assert "Let me reason about this" not in md + assert "Thinking" not in md + + def test_compact_markdown_preserves_session_structure(self, tmp_path): + """Compact Markdown preserves session headers.""" + entries = [ + _user_entry("Hello"), + _assistant_entry("Hi there"), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + + renderer = MarkdownRenderer() + renderer.compact = True + md = renderer.generate(messages, "Compact Test") + + assert "# Compact Test" in md + assert "Hello" in md + assert "Hi there" in md + + def test_compact_markdown_on_real_projects(self, tmp_path): + """Compact Markdown works on real project data.""" + real_projects = Path(__file__).parent / "test_data" / "real_projects" + if not real_projects.exists(): + pytest.skip("Real test projects not available") + + # Pick first JSONL file + jsonl_files = [] + for project_dir in real_projects.iterdir(): + if project_dir.is_dir(): + jsonl_files.extend(project_dir.glob("*.jsonl")) + if not jsonl_files: + pytest.skip("No JSONL files in real_projects") + + renderer = MarkdownRenderer() + renderer.compact = True + messages = load_transcript(jsonl_files[0]) + md = renderer.generate(messages, "Compact MD Test") + assert md + assert "# Compact MD Test" in md + + +# -- CLI tests ---------------------------------------------------------------- + + +class TestCompactCLI: + """Test the --compact CLI flag.""" + + def test_compact_flag_accepted(self, tmp_path): + """CLI accepts --compact without error.""" + entries = [ + _user_entry("Hello"), + _assistant_entry("Hi there"), + ] + _write_jsonl(entries, tmp_path / "test.jsonl") + output_file = tmp_path / "output.html" + + runner = CliRunner() + result = runner.invoke( + main, + [str(tmp_path / "test.jsonl"), "-o", str(output_file), "--compact"], + ) + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert output_file.exists() + + def test_compact_flag_filters_tools(self, tmp_path): + """CLI --compact produces HTML without tool messages.""" + entries = [ + _user_entry("Run a command"), + _assistant_entry( + "Running it.", + extra_content=[_tool_use_item("Bash", "tool_b01")], + timestamp="2025-01-01T10:00:01Z", + ), + _user_entry( + "", + extra_content=[_tool_result_item("tool_b01")], + timestamp="2025-01-01T10:00:02Z", + ), + _assistant_entry("Here's the output.", timestamp="2025-01-01T10:00:03Z"), + ] + _write_jsonl(entries, tmp_path / "test.jsonl") + output_file = tmp_path / "output.html" + + runner = CliRunner() + result = runner.invoke( + main, + [str(tmp_path / "test.jsonl"), "-o", str(output_file), "--compact"], + ) + assert result.exit_code == 0, f"CLI failed: {result.output}" + + html = output_file.read_text(encoding="utf-8") + assert "class='message tool_use" not in html + assert "class='message tool_result" not in html + assert "Run a command" in html + assert "Here's the output" in html + + def test_compact_with_markdown_format(self, tmp_path): + """CLI --compact works with --format md too.""" + entries = [ + _user_entry("Hello"), + _assistant_entry( + "Hi", + extra_content=[_tool_use_item()], + ), + ] + _write_jsonl(entries, tmp_path / "test.jsonl") + output_file = tmp_path / "output.md" + + runner = CliRunner() + result = runner.invoke( + main, + [ + str(tmp_path / "test.jsonl"), + "-o", + str(output_file), + "--compact", + "--format", + "md", + ], + ) + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert output_file.exists() + md = output_file.read_text(encoding="utf-8") + assert "Hello" in md + assert "Bash" not in md # Tool name should not appear + + +# -- Real project data tests -------------------------------------------------- + +REAL_PROJECTS_DIR = Path(__file__).parent / "test_data" / "real_projects" + + +@pytest.fixture(scope="module") +def real_projects_path() -> Path: + if not REAL_PROJECTS_DIR.exists(): + pytest.skip("Real test projects not available") + return REAL_PROJECTS_DIR + + +class TestCompactRealProjects: + """Test compact mode against real project data from test_data/real_projects/.""" + + def _get_project_jsonl_files(self, projects_path: Path) -> list[Path]: + """Get all JSONL files from real projects (top-level only, no subagents).""" + files = [] + for project_dir in sorted(projects_path.iterdir()): + if project_dir.is_dir(): + for f in project_dir.glob("*.jsonl"): + files.append(f) + return files + + def test_compact_produces_valid_html(self, real_projects_path): + """Compact mode generates valid HTML for every real project file.""" + files = self._get_project_jsonl_files(real_projects_path) + assert files, "No JSONL files found in real_projects" + + renderer = HtmlRenderer() + renderer.compact = True + + for jsonl_file in files: + messages = load_transcript(jsonl_file) + html = renderer.generate(messages, f"Compact: {jsonl_file.name}") + assert html, f"Empty HTML for {jsonl_file.name}" + assert "" in html + + def test_compact_has_no_tool_messages(self, real_projects_path): + """Compact HTML from real projects contains no tool_use/tool_result divs.""" + files = self._get_project_jsonl_files(real_projects_path) + + renderer = HtmlRenderer() + renderer.compact = True + + for jsonl_file in files: + messages = load_transcript(jsonl_file) + html = renderer.generate(messages, "Compact Test") + tool_use_count = html.count("class='message tool_use") + tool_result_count = html.count("class='message tool_result") + thinking_count = html.count("class='message thinking") + assert tool_use_count == 0, ( + f"{jsonl_file.name}: found {tool_use_count} tool_use messages" + ) + assert tool_result_count == 0, ( + f"{jsonl_file.name}: found {tool_result_count} tool_result messages" + ) + assert thinking_count == 0, ( + f"{jsonl_file.name}: found {thinking_count} thinking messages" + ) + + def test_compact_fewer_messages_than_normal(self, real_projects_path): + """Compact mode produces strictly fewer messages for projects with tools.""" + files = self._get_project_jsonl_files(real_projects_path) + + for jsonl_file in files: + messages = load_transcript(jsonl_file) + _, _, normal_ctx = generate_template_messages(messages, compact=False) + _, _, compact_ctx = generate_template_messages(messages, compact=True) + + normal_count = len(normal_ctx.messages) + compact_count = len(compact_ctx.messages) + + # Real projects typically have many tool calls, so compact should + # have fewer messages. Some tiny projects might only have text. + assert compact_count <= normal_count, ( + f"{jsonl_file.name}: compact ({compact_count}) > normal ({normal_count})" + ) + + def test_compact_preserves_user_and_assistant(self, real_projects_path): + """Compact mode keeps user and assistant messages from real projects.""" + files = self._get_project_jsonl_files(real_projects_path) + + for jsonl_file in files: + messages = load_transcript(jsonl_file) + root_messages, _, _ = generate_template_messages(messages, compact=True) + all_types = set() + _collect_types(root_messages, all_types) + + # Should only have user/assistant text types (plus session headers + # and user-derived types like bash-input/output, slash commands) + non_tool_types = all_types - { + "session-header", + "session_header", + } + # These are all derived from user/assistant text content, not tools + allowed = { + "user", + "assistant", + "bash-input", + "bash-output", + "user-slash-command", + "command-output", + "compacted-summary", + "user-steering", + "user-memory", + } + unexpected = non_tool_types - allowed + assert not unexpected, ( + f"{jsonl_file.name}: unexpected types in compact: {unexpected}" + ) + + def test_compact_directory_mode(self, real_projects_path, tmp_path): + """Compact mode works on a directory of JSONL files.""" + # Copy a project to tmp for isolated testing + project_dirs = [d for d in real_projects_path.iterdir() if d.is_dir()] + if not project_dirs: + pytest.skip("No project dirs in real_projects") + + source = project_dirs[0] + dest = tmp_path / source.name + shutil.copytree(source, dest) + + output = convert_jsonl_to( + "html", + dest, + use_cache=False, + generate_individual_sessions=False, + silent=True, + compact=True, + ) + html = output.read_text(encoding="utf-8") + assert "" in html + assert "class='message tool_use" not in html + assert "class='message tool_result" not in html + + +# -- Test data file tests (representative_messages.jsonl) ---------------------- + + +class TestCompactTestData: + """Test compact mode on the bundled test data files.""" + + @pytest.fixture + def test_data_dir(self) -> Path: + return Path(__file__).parent / "test_data" + + def test_compact_representative_messages(self, test_data_dir): + """Compact mode on representative_messages.jsonl removes tools.""" + test_file = test_data_dir / "representative_messages.jsonl" + messages = load_transcript(test_file) + + renderer = HtmlRenderer() + renderer.compact = True + html = renderer.generate(messages, "Compact Representative") + + # Should have user and assistant content + assert "class='message user" in html + assert "class='message assistant" in html + # Should not have tool content + assert "class='message tool_use" not in html + assert "class='message tool_result" not in html + + def test_compact_sidechain(self, test_data_dir): + """Compact mode on sidechain data removes tool messages.""" + test_file = test_data_dir / "sidechain.jsonl" + if not test_file.exists(): + pytest.skip("sidechain.jsonl not available") + messages = load_transcript(test_file) + + root_messages, _, _ = generate_template_messages(messages, compact=True) + all_types = set() + _collect_types(root_messages, all_types) + assert "tool_use" not in all_types + assert "tool_result" not in all_types + + +# -- Helpers ------------------------------------------------------------------ + + +def _collect_types(messages: list, types: set[str]) -> None: + """Recursively collect all message types from a tree of TemplateMessages.""" + for msg in messages: + types.add(msg.type) + if hasattr(msg, "children"): + _collect_types(msg.children, types) From 4f970ea261a0268248c4a6da7665e97837c852e4 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 1 Mar 2026 19:32:40 +0100 Subject: [PATCH 3/6] Fix compact mode to filter slash commands, sidechains, and other user-derived types Use isinstance checks on content classes instead of message_type strings, since SlashCommandMessage, CommandOutputMessage, and CompactedSummaryMessage all return "user" as their message_type. Co-Authored-By: Claude Opus 4.6 --- claude_code_log/renderer.py | 66 +++++++++++++++---- test/test_compact_mode.py | 125 +++++++++++++++++++++++++++++------- 2 files changed, 155 insertions(+), 36 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 61e7845..e565228 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -581,6 +581,11 @@ def generate_template_messages( ): ctx = _render_messages(filtered_messages, sessions, show_tokens_for_message) + # Compact post-render: remove text-derived types (bash, slash commands, etc.) + if compact: + with log_timing("Compact post-render filter", t_start): + ctx.messages = _filter_compact_template_messages(ctx.messages) + # Prepare session navigation data (uses ctx for session header indices) session_nav: list[dict[str, Any]] = [] with log_timing( @@ -1568,27 +1573,64 @@ def _filter_compact(messages: list[TranscriptEntry]) -> list[TranscriptEntry]: """Filter messages for compact mode: keep only user and assistant text. Strips tool items from user entries and thinking/tool items from assistant - entries. System, summary, and queue-operation entries are removed entirely. + entries. System, summary, queue-operation, and sidechain entries are removed. """ from copy import copy _strip_types = (ThinkingContent, ToolUseContent, ToolResultContent) filtered: list[TranscriptEntry] = [] for message in messages: - if isinstance(message, (UserTranscriptEntry, AssistantTranscriptEntry)): - text_items = [ - item - for item in message.message.content - if not isinstance(item, _strip_types) - ] - if text_items: - msg_copy = copy(message) - msg_copy.message = copy(message.message) - msg_copy.message.content = text_items - filtered.append(msg_copy) + if not isinstance(message, (UserTranscriptEntry, AssistantTranscriptEntry)): + continue + # Drop sidechain (subagent) messages + if message.isSidechain: + continue + text_items = [ + item + for item in message.message.content + if not isinstance(item, _strip_types) + ] + if text_items: + msg_copy = copy(message) + msg_copy.message = copy(message.message) + msg_copy.message.content = text_items + filtered.append(msg_copy) return filtered +# Content classes to exclude in compact mode post-render. +# These are text-derived types created by the user factory that we don't want +# in a compact view (bash commands, slash command prompts, compacted summaries). +# We check by class rather than message_type string because several of these +# classes (SlashCommandMessage, CommandOutputMessage, CompactedSummaryMessage) +# return "user" as their message_type. +_COMPACT_EXCLUDE_CLASSES = ( + BashInputMessage, + BashOutputMessage, + SlashCommandMessage, + UserSlashCommandMessage, + CommandOutputMessage, + CompactedSummaryMessage, +) + + +def _filter_compact_template_messages( + messages: list[TemplateMessage], +) -> list[TemplateMessage]: + """Post-render filter for compact mode: remove text-derived message types. + + After _render_messages creates TemplateMessages, some user text content + gets classified into special types (bash commands, slash commands, etc.) + that should be excluded from compact output. + """ + return [ + msg + for msg in messages + if not isinstance(msg.content, _COMPACT_EXCLUDE_CLASSES) + and not msg.is_sidechain + ] + + def _collect_session_info( messages: list[TranscriptEntry], session_summaries: dict[str, str], diff --git a/test/test_compact_mode.py b/test/test_compact_mode.py index 44c2dd4..bcad4e5 100644 --- a/test/test_compact_mode.py +++ b/test/test_compact_mode.py @@ -213,6 +213,23 @@ def test_drops_assistant_with_only_tool_use(self, tmp_path): result = _filter_compact(messages) assert len(result) == 0 + def test_removes_sidechain_entries(self, tmp_path): + """Sidechain (subagent) entries are dropped.""" + sidechain_user = _user_entry("Subagent prompt") + sidechain_user["isSidechain"] = True + sidechain_assistant = _assistant_entry("Subagent reply") + sidechain_assistant["isSidechain"] = True + entries = [ + _user_entry("Main prompt"), + sidechain_user, + sidechain_assistant, + _assistant_entry("Main reply"), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + result = _filter_compact(messages) + assert len(result) == 2 + assert all(not m.isSidechain for m in result) + def test_does_not_mutate_original(self, tmp_path): """Filtering creates copies, not mutations of the original.""" entries = [ @@ -292,6 +309,68 @@ def test_compact_preserves_session_headers(self, tmp_path): assert root_messages[0].is_session_header assert len(session_nav) >= 1 + def test_compact_removes_bash_messages(self, tmp_path): + """Compact mode removes bash-input and bash-output messages.""" + entries = [ + _user_entry("Check the directory"), + # bash-input is parsed from user text containing tags + _user_entry( + "ls -la", timestamp="2025-01-01T10:00:01Z" + ), + _user_entry( + "total 42\ndrwxr-xr-x", + timestamp="2025-01-01T10:00:02Z", + ), + _assistant_entry("Here are the files.", timestamp="2025-01-01T10:00:03Z"), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + + root_messages, _, _ = generate_template_messages(messages, compact=True) + all_types = set() + _collect_types(root_messages, all_types) + assert "bash-input" not in all_types + assert "bash-output" not in all_types + + def test_compact_removes_slash_command_messages(self, tmp_path): + """Compact mode removes slash command messages (e.g. /exit).""" + entries = [ + _user_entry("Hello"), + _assistant_entry("Hi", timestamp="2025-01-01T10:00:01Z"), + # Slash command entries are user entries whose text matches /command + _user_entry("/exit", timestamp="2025-01-01T10:00:02Z"), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + + root_messages, _, ctx = generate_template_messages(messages, compact=True) + all_types = set() + _collect_types(root_messages, all_types) + # /exit should not appear as any type + for msg in ctx.messages: + assert "/exit" not in getattr(msg.content, "text", ""), ( + f"Slash command '/exit' found in compact output as {msg.type}" + ) + + def test_compact_removes_sidechain_messages(self, tmp_path): + """Compact mode removes sidechain (subagent) messages entirely.""" + sidechain_user = _user_entry("Subagent task", timestamp="2025-01-01T10:00:01Z") + sidechain_user["isSidechain"] = True + sidechain_assistant = _assistant_entry( + "Subagent result", timestamp="2025-01-01T10:00:02Z" + ) + sidechain_assistant["isSidechain"] = True + entries = [ + _user_entry("Do a task"), + sidechain_user, + sidechain_assistant, + _assistant_entry("Task done.", timestamp="2025-01-01T10:00:03Z"), + ] + messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) + + root_messages, _, ctx = generate_template_messages(messages, compact=True) + # No sidechain messages should remain + for msg in ctx.messages: + assert not msg.is_sidechain, f"Sidechain message found: {msg.type}" + def test_compact_vs_normal_fewer_messages(self, tmp_path): """Compact mode produces fewer messages than normal mode.""" entries = [ @@ -593,28 +672,33 @@ def test_compact_produces_valid_html(self, real_projects_path): assert html, f"Empty HTML for {jsonl_file.name}" assert "" in html - def test_compact_has_no_tool_messages(self, real_projects_path): - """Compact HTML from real projects contains no tool_use/tool_result divs.""" + def test_compact_has_no_excluded_messages(self, real_projects_path): + """Compact HTML from real projects contains no tool, thinking, bash, or sidechain divs.""" files = self._get_project_jsonl_files(real_projects_path) renderer = HtmlRenderer() renderer.compact = True + excluded_patterns = [ + "class='message tool_use", + "class='message tool_result", + "class='message thinking", + "class='message bash-input", + "class='message bash-output", + "class='message user-slash-command", + "class='message command-output", + "class='message compacted-summary", + ] + for jsonl_file in files: messages = load_transcript(jsonl_file) html = renderer.generate(messages, "Compact Test") - tool_use_count = html.count("class='message tool_use") - tool_result_count = html.count("class='message tool_result") - thinking_count = html.count("class='message thinking") - assert tool_use_count == 0, ( - f"{jsonl_file.name}: found {tool_use_count} tool_use messages" - ) - assert tool_result_count == 0, ( - f"{jsonl_file.name}: found {tool_result_count} tool_result messages" - ) - assert thinking_count == 0, ( - f"{jsonl_file.name}: found {thinking_count} thinking messages" - ) + for pattern in excluded_patterns: + count = html.count(pattern) + msg_type = pattern.split("class='message ")[1] + assert count == 0, ( + f"{jsonl_file.name}: found {count} {msg_type} messages" + ) def test_compact_fewer_messages_than_normal(self, real_projects_path): """Compact mode produces strictly fewer messages for projects with tools.""" @@ -644,25 +728,18 @@ def test_compact_preserves_user_and_assistant(self, real_projects_path): all_types = set() _collect_types(root_messages, all_types) - # Should only have user/assistant text types (plus session headers - # and user-derived types like bash-input/output, slash commands) - non_tool_types = all_types - { + # Should only have user/assistant text types (plus session headers) + non_header_types = all_types - { "session-header", "session_header", } - # These are all derived from user/assistant text content, not tools allowed = { "user", "assistant", - "bash-input", - "bash-output", - "user-slash-command", - "command-output", - "compacted-summary", "user-steering", "user-memory", } - unexpected = non_tool_types - allowed + unexpected = non_header_types - allowed assert not unexpected, ( f"{jsonl_file.name}: unexpected types in compact: {unexpected}" ) From 77089f1c2beb343300dc18fbdd951e33e172953f Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 1 Mar 2026 19:57:57 +0100 Subject: [PATCH 4/6] Fix pyright type errors in compact filter Add explicit ContentItem annotation and cast for narrowing the UserTranscriptEntry/AssistantTranscriptEntry union on .message assignment. Co-Authored-By: Claude Opus 4.6 --- claude_code_log/renderer.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index e565228..cd2336a 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -18,10 +18,12 @@ MessageMeta, MessageType, TranscriptEntry, + AssistantMessageModel, AssistantTranscriptEntry, SystemTranscriptEntry, SummaryTranscriptEntry, QueueOperationTranscriptEntry, + UserMessageModel, UserTranscriptEntry, ContentItem, TextContent, @@ -1585,15 +1587,19 @@ def _filter_compact(messages: list[TranscriptEntry]) -> list[TranscriptEntry]: # Drop sidechain (subagent) messages if message.isSidechain: continue - text_items = [ + text_items: list[ContentItem] = [ item for item in message.message.content if not isinstance(item, _strip_types) ] if text_items: msg_copy = copy(message) - msg_copy.message = copy(message.message) - msg_copy.message.content = text_items + msg_model = copy(message.message) + msg_model.content = text_items + if isinstance(msg_copy, UserTranscriptEntry): + msg_copy.message = cast("UserMessageModel", msg_model) + else: + msg_copy.message = cast("AssistantMessageModel", msg_model) filtered.append(msg_copy) return filtered From 546e2032a76b435281e5b8d34e495894803505d6 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 1 Mar 2026 20:10:03 +0100 Subject: [PATCH 5/6] Fix ty type warnings in compact mode tests Add isinstance narrowing for TranscriptEntry union types before accessing .message and .isSidechain attributes. Co-Authored-By: Claude Opus 4.6 --- test/test_compact_mode.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/test_compact_mode.py b/test/test_compact_mode.py index bcad4e5..02d35d8 100644 --- a/test/test_compact_mode.py +++ b/test/test_compact_mode.py @@ -7,7 +7,6 @@ import json import shutil -import tempfile import uuid from pathlib import Path @@ -200,6 +199,7 @@ def test_strips_thinking_from_assistant(self, tmp_path): result = _filter_compact(messages) assert len(result) == 1 assistant = result[0] + assert isinstance(assistant, AssistantTranscriptEntry) for item in assistant.message.content: assert not isinstance(item, ThinkingContent) @@ -228,7 +228,9 @@ def test_removes_sidechain_entries(self, tmp_path): messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) result = _filter_compact(messages) assert len(result) == 2 - assert all(not m.isSidechain for m in result) + for m in result: + assert isinstance(m, (UserTranscriptEntry, AssistantTranscriptEntry)) + assert not m.isSidechain def test_does_not_mutate_original(self, tmp_path): """Filtering creates copies, not mutations of the original.""" @@ -239,9 +241,11 @@ def test_does_not_mutate_original(self, tmp_path): ), ] messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) - original_content_count = len(messages[0].message.content) + first = messages[0] + assert isinstance(first, AssistantTranscriptEntry) + original_content_count = len(first.message.content) _filter_compact(messages) - assert len(messages[0].message.content) == original_content_count + assert len(first.message.content) == original_content_count # -- Integration tests: generate_template_messages with compact --------------- From ed7036138614384fdf57fbecbb1a69326e7cd91f Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 1 Mar 2026 21:07:28 +0100 Subject: [PATCH 6/6] Rename --compact to --shallow across codebase Rename CLI flag, parameters, functions, constants, test file, and all references from "compact" to "shallow" to avoid confusion with the existing CompactedSummary message type and future compact styling mode. Co-Authored-By: Claude Opus 4.6 --- claude_code_log/cli.py | 8 +- claude_code_log/converter.py | 22 +- claude_code_log/html/renderer.py | 2 +- claude_code_log/markdown/renderer.py | 2 +- claude_code_log/renderer.py | 44 ++-- ...t_compact_mode.py => test_shallow_mode.py} | 232 +++++++++--------- 6 files changed, 155 insertions(+), 155 deletions(-) rename test/{test_compact_mode.py => test_shallow_mode.py} (80%) diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index 8b09472..2433744 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -507,7 +507,7 @@ def _clear_output_files(input_path: Path, all_projects: bool, file_ext: str) -> help="Maximum messages per page for combined transcript (default: 2000). Sessions are never split across pages.", ) @click.option( - "--compact", + "--shallow", is_flag=True, help="Render only user and assistant text messages (no tools, system, or thinking)", ) @@ -533,7 +533,7 @@ def main( output_format: str, image_export_mode: Optional[str], page_size: int, - compact: bool, + shallow: bool, debug: bool, ) -> None: """Convert Claude transcript JSONL files to HTML or Markdown. @@ -691,7 +691,7 @@ def main( output_format, image_export_mode, page_size=page_size, - compact=compact, + shallow=shallow, ) # Count processed projects @@ -744,7 +744,7 @@ def main( not no_cache, image_export_mode=image_export_mode, page_size=page_size, - compact=compact, + shallow=shallow, ) if input_path.is_file(): click.echo(f"Successfully converted {input_path} to {output_path}") diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 4db32ee..a7718ab 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -698,7 +698,7 @@ def _generate_paginated_html( session_data: Dict[str, SessionCacheData], working_directories: List[str], silent: bool = False, - compact: bool = False, + shallow: bool = False, ) -> Path: """Generate paginated HTML files for combined transcript. @@ -853,7 +853,7 @@ def _generate_paginated_html( # Generate HTML for this page page_title = f"{title} - Page {page_num}" if page_num > 1 else title page_renderer = HtmlRenderer() - page_renderer.compact = compact + page_renderer.shallow = shallow html_content = page_renderer.generate( page_messages, page_title, @@ -918,7 +918,7 @@ def convert_jsonl_to( silent: bool = False, image_export_mode: Optional[str] = None, page_size: int = 2000, - compact: bool = False, + shallow: bool = False, ) -> Path: """Convert JSONL transcript(s) to the specified format. @@ -934,7 +934,7 @@ def convert_jsonl_to( image_export_mode: Image export mode ("placeholder", "embedded", "referenced"). page_size: Maximum messages per page for combined transcript pagination. If None, uses format default (embedded for HTML, referenced for Markdown). - compact: If True, render only user and assistant text messages. + shallow: If True, render only user and assistant text messages. """ if not input_path.exists(): raise FileNotFoundError(f"Input path not found: {input_path}") @@ -1023,7 +1023,7 @@ def convert_jsonl_to( # Generate combined output file (check if regeneration needed) assert output_path is not None - renderer = get_renderer(format, image_export_mode, compact=compact) + renderer = get_renderer(format, image_export_mode, shallow=shallow) # Decide whether to use pagination (HTML only, directory mode, no date filter) use_pagination = False @@ -1070,7 +1070,7 @@ def convert_jsonl_to( session_data, working_directories, silent=silent, - compact=compact, + shallow=shallow, ) else: # Use single-file generation for small projects or filtered views @@ -1123,7 +1123,7 @@ def convert_jsonl_to( cache_was_updated, image_export_mode, silent=silent, - compact=compact, + shallow=shallow, ) return output_path @@ -1486,7 +1486,7 @@ def _generate_individual_session_files( cache_was_updated: bool = False, image_export_mode: Optional[str] = None, silent: bool = False, - compact: bool = False, + shallow: bool = False, ) -> int: """Generate individual files for each session in the specified format. @@ -1522,7 +1522,7 @@ def _generate_individual_session_files( project_title = get_project_display_name(output_dir.name, working_directories) # Get renderer once outside the loop - renderer = get_renderer(format, image_export_mode, compact=compact) + renderer = get_renderer(format, image_export_mode, shallow=shallow) regenerated_count = 0 # Generate HTML file for each session @@ -1670,7 +1670,7 @@ def process_projects_hierarchy( image_export_mode: Optional[str] = None, silent: bool = True, page_size: int = 2000, - compact: bool = False, + shallow: bool = False, ) -> Path: """Process the entire ~/.claude/projects/ hierarchy and create linked output files. @@ -1827,7 +1827,7 @@ def process_projects_hierarchy( silent=silent, image_export_mode=image_export_mode, page_size=page_size, - compact=compact, + shallow=shallow, ) # Track timing diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 9993cf0..11ad1bb 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -542,7 +542,7 @@ def generate( # Get root messages (tree) and session navigation from format-neutral renderer root_messages, session_nav, _ = generate_template_messages( - messages, compact=self.compact + messages, shallow=self.shallow ) # Flatten tree via pre-order traversal, formatting content along the way diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index 5ee476d..40dc56b 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -877,7 +877,7 @@ def generate( # Get root messages (tree), session navigation, and rendering context root_messages, session_nav, ctx = generate_template_messages( - messages, compact=self.compact + messages, shallow=self.shallow ) self._ctx = ctx diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index cd2336a..a115a9e 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -526,7 +526,7 @@ def __init__(self, project_summaries: list[dict[str, Any]]): def generate_template_messages( messages: list[TranscriptEntry], - compact: bool = False, + shallow: bool = False, ) -> Tuple[list[TemplateMessage], list[dict[str, Any]], RenderingContext]: """Generate root messages and session navigation from transcript messages. @@ -565,10 +565,10 @@ def generate_template_messages( with log_timing("Filter messages", t_start): filtered_messages = _filter_messages(messages) - # Compact mode: keep only user and assistant text messages (no tools, system, thinking) - if compact: - with log_timing("Compact filter", t_start): - filtered_messages = _filter_compact(filtered_messages) + # Shallow mode: keep only user and assistant text messages (no tools, system, thinking) + if shallow: + with log_timing("Shallow filter", t_start): + filtered_messages = _filter_shallow(filtered_messages) # Pass 1: Collect session metadata and token tracking with log_timing("Collect session info", t_start): @@ -583,10 +583,10 @@ def generate_template_messages( ): ctx = _render_messages(filtered_messages, sessions, show_tokens_for_message) - # Compact post-render: remove text-derived types (bash, slash commands, etc.) - if compact: - with log_timing("Compact post-render filter", t_start): - ctx.messages = _filter_compact_template_messages(ctx.messages) + # Shallow post-render: remove text-derived types (bash, slash commands, etc.) + if shallow: + with log_timing("Shallow post-render filter", t_start): + ctx.messages = _filter_shallow_template_messages(ctx.messages) # Prepare session navigation data (uses ctx for session header indices) session_nav: list[dict[str, Any]] = [] @@ -1571,8 +1571,8 @@ def _filter_messages(messages: list[TranscriptEntry]) -> list[TranscriptEntry]: return filtered -def _filter_compact(messages: list[TranscriptEntry]) -> list[TranscriptEntry]: - """Filter messages for compact mode: keep only user and assistant text. +def _filter_shallow(messages: list[TranscriptEntry]) -> list[TranscriptEntry]: + """Filter messages for shallow mode: keep only user and assistant text. Strips tool items from user entries and thinking/tool items from assistant entries. System, summary, queue-operation, and sidechain entries are removed. @@ -1604,13 +1604,13 @@ def _filter_compact(messages: list[TranscriptEntry]) -> list[TranscriptEntry]: return filtered -# Content classes to exclude in compact mode post-render. +# Content classes to exclude in shallow mode post-render. # These are text-derived types created by the user factory that we don't want -# in a compact view (bash commands, slash command prompts, compacted summaries). +# in a shallow view (bash commands, slash command prompts, compacted summaries). # We check by class rather than message_type string because several of these # classes (SlashCommandMessage, CommandOutputMessage, CompactedSummaryMessage) # return "user" as their message_type. -_COMPACT_EXCLUDE_CLASSES = ( +_SHALLOW_EXCLUDE_CLASSES = ( BashInputMessage, BashOutputMessage, SlashCommandMessage, @@ -1620,19 +1620,19 @@ def _filter_compact(messages: list[TranscriptEntry]) -> list[TranscriptEntry]: ) -def _filter_compact_template_messages( +def _filter_shallow_template_messages( messages: list[TemplateMessage], ) -> list[TemplateMessage]: - """Post-render filter for compact mode: remove text-derived message types. + """Post-render filter for shallow mode: remove text-derived message types. After _render_messages creates TemplateMessages, some user text content gets classified into special types (bash commands, slash commands, etc.) - that should be excluded from compact output. + that should be excluded from shallow output. """ return [ msg for msg in messages - if not isinstance(msg.content, _COMPACT_EXCLUDE_CLASSES) + if not isinstance(msg.content, _SHALLOW_EXCLUDE_CLASSES) and not msg.is_sidechain ] @@ -2093,7 +2093,7 @@ class Renderer: - Subclasses override methods to implement format-specific rendering """ - compact: bool = False + shallow: bool = False def _dispatch_format(self, obj: Any, message: TemplateMessage) -> str: """Dispatch to format_{ClassName}(obj, message) based on object type.""" @@ -2368,7 +2368,7 @@ def is_outdated(self, file_path: Path) -> Optional[bool]: def get_renderer( format: str, image_export_mode: Optional[str] = None, - compact: bool = False, + shallow: bool = False, ) -> Renderer: """Get a renderer instance for the specified format. @@ -2376,7 +2376,7 @@ def get_renderer( format: The output format ("html", "md", or "markdown"). image_export_mode: Image export mode ("placeholder", "embedded", "referenced"). If None, defaults to "embedded" for HTML and "referenced" for Markdown. - compact: If True, render only user and assistant text messages. + shallow: If True, render only user and assistant text messages. Returns: A Renderer instance for the specified format. @@ -2398,7 +2398,7 @@ def get_renderer( renderer = MarkdownRenderer(image_export_mode=mode) else: raise ValueError(f"Unsupported format: {format}") - renderer.compact = compact + renderer.shallow = shallow return renderer diff --git a/test/test_compact_mode.py b/test/test_shallow_mode.py similarity index 80% rename from test/test_compact_mode.py rename to test/test_shallow_mode.py index 02d35d8..485ea41 100644 --- a/test/test_compact_mode.py +++ b/test/test_shallow_mode.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -"""Tests for --compact rendering mode. +"""Tests for --shallow rendering mode. -Compact mode filters out everything except user and assistant text messages: +Shallow mode filters out everything except user and assistant text messages: no tools, no thinking, no system messages. """ @@ -25,7 +25,7 @@ ToolUseContent, UserTranscriptEntry, ) -from claude_code_log.renderer import _filter_compact, generate_template_messages +from claude_code_log.renderer import _filter_shallow, generate_template_messages # -- Test data helpers -------------------------------------------------------- @@ -126,7 +126,7 @@ def _write_jsonl(entries: list[dict], path: Path) -> Path: # -- Unit tests for _filter_compact ------------------------------------------ -class TestFilterCompact: +class TestFilterShallow: """Test the _filter_compact function directly on parsed TranscriptEntry lists.""" def test_keeps_user_and_assistant_text(self, tmp_path): @@ -136,7 +136,7 @@ def test_keeps_user_and_assistant_text(self, tmp_path): _assistant_entry("Hi there!"), ] messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) - result = _filter_compact(messages) + result = _filter_shallow(messages) assert len(result) == 2 assert isinstance(result[0], UserTranscriptEntry) assert isinstance(result[1], AssistantTranscriptEntry) @@ -149,7 +149,7 @@ def test_removes_system_entries(self, tmp_path): _assistant_entry("Hi"), ] messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) - result = _filter_compact(messages) + result = _filter_shallow(messages) assert len(result) == 2 assert all(not isinstance(m, SystemTranscriptEntry) for m in result) @@ -163,7 +163,7 @@ def test_strips_tool_use_from_assistant(self, tmp_path): ), ] messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) - result = _filter_compact(messages) + result = _filter_shallow(messages) assert len(result) == 2 # Assistant entry should have text but no tool_use assistant = result[1] @@ -180,7 +180,7 @@ def test_strips_tool_result_from_user(self, tmp_path): ), ] messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) - result = _filter_compact(messages) + result = _filter_shallow(messages) assert len(result) == 1 user = result[0] assert isinstance(user, UserTranscriptEntry) @@ -196,7 +196,7 @@ def test_strips_thinking_from_assistant(self, tmp_path): ), ] messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) - result = _filter_compact(messages) + result = _filter_shallow(messages) assert len(result) == 1 assistant = result[0] assert isinstance(assistant, AssistantTranscriptEntry) @@ -210,7 +210,7 @@ def test_drops_assistant_with_only_tool_use(self, tmp_path): # Remove the text item, keeping only tool_use entry["message"]["content"] = [_tool_use_item()] messages = load_transcript(_write_jsonl([entry], tmp_path / "t.jsonl")) - result = _filter_compact(messages) + result = _filter_shallow(messages) assert len(result) == 0 def test_removes_sidechain_entries(self, tmp_path): @@ -226,7 +226,7 @@ def test_removes_sidechain_entries(self, tmp_path): _assistant_entry("Main reply"), ] messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) - result = _filter_compact(messages) + result = _filter_shallow(messages) assert len(result) == 2 for m in result: assert isinstance(m, (UserTranscriptEntry, AssistantTranscriptEntry)) @@ -244,18 +244,18 @@ def test_does_not_mutate_original(self, tmp_path): first = messages[0] assert isinstance(first, AssistantTranscriptEntry) original_content_count = len(first.message.content) - _filter_compact(messages) + _filter_shallow(messages) assert len(first.message.content) == original_content_count -# -- Integration tests: generate_template_messages with compact --------------- +# -- Integration tests: generate_template_messages with shallow --------------- -class TestCompactTemplateMessages: - """Test compact mode through the full generate_template_messages pipeline.""" +class TestShallowTemplateMessages: + """Test shallow mode through the full generate_template_messages pipeline.""" - def test_compact_removes_tool_messages(self, tmp_path): - """Compact mode should not produce tool_use or tool_result TemplateMessages.""" + def test_shallow_removes_tool_messages(self, tmp_path): + """Shallow mode should not produce tool_use or tool_result TemplateMessages.""" entries = [ _user_entry("Run something"), _assistant_entry( @@ -272,7 +272,7 @@ def test_compact_removes_tool_messages(self, tmp_path): ] messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) - root_messages, _, _ = generate_template_messages(messages, compact=True) + root_messages, _, _ = generate_template_messages(messages, shallow=True) # Flatten tree all_types = set() _collect_types(root_messages, all_types) @@ -281,8 +281,8 @@ def test_compact_removes_tool_messages(self, tmp_path): assert "user" in all_types assert "assistant" in all_types - def test_compact_removes_thinking_messages(self, tmp_path): - """Compact mode should not produce thinking TemplateMessages.""" + def test_shallow_removes_thinking_messages(self, tmp_path): + """Shallow mode should not produce thinking TemplateMessages.""" entries = [ _user_entry("Think about this"), _assistant_entry( @@ -292,14 +292,14 @@ def test_compact_removes_thinking_messages(self, tmp_path): ] messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) - root_messages, _, _ = generate_template_messages(messages, compact=True) + root_messages, _, _ = generate_template_messages(messages, shallow=True) all_types = set() _collect_types(root_messages, all_types) assert "thinking" not in all_types assert "assistant" in all_types - def test_compact_preserves_session_headers(self, tmp_path): - """Session headers are still generated in compact mode.""" + def test_shallow_preserves_session_headers(self, tmp_path): + """Session headers are still generated in shallow mode.""" entries = [ _user_entry("Hello"), _assistant_entry("Hi"), @@ -307,14 +307,14 @@ def test_compact_preserves_session_headers(self, tmp_path): messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) root_messages, session_nav, _ = generate_template_messages( - messages, compact=True + messages, shallow=True ) assert len(root_messages) >= 1 assert root_messages[0].is_session_header assert len(session_nav) >= 1 - def test_compact_removes_bash_messages(self, tmp_path): - """Compact mode removes bash-input and bash-output messages.""" + def test_shallow_removes_bash_messages(self, tmp_path): + """Shallow mode removes bash-input and bash-output messages.""" entries = [ _user_entry("Check the directory"), # bash-input is parsed from user text containing tags @@ -329,14 +329,14 @@ def test_compact_removes_bash_messages(self, tmp_path): ] messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) - root_messages, _, _ = generate_template_messages(messages, compact=True) + root_messages, _, _ = generate_template_messages(messages, shallow=True) all_types = set() _collect_types(root_messages, all_types) assert "bash-input" not in all_types assert "bash-output" not in all_types - def test_compact_removes_slash_command_messages(self, tmp_path): - """Compact mode removes slash command messages (e.g. /exit).""" + def test_shallow_removes_slash_command_messages(self, tmp_path): + """Shallow mode removes slash command messages (e.g. /exit).""" entries = [ _user_entry("Hello"), _assistant_entry("Hi", timestamp="2025-01-01T10:00:01Z"), @@ -345,17 +345,17 @@ def test_compact_removes_slash_command_messages(self, tmp_path): ] messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) - root_messages, _, ctx = generate_template_messages(messages, compact=True) + root_messages, _, ctx = generate_template_messages(messages, shallow=True) all_types = set() _collect_types(root_messages, all_types) # /exit should not appear as any type for msg in ctx.messages: assert "/exit" not in getattr(msg.content, "text", ""), ( - f"Slash command '/exit' found in compact output as {msg.type}" + f"Slash command '/exit' found in shallow output as {msg.type}" ) - def test_compact_removes_sidechain_messages(self, tmp_path): - """Compact mode removes sidechain (subagent) messages entirely.""" + def test_shallow_removes_sidechain_messages(self, tmp_path): + """Shallow mode removes sidechain (subagent) messages entirely.""" sidechain_user = _user_entry("Subagent task", timestamp="2025-01-01T10:00:01Z") sidechain_user["isSidechain"] = True sidechain_assistant = _assistant_entry( @@ -370,13 +370,13 @@ def test_compact_removes_sidechain_messages(self, tmp_path): ] messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) - root_messages, _, ctx = generate_template_messages(messages, compact=True) + root_messages, _, ctx = generate_template_messages(messages, shallow=True) # No sidechain messages should remain for msg in ctx.messages: assert not msg.is_sidechain, f"Sidechain message found: {msg.type}" - def test_compact_vs_normal_fewer_messages(self, tmp_path): - """Compact mode produces fewer messages than normal mode.""" + def test_shallow_vs_normal_fewer_messages(self, tmp_path): + """Shallow mode produces fewer messages than normal mode.""" entries = [ _user_entry("Do something"), _assistant_entry( @@ -394,25 +394,25 @@ def test_compact_vs_normal_fewer_messages(self, tmp_path): messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) normal_roots, _, normal_ctx = generate_template_messages( - messages, compact=False + messages, shallow=False ) - compact_roots, _, compact_ctx = generate_template_messages( - messages, compact=True + shallow_roots, _, shallow_ctx = generate_template_messages( + messages, shallow=True ) normal_count = len(normal_ctx.messages) - compact_count = len(compact_ctx.messages) - assert compact_count < normal_count + shallow_count = len(shallow_ctx.messages) + assert shallow_count < normal_count # -- HTML rendering tests ----------------------------------------------------- -class TestCompactHtmlRendering: - """Test compact mode through the HTML renderer.""" +class TestShallowHtmlRendering: + """Test shallow mode through the HTML renderer.""" - def test_compact_html_no_tool_divs(self, tmp_path): - """Compact HTML should not contain tool_use or tool_result message divs.""" + def test_shallow_html_no_tool_divs(self, tmp_path): + """Shallow HTML should not contain tool_use or tool_result message divs.""" entries = [ _user_entry("Write a file"), _assistant_entry( @@ -430,8 +430,8 @@ def test_compact_html_no_tool_divs(self, tmp_path): messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) renderer = HtmlRenderer() - renderer.compact = True - html = renderer.generate(messages, "Compact Test") + renderer.shallow = True + html = renderer.generate(messages, "Shallow Test") assert "class='message tool_use" not in html assert "class='message tool_result" not in html @@ -439,8 +439,8 @@ def test_compact_html_no_tool_divs(self, tmp_path): assert "Creating the file" in html assert "File created!" in html - def test_compact_html_no_thinking(self, tmp_path): - """Compact HTML should not contain thinking message divs.""" + def test_shallow_html_no_thinking(self, tmp_path): + """Shallow HTML should not contain thinking message divs.""" entries = [ _user_entry("Explain something"), _assistant_entry( @@ -451,8 +451,8 @@ def test_compact_html_no_thinking(self, tmp_path): messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) renderer = HtmlRenderer() - renderer.compact = True - html = renderer.generate(messages, "Compact Test") + renderer.shallow = True + html = renderer.generate(messages, "Shallow Test") assert "class='message thinking" not in html assert "I need to consider" not in html @@ -462,11 +462,11 @@ def test_compact_html_no_thinking(self, tmp_path): # -- Markdown rendering tests -------------------------------------------------- -class TestCompactMarkdownRendering: - """Test compact mode through the Markdown renderer.""" +class TestShallowMarkdownRendering: + """Test shallow mode through the Markdown renderer.""" - def test_compact_markdown_no_tool_content(self, tmp_path): - """Compact Markdown should not contain tool names or tool output.""" + def test_shallow_markdown_no_tool_content(self, tmp_path): + """Shallow Markdown should not contain tool names or tool output.""" entries = [ _user_entry("Write a file"), _assistant_entry( @@ -484,8 +484,8 @@ def test_compact_markdown_no_tool_content(self, tmp_path): messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) renderer = MarkdownRenderer() - renderer.compact = True - md = renderer.generate(messages, "Compact Test") + renderer.shallow = True + md = renderer.generate(messages, "Shallow Test") assert "Write a file" in md assert "Creating the file" in md @@ -495,8 +495,8 @@ def test_compact_markdown_no_tool_content(self, tmp_path): "Write" not in md.split("File created!")[0].split("Creating the file.")[1] ) - def test_compact_markdown_no_thinking(self, tmp_path): - """Compact Markdown should not contain thinking blocks.""" + def test_shallow_markdown_no_thinking(self, tmp_path): + """Shallow Markdown should not contain thinking blocks.""" entries = [ _user_entry("Explain this"), _assistant_entry( @@ -507,15 +507,15 @@ def test_compact_markdown_no_thinking(self, tmp_path): messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) renderer = MarkdownRenderer() - renderer.compact = True - md = renderer.generate(messages, "Compact Test") + renderer.shallow = True + md = renderer.generate(messages, "Shallow Test") assert "Here's the explanation" in md assert "Let me reason about this" not in md assert "Thinking" not in md - def test_compact_markdown_preserves_session_structure(self, tmp_path): - """Compact Markdown preserves session headers.""" + def test_shallow_markdown_preserves_session_structure(self, tmp_path): + """Shallow Markdown preserves session headers.""" entries = [ _user_entry("Hello"), _assistant_entry("Hi there"), @@ -523,15 +523,15 @@ def test_compact_markdown_preserves_session_structure(self, tmp_path): messages = load_transcript(_write_jsonl(entries, tmp_path / "t.jsonl")) renderer = MarkdownRenderer() - renderer.compact = True - md = renderer.generate(messages, "Compact Test") + renderer.shallow = True + md = renderer.generate(messages, "Shallow Test") - assert "# Compact Test" in md + assert "# Shallow Test" in md assert "Hello" in md assert "Hi there" in md - def test_compact_markdown_on_real_projects(self, tmp_path): - """Compact Markdown works on real project data.""" + def test_shallow_markdown_on_real_projects(self, tmp_path): + """Shallow Markdown works on real project data.""" real_projects = Path(__file__).parent / "test_data" / "real_projects" if not real_projects.exists(): pytest.skip("Real test projects not available") @@ -545,21 +545,21 @@ def test_compact_markdown_on_real_projects(self, tmp_path): pytest.skip("No JSONL files in real_projects") renderer = MarkdownRenderer() - renderer.compact = True + renderer.shallow = True messages = load_transcript(jsonl_files[0]) - md = renderer.generate(messages, "Compact MD Test") + md = renderer.generate(messages, "Shallow MD Test") assert md - assert "# Compact MD Test" in md + assert "# Shallow MD Test" in md # -- CLI tests ---------------------------------------------------------------- -class TestCompactCLI: - """Test the --compact CLI flag.""" +class TestShallowCLI: + """Test the --shallow CLI flag.""" - def test_compact_flag_accepted(self, tmp_path): - """CLI accepts --compact without error.""" + def test_shallow_flag_accepted(self, tmp_path): + """CLI accepts --shallow without error.""" entries = [ _user_entry("Hello"), _assistant_entry("Hi there"), @@ -570,13 +570,13 @@ def test_compact_flag_accepted(self, tmp_path): runner = CliRunner() result = runner.invoke( main, - [str(tmp_path / "test.jsonl"), "-o", str(output_file), "--compact"], + [str(tmp_path / "test.jsonl"), "-o", str(output_file), "--shallow"], ) assert result.exit_code == 0, f"CLI failed: {result.output}" assert output_file.exists() - def test_compact_flag_filters_tools(self, tmp_path): - """CLI --compact produces HTML without tool messages.""" + def test_shallow_flag_filters_tools(self, tmp_path): + """CLI --shallow produces HTML without tool messages.""" entries = [ _user_entry("Run a command"), _assistant_entry( @@ -597,7 +597,7 @@ def test_compact_flag_filters_tools(self, tmp_path): runner = CliRunner() result = runner.invoke( main, - [str(tmp_path / "test.jsonl"), "-o", str(output_file), "--compact"], + [str(tmp_path / "test.jsonl"), "-o", str(output_file), "--shallow"], ) assert result.exit_code == 0, f"CLI failed: {result.output}" @@ -607,8 +607,8 @@ def test_compact_flag_filters_tools(self, tmp_path): assert "Run a command" in html assert "Here's the output" in html - def test_compact_with_markdown_format(self, tmp_path): - """CLI --compact works with --format md too.""" + def test_shallow_with_markdown_format(self, tmp_path): + """CLI --shallow works with --format md too.""" entries = [ _user_entry("Hello"), _assistant_entry( @@ -626,7 +626,7 @@ def test_compact_with_markdown_format(self, tmp_path): str(tmp_path / "test.jsonl"), "-o", str(output_file), - "--compact", + "--shallow", "--format", "md", ], @@ -650,8 +650,8 @@ def real_projects_path() -> Path: return REAL_PROJECTS_DIR -class TestCompactRealProjects: - """Test compact mode against real project data from test_data/real_projects/.""" +class TestShallowRealProjects: + """Test shallow mode against real project data from test_data/real_projects/.""" def _get_project_jsonl_files(self, projects_path: Path) -> list[Path]: """Get all JSONL files from real projects (top-level only, no subagents).""" @@ -662,26 +662,26 @@ def _get_project_jsonl_files(self, projects_path: Path) -> list[Path]: files.append(f) return files - def test_compact_produces_valid_html(self, real_projects_path): - """Compact mode generates valid HTML for every real project file.""" + def test_shallow_produces_valid_html(self, real_projects_path): + """Shallow mode generates valid HTML for every real project file.""" files = self._get_project_jsonl_files(real_projects_path) assert files, "No JSONL files found in real_projects" renderer = HtmlRenderer() - renderer.compact = True + renderer.shallow = True for jsonl_file in files: messages = load_transcript(jsonl_file) - html = renderer.generate(messages, f"Compact: {jsonl_file.name}") + html = renderer.generate(messages, f"Shallow: {jsonl_file.name}") assert html, f"Empty HTML for {jsonl_file.name}" assert "" in html - def test_compact_has_no_excluded_messages(self, real_projects_path): - """Compact HTML from real projects contains no tool, thinking, bash, or sidechain divs.""" + def test_shallow_has_no_excluded_messages(self, real_projects_path): + """Shallow HTML from real projects contains no tool, thinking, bash, or sidechain divs.""" files = self._get_project_jsonl_files(real_projects_path) renderer = HtmlRenderer() - renderer.compact = True + renderer.shallow = True excluded_patterns = [ "class='message tool_use", @@ -696,7 +696,7 @@ def test_compact_has_no_excluded_messages(self, real_projects_path): for jsonl_file in files: messages = load_transcript(jsonl_file) - html = renderer.generate(messages, "Compact Test") + html = renderer.generate(messages, "Shallow Test") for pattern in excluded_patterns: count = html.count(pattern) msg_type = pattern.split("class='message ")[1] @@ -704,31 +704,31 @@ def test_compact_has_no_excluded_messages(self, real_projects_path): f"{jsonl_file.name}: found {count} {msg_type} messages" ) - def test_compact_fewer_messages_than_normal(self, real_projects_path): - """Compact mode produces strictly fewer messages for projects with tools.""" + def test_shallow_fewer_messages_than_normal(self, real_projects_path): + """Shallow mode produces strictly fewer messages for projects with tools.""" files = self._get_project_jsonl_files(real_projects_path) for jsonl_file in files: messages = load_transcript(jsonl_file) - _, _, normal_ctx = generate_template_messages(messages, compact=False) - _, _, compact_ctx = generate_template_messages(messages, compact=True) + _, _, normal_ctx = generate_template_messages(messages, shallow=False) + _, _, shallow_ctx = generate_template_messages(messages, shallow=True) normal_count = len(normal_ctx.messages) - compact_count = len(compact_ctx.messages) + shallow_count = len(shallow_ctx.messages) - # Real projects typically have many tool calls, so compact should + # Real projects typically have many tool calls, so shallow should # have fewer messages. Some tiny projects might only have text. - assert compact_count <= normal_count, ( - f"{jsonl_file.name}: compact ({compact_count}) > normal ({normal_count})" + assert shallow_count <= normal_count, ( + f"{jsonl_file.name}: shallow ({shallow_count}) > normal ({normal_count})" ) - def test_compact_preserves_user_and_assistant(self, real_projects_path): - """Compact mode keeps user and assistant messages from real projects.""" + def test_shallow_preserves_user_and_assistant(self, real_projects_path): + """Shallow mode keeps user and assistant messages from real projects.""" files = self._get_project_jsonl_files(real_projects_path) for jsonl_file in files: messages = load_transcript(jsonl_file) - root_messages, _, _ = generate_template_messages(messages, compact=True) + root_messages, _, _ = generate_template_messages(messages, shallow=True) all_types = set() _collect_types(root_messages, all_types) @@ -745,11 +745,11 @@ def test_compact_preserves_user_and_assistant(self, real_projects_path): } unexpected = non_header_types - allowed assert not unexpected, ( - f"{jsonl_file.name}: unexpected types in compact: {unexpected}" + f"{jsonl_file.name}: unexpected types in shallow: {unexpected}" ) - def test_compact_directory_mode(self, real_projects_path, tmp_path): - """Compact mode works on a directory of JSONL files.""" + def test_shallow_directory_mode(self, real_projects_path, tmp_path): + """Shallow mode works on a directory of JSONL files.""" # Copy a project to tmp for isolated testing project_dirs = [d for d in real_projects_path.iterdir() if d.is_dir()] if not project_dirs: @@ -765,7 +765,7 @@ def test_compact_directory_mode(self, real_projects_path, tmp_path): use_cache=False, generate_individual_sessions=False, silent=True, - compact=True, + shallow=True, ) html = output.read_text(encoding="utf-8") assert "" in html @@ -776,21 +776,21 @@ def test_compact_directory_mode(self, real_projects_path, tmp_path): # -- Test data file tests (representative_messages.jsonl) ---------------------- -class TestCompactTestData: - """Test compact mode on the bundled test data files.""" +class TestShallowTestData: + """Test shallow mode on the bundled test data files.""" @pytest.fixture def test_data_dir(self) -> Path: return Path(__file__).parent / "test_data" - def test_compact_representative_messages(self, test_data_dir): - """Compact mode on representative_messages.jsonl removes tools.""" + def test_shallow_representative_messages(self, test_data_dir): + """Shallow mode on representative_messages.jsonl removes tools.""" test_file = test_data_dir / "representative_messages.jsonl" messages = load_transcript(test_file) renderer = HtmlRenderer() - renderer.compact = True - html = renderer.generate(messages, "Compact Representative") + renderer.shallow = True + html = renderer.generate(messages, "Shallow Representative") # Should have user and assistant content assert "class='message user" in html @@ -799,14 +799,14 @@ def test_compact_representative_messages(self, test_data_dir): assert "class='message tool_use" not in html assert "class='message tool_result" not in html - def test_compact_sidechain(self, test_data_dir): - """Compact mode on sidechain data removes tool messages.""" + def test_shallow_sidechain(self, test_data_dir): + """Shallow mode on sidechain data removes tool messages.""" test_file = test_data_dir / "sidechain.jsonl" if not test_file.exists(): pytest.skip("sidechain.jsonl not available") messages = load_transcript(test_file) - root_messages, _, _ = generate_template_messages(messages, compact=True) + root_messages, _, _ = generate_template_messages(messages, shallow=True) all_types = set() _collect_types(root_messages, all_types) assert "tool_use" not in all_types