diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index a82c65ff..cc174bbd 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -23,6 +23,7 @@ from .utils import ( escape_html, + is_markdown_path, is_memory_path, render_collapsible_code, render_async_result_body, @@ -391,6 +392,26 @@ def format_read_input(read_input: ReadInput) -> str: # noqa: ARG001 # Parsing (parse_read_output, parse_edit_output) is now in factories/tool_factory.py +def _is_full_read(output: ReadOutput) -> bool: + """True when a Read result covers the file from line 1 with no truncation. + + A full read is safe to render as Markdown; a partial slice (``start_line`` + > 1 or truncated content) can split a code fence and is kept as Pygments + source (issue #232). The text-only parser fallback can't recover + ``total_lines`` and reports ``is_truncated=False`` with ``start_line=1`` + for a whole-file read, so it correctly reads as full. + + Residual edge: a *truncated* read that started at line 1, parsed via the + text-only fallback (no structured ``toolUseResult.file``), forces + ``is_truncated=False`` and so reads as full — rendering as Markdown even + though the tail was cut, which may garble a fence straddling the cut. + This is narrow (old transcripts only; modern ones carry the structured + metadata that classifies it correctly) and cosmetic (escaping still + applies, so no XSS/crash). + """ + return output.start_line == 1 and not output.is_truncated + + def format_read_output(output: ReadOutput) -> str: """Format Read tool result as HTML with syntax highlighting. @@ -411,12 +432,24 @@ def format_read_output(output: ReadOutput) -> str: # Auto-memory files are Markdown (MEMORY.md + topic .md), so render a # recalled-memory body as rendered Markdown rather than syntax-highlighted # source — using the project's usual collapsible-markdown helper (#192). + # Memory bodies render as Markdown unconditionally (even partial reads): + # memory files are small and read whole, and #192 pinned this behavior. if is_memory_path(output.file_path): # Escape HTML: memory files are untrusted content — raw \n")) + assert "<script>" in html + assert "" not in html + + def test_general_md_read_has_no_memory_link_resolution(self): + # Relative links in a non-memory .md stay as-authored (no file:// rewrite). + html = format_read_output(_read(MD, "[peer](peer.md)\n")) + assert 'href="peer.md"' in html + assert "file://" not in html + + +# ----------------------------- Write rendering ------------------------------- + + +class TestWriteMarkdownRendering: + def test_md_write_rendered_as_markdown(self): + html = format_write_input(WriteInput(file_path=MD, content=MD_BODY)) + assert re.search(r'class="write-tool-content markdown"', html) + assert re.search(r"