"""HTML-specific rendering utilities. This module contains all HTML generation code: - CSS class computation from message type and modifiers - Message emoji generation - HTML escaping and markdown rendering - Collapsible content rendering - Tool-specific HTML formatters - Message content HTML rendering - Template environment management The functions here transform format-neutral TemplateMessage data into HTML-specific output. """ import functools import html import json import logging import os import re import shutil from pathlib import Path from typing import Any, Optional, TYPE_CHECKING from urllib.parse import urlsplit from jinja2 import Environment, FileSystemLoader, select_autoescape from wenmode import HTMLRenderer, Wenmode from wenmode.renderers.html import ( normalize_url_for_scheme_check as _normalize_url_for_scheme_check, ) from wenmode.renderers.html import render_code as _default_render_code from .renderer_code import highlight_code_with_pygments, truncate_highlighted_preview from ..json_depth import exceeds_depth from ..models import ( AssistantTextMessage, AwaySummaryMessage, HookAttachmentMessage, BashInputMessage, BashOutputMessage, CommandOutputMessage, CompactedSummaryMessage, HookSummaryMessage, MessageContent, SessionHeaderMessage, SlashCommandMessage, SystemMessage, TaskNotificationMessage, TeammateMessage, ThinkingMessage, ToolResultMessage, ToolUseMessage, UnknownMessage, UserMemoryMessage, UserSlashCommandMessage, UserSteeringMessage, UserTextMessage, WorkflowAgentMessage, WorkflowPhaseMessage, ) from ..render_cache import markdown_cache from ..renderer_timings import timing_stat if TYPE_CHECKING: from ..renderer import TemplateMessage # -- Auto-memory detection ---------------------------------------------------- # Claude's auto-memory (https://code.claude.com/docs/en/memory#auto-memory) # has no dedicated tool: it surfaces in transcripts as ordinary Read/Write/Edit # calls whose file_path points into the per-project memory directory. We anchor # on the full default path ``~/.claude/projects//memory/`` rather than a # bare ``memory/`` substring so a project's own ``memory/`` folder can't yield # false positives (issue #192). # # The regex is forward-slash anchored; ``_normalize_sep`` folds Windows # backslash paths to forward slashes first so detection works on windows-latest # (transcript file_paths may use the native separator). # # Limitation (acceptable for v1; see work/parse-memory-spike.md): a custom # ``autoMemoryDirectory`` setting relocates memory outside this path and won't # be detected — that needs config plumbing, so it stays deferred. _MEMORY_PATH_RE = re.compile(r"/\.claude/projects/[^/]+/memory/") # File tools whose paths we flag as memory interactions. Bash references a # memory path inside its command string (not a typed field) and is rare, so # it's intentionally out of scope for v1 (issue #192). _MEMORY_TOOL_NAMES = frozenset({"Read", "Write", "Edit"}) def _normalize_sep(file_path: str) -> str: """Fold Windows backslash separators to forward slashes for matching.""" return file_path.replace("\\", "/") def is_memory_path(file_path: Optional[str]) -> bool: """True if ``file_path`` lives inside an auto-memory directory.""" return ( bool(file_path) and _MEMORY_PATH_RE.search(_normalize_sep(file_path)) is not None ) # -- Markdown-file detection -------------------------------------------------- # A file whose own format is Markdown. Read/Write of such a file renders the # body as Markdown rather than Pygments-highlighted source (issue #232). # Auto-memory files (always ``.md``) are a subset: ``is_memory_path`` ⊂ # ``is_markdown_path``. The memory specialization that survives is the 🧠 title # (and memory's relative-link resolution + always-Markdown body); for any other # ``.md`` the generalization keys on this predicate instead. _MARKDOWN_EXTS = (".md", ".markdown") def is_markdown_path(file_path: Optional[str]) -> bool: """True if ``file_path`` names a Markdown file (``.md`` / ``.markdown``).""" return bool(file_path) and _normalize_sep(file_path).lower().endswith( _MARKDOWN_EXTS ) def is_memory_tool(tool_name: Optional[str], file_path: Optional[str]) -> bool: """True if a tool call/result is an auto-memory interaction. A file tool (Read/Write/Edit) acting on a path inside a ``memory/`` directory. Used to tag both the ``tool_use`` call and its paired ``tool_result`` with the ``memory`` CSS modifier. """ return tool_name in _MEMORY_TOOL_NAMES and is_memory_path(file_path) def memory_short_path(file_path: str) -> str: """Return the path relative to the ``memory/`` directory. e.g. ``…/memory/MEMORY.md`` -> ``MEMORY.md``, ``…/memory/sub/topic.md`` -> ``sub/topic.md``. Separators are normalized to ``/`` (so Windows paths render consistently). Falls back to the normalized path if the marker isn't found (shouldn't happen for memory paths). """ normalized = _normalize_sep(file_path) parts = _MEMORY_PATH_RE.split(normalized, maxsplit=1) return parts[-1] if len(parts) > 1 else normalized # Relative href/src in rendered memory-file markdown (e.g. a MEMORY.md link to # a sibling topic file: ``feedback_x.md``). Absolute URLs (scheme://), in-page # anchors (#…), root-absolute (/…) and protocol-relative (//…) are left alone. _RELATIVE_LINK_ATTR_RE = re.compile(r'((?:href|src)=")([^"#/][^":]*)(")', re.IGNORECASE) def resolve_memory_body_links(html: str, file_path: str) -> str: """Anchor relative links in rendered memory-file markdown to the memory dir. Memory files live at ``…//memory/`` and link to sibling memory files with bare relative paths. When that markdown is rendered into a transcript page that lives one level up (``…//``), the browser resolves those links against the page's directory and drops the ``memory/`` segment (#192). Rewriting them to ``file://`` URLs under the memory file's own directory fixes the target and is independent of where the page is written. """ dir_path = _normalize_sep(file_path).rsplit("/", 1)[0] # file:// URLs need a leading slash before the path. POSIX paths already # start with "/" (-> file:///home/...); a Windows drive-letter path # (C:/Users/...) does not, so add one (-> file:///C:/Users/...). if not dir_path.startswith("/"): dir_path = "/" + dir_path base = "file://" + dir_path def _rewrite(m: "re.Match[str]") -> str: target = m.group(2) # Skip anything with a scheme (foo://…, mailto:) — the ``[^":]`` guard # in the pattern already excludes ``:`` so only truly relative targets # reach here, but keep the wrapper resilient. return f"{m.group(1)}{base}/{target}{m.group(3)}" return _RELATIVE_LINK_ATTR_RE.sub(_rewrite, html) # -- CSS Class Registry ------------------------------------------------------- # Maps content types to their CSS classes. # The first class is typically the base type (user, assistant, system, etc.), # followed by any static modifiers. CSS_CLASS_REGISTRY: dict[type[MessageContent], list[str]] = { # System message types SystemMessage: ["system"], # level added dynamically HookSummaryMessage: ["system", "system-hook"], HookAttachmentMessage: ["system", "system-hook-attachment"], AwaySummaryMessage: ["system", "system-away-summary"], # User message types UserTextMessage: ["user"], UserSteeringMessage: ["user", "steering"], SlashCommandMessage: ["user", "slash-command"], UserSlashCommandMessage: ["user", "slash-command"], UserMemoryMessage: ["user"], CompactedSummaryMessage: ["user", "compacted"], CommandOutputMessage: ["user", "command-output"], TeammateMessage: ["user", "teammate"], # Async-agent entry (issue #90). Carries the # `user` class so the runtime "User" filter toggle keeps it visible # — without it, the rendered
would only carry the # `task-notification` modifier, which matches no filter toolbar # type and would be permanently flagged `filtered-hidden`. The # modifier itself uses hyphens to match the rest of this registry # (`system-hook`, `slash-command`, `command-output`, `bash-input`, # `bash-output`, `task-notification-card`/`-backlink` in CSS) — the # underlying message-type identifier `"task_notification"` (set on # `TaskNotificationMessage.message_type`) keeps its underscore. TaskNotificationMessage: ["user", "task-notification"], # Assistant message types AssistantTextMessage: ["assistant"], # Tool message types ToolUseMessage: ["tool_use"], ToolResultMessage: ["tool_result"], # error added dynamically # Other message types ThinkingMessage: ["thinking"], SessionHeaderMessage: ["session_header"], BashInputMessage: ["bash-input"], BashOutputMessage: ["bash-output"], UnknownMessage: ["unknown"], # Dynamic-workflow synthetic nodes (#174 PR3): phase/agent cards spliced # under a Workflow tool_use. The `tool_use` class keeps them visible under # the runtime "Tool Use" filter toggle (the splice lives inside a tool_use # subtree); the `workflow_phase`/`workflow_agent` modifiers drive styling # and the timeline's dedicated detection branch. WorkflowPhaseMessage: ["tool_use", "workflow_phase"], WorkflowAgentMessage: ["tool_use", "workflow_agent"], } def _get_css_classes_from_content(content: MessageContent) -> list[str]: """Get CSS classes from content type using the registry. Walks the MRO to find a matching registry entry, then adds any dynamic modifiers based on content attributes. """ for cls in type(content).__mro__: if not issubclass(cls, MessageContent): continue if classes := CSS_CLASS_REGISTRY.get(cls): result = list(classes) # Dynamic modifiers based on content attributes if isinstance(content, SystemMessage): result.append(f"system-{content.level}") elif isinstance(content, ToolResultMessage): if content.is_error: result.append("error") # Memory interaction (recalled memory): tag the result so the # filter/timeline hide or isolate the whole call+result pair. if is_memory_tool(content.tool_name, content.file_path): result.append("memory") elif isinstance(content, ToolUseMessage): # Memory interaction (writing/recalling memory): a Read/Write/ # Edit on a path inside the project's memory/ directory (#192). file_path = getattr(content.input, "file_path", None) if is_memory_tool(content.tool_name, file_path): result.append("memory") return result return [] # -- CSS and Message Display -------------------------------------------------- def css_class_from_message(msg: "TemplateMessage") -> str: """Generate CSS class string from message type and modifiers. Uses CSS_CLASS_REGISTRY to derive classes from content type, with fallback to msg.type for messages without registered content. The order of classes follows the original pattern: 1. Message type (from content type or msg.type fallback) 2. Content-derived modifiers (e.g., slash-command, compacted, error) 3. Cross-cutting modifier flags: steering, sidechain Args: msg: The template message to generate CSS classes for Returns: Space-separated CSS class string (e.g., "user slash-command sidechain") """ # Get base classes and content-derived modifiers from content type if msg.content: parts = _get_css_classes_from_content(msg.content) if not parts: parts = [msg.type] # Fallback if content type not in registry else: parts = [msg.type] # Cross-cutting modifier flags (not derivable from content type alone) if msg.is_sidechain: parts.append("sidechain") # Agent-nesting depth (#213 visual layer). A message at depth d (d >= 1) # carries: # - ``agent-depth-{d}`` exact depth (data / tests / timeline) # - ``agent-ring-{1..5}`` 5-colour cycle bucket for the group line # - ``agent-deep`` d >= 6 → compress the indent step # All three are derivable from d, but CSS can't compute them, so we emit # the classes. Depth 0 (trunk / non-agent) adds nothing. if msg.agent_depth >= 1: d = msg.agent_depth parts.append(f"agent-depth-{d}") parts.append(f"agent-ring-{((d - 1) % 5) + 1}") if d >= 6: parts.append("agent-deep") return " ".join(parts) def is_session_header(msg: "TemplateMessage") -> bool: """Check if message is a session header. Args: msg: The template message to check Returns: True if message content is a SessionHeaderMessage """ return isinstance(msg.content, SessionHeaderMessage) def get_message_emoji(msg: "TemplateMessage") -> str: """Return appropriate emoji for message type. Args: msg: The template message to get emoji for Returns: Emoji string for the message type, or empty string if no emoji """ msg_type = msg.type if msg_type == "session_header": return "📋" elif msg_type == "user": # Command output has no emoji (neutral - can be from built-in or user command) if isinstance(msg.content, CommandOutputMessage): return "" return "🤷" elif msg_type == "bash-input": return "💻" elif msg_type == "assistant": if msg.is_sidechain: return "🔗" return "🤖" elif msg_type == "system": # Recap entries (away_summary) carry the same `system` message_type # as generic system info but are content, not noise. Give them a # distinct memo glyph so they read at-a-glance in scrolling lists, # and drop the matching label from the body chrome (one icon, one # title — see format_away_summary_content). if isinstance(msg.content, AwaySummaryMessage): return "📝" # Hook entries (both the typed attachment from #128 and the # legacy stop_hook_summary system entry) get a hook glyph at # the title level so the body summary doesn't have to repeat # "🪝 Hook output" as a redundant subheader. if isinstance(msg.content, (HookAttachmentMessage, HookSummaryMessage)): return "🪝" return "⚙️" elif msg_type == "tool_use": return "🛠️" elif msg_type == "tool_result": if isinstance(msg.content, ToolResultMessage) and msg.content.is_error: return "🚨" return "🧰" elif msg_type == "thinking": return "💭" elif msg_type == "image": return "🖼️" elif msg_type == "workflow_phase": return "🧩" elif msg_type == "workflow_agent": return "🤖" return "" # -- HTML Utilities ----------------------------------------------------------- def escape_html(text: str) -> str: """Escape HTML special characters in text. Also normalizes line endings (CRLF -> LF) to prevent double spacing in
 blocks.
    """
    # Normalize CRLF to LF to prevent double line breaks in HTML
    normalized = text.replace("\r\n", "\n").replace("\r", "\n")
    return html.escape(normalized)


def _render_code_with_pygments(renderer: Any, node: Any, context: Any) -> str:
    """wenmode ``code`` handler: Pygments when the fence names a language.

    Falls back to wenmode's own ``
`` rendering when there is
    no language hint, so plain fences stay as they were.
    """
    if not node.lang:
        return str(_default_render_code(renderer, node, context))
    from pygments import highlight
    from pygments.lexer import Lexer
    from pygments.lexers import get_lexer_by_name, TextLexer
    from pygments.formatter import Formatter
    from pygments.formatters import HtmlFormatter
    from pygments.util import ClassNotFound

    lang = node.lang.split()[0]
    lexer: Lexer = TextLexer()
    try:
        lexer = get_lexer_by_name(lang, stripall=False)
    except ClassNotFound:
        pass  # Already have default
    formatter: Formatter = HtmlFormatter(
        linenos=False,  # No line numbers in markdown code blocks
        cssclass="highlight",
        wrapcode=True,
    )
    with timing_stat("_pygments_timings"):
        return str(highlight(node.value, lexer, formatter))


def _render_html_escaped(renderer: Any, node: Any, context: Any) -> str:
    """wenmode ``html`` handler: raw HTML is shown as text, never live.

    Transcript content is untrusted from every source (#245 XSS), so
    every raw-HTML node is entity-escaped. A block-level one (marked by
    ``markdown_plugins.BlockHtmlMarker``) is wrapped in a paragraph so
    it lays out like the text it now is.
    """
    if node.data and node.data.get("block"):
        return "

" + str(renderer.escape_html(node.value.strip())) + "

\n" return str(renderer.escape_html(node.value)) # Schemes a link or image in a transcript must never carry: mistune's # list, plus OS protocol handlers that a click would hand to the desktop # (``search-ms:`` and ``ms-appinstaller:`` have real abuse history) and # the browser-internal ones. Anything else — ``cci:`` editor links, # ``vercel.json:20`` file references, relative paths — stays a working # link: transcripts are full of targets no allowlist would anticipate, # and none of those can execute script. _HARMFUL_URL_SCHEMES = frozenset( { "javascript", "vbscript", "file", "data", "feed", "jar", "livescript", "mocha", "ms-its", "mk", "res", "view-source", # beyond mistune's list "search-ms", "ms-appinstaller", "ms-msdt", "intent", "blob", "filesystem", "about", } ) _GOOD_DATA_PREFIXES = ( "data:image/gif;", "data:image/png;", "data:image/jpeg;", "data:image/webp;", ) class _TranscriptHTMLRenderer(HTMLRenderer): """wenmode's HTML renderer with a denylist URL policy. wenmode's default allows only ``http``, ``https``, ``irc``, ``ircs``, ``mailto`` and ``tel``; every other scheme loses its ``href``. The XSS contract (#245) only needs the dangerous schemes gone, so this keeps mistune's denylist (``file:`` included) and lets the rest through unchanged. Attribute sanitising and raw-HTML escaping are wenmode's defaults, untouched. """ def sanitize_url(self, value: str) -> Optional[str]: try: normalized = _normalize_url_for_scheme_check(value) scheme = urlsplit(normalized).scheme.lower() except ValueError: return None if scheme in _HARMFUL_URL_SCHEMES and not normalized.lower().startswith( _GOOD_DATA_PREFIXES ): return None return value class _TranscriptHtmlHandlers: """wenmode plugin installing the transcript HTML handlers above.""" def setup(self, wen: Wenmode, /) -> None: wen.register_renderer_handlers( { "html": { "code": _render_code_with_pygments, "html": _render_html_escaped, } } ) def _build_transcript_markdown(document: bool = False) -> Wenmode: """The wenmode pipeline both content renderers use. ``escape=True`` renders raw HTML embedded in the source text (```` into the field" — so rendering it unescaped lets that payload execute when the transcript HTML is opened. The Markdown output path neutralises raw HTML the same way (see ``markdown/renderer.py::_protect_html_tags``). Escaping does not affect Markdown formatting, Pygments output, SHA links or code fences — only raw HTML in the body. The SHA → commit-URL linkifier (issue #156) reads the per-render cwd from a ContextVar, so the cached singleton keeps working unchanged across transcripts from different repos. ``document`` adds wenmode's ``frontmatter`` plugin, for a whole body (a message, a Markdown file, a plan) that may open with YAML front matter; see ``render_markdown_document``. """ from ..markdown_plugins import transcript_plugins, transcript_rules from ..git_remote import resolve_sha_for_current_render plugins: list[Any] = [*transcript_plugins(), _TranscriptHtmlHandlers()] if document: from wenmode.plugins import frontmatter from ..frontmatter import load_frontmatter plugins.append(frontmatter.configure(load=load_frontmatter)) return Wenmode( transcript_rules(resolve_sha_for_current_render), # escape: untrusted content (XSS, #245). soft_break: a newline in # prose is a line break (checklists in assistant messages carry no # two-trailing-spaces markup) — mistune's hard_wrap. renderer=_TranscriptHTMLRenderer(escape=True, soft_break="br"), plugins=plugins, ) @functools.lru_cache(maxsize=1) def _get_markdown_renderer() -> Wenmode: """Cached renderer for assistant/tool/web-authored content. Assistant prose, Task/WebSearch/WebFetch results, plans, system messages, teammate bodies. See ``_build_transcript_markdown`` for the escaping contract. """ return _build_transcript_markdown() @functools.lru_cache(maxsize=1) def _get_document_markdown_renderer() -> Wenmode: """Cached renderer for whole bodies that open with YAML front matter. The transcript pipeline plus wenmode's ``frontmatter`` plugin. Only ``render_markdown_document`` uses it: inline renders, previews and the Markdown inside a params-table value never parse front matter. """ return _build_transcript_markdown(document=True) def _frontmatter_html(value: Any) -> str: """HTML for loaded front matter: a params table, or a YAML code block.""" from ..frontmatter import RawFrontmatter, yaml_fence from .tool_formatters import render_params_table if isinstance(value, RawFrontmatter): body = render_markdown(yaml_fence(value.source)) else: body = render_params_table(value) return f"
{body}
" def render_markdown_document(text: str, user: bool = False) -> str: """Render a whole Markdown body, showing YAML front matter as data. A body that opens with front matter (``frontmatter.is_frontmatter_start``) is parsed by the document renderer, whose ``frontmatter`` plugin takes the block out of the body; it renders as a params table above the rest (a YAML code block when it is not a mapping). Any other body renders exactly as ``render_markdown`` (``render_user_markdown`` when ``user``). ``user`` does not apply to the front-matter path: the document renderer is the same escaping pipeline as both, so one memo entry serves either. """ from ..frontmatter import is_frontmatter_start from ..git_remote import current_render_repo_cwd if not is_frontmatter_start(text): return _render_markdown_memoized(text, escaping_user_renderer=user) memo_key = ("document", current_render_repo_cwd(), text) cached = markdown_cache.get(memo_key) if cached is not None: return cached with timing_stat("_markdown_timings"): renderer = _get_document_markdown_renderer() root = renderer.parse(text) body = renderer.render_node(root) data = root.data or {} if "frontmatter" in data: body = _frontmatter_html(data["frontmatter"]) + body markdown_cache.put(memo_key, body) return body def _render_markdown_memoized(text: str, escaping_user_renderer: bool) -> str: """Render ``text`` through one of the two wenmode singletons, memoized. Markdown output is a pure function of the text *and* the active render repo cwd: the SHA-linkifier plugin resolves commit hashes against ``git_remote._render_repo_cwd``, so the same ``5baac35`` links to different repositories in different transcripts. The cwd is therefore part of the key — without it a long-lived host (``serve``, the TUI) would serve one project's commit links inside another's page. ``escaping_user_renderer`` selects between the two renderer singletons (they differ only in call site convention today, but they are distinct objects and must not share cache entries). """ from ..git_remote import current_render_repo_cwd memo_key = (escaping_user_renderer, current_render_repo_cwd(), text) cached = markdown_cache.get(memo_key) if cached is not None: return cached # Track markdown rendering time if enabled with timing_stat("_markdown_timings"): renderer = ( _get_user_markdown_renderer() if escaping_user_renderer else _get_markdown_renderer() ) rendered = renderer.render(text) markdown_cache.put(memo_key, rendered) return rendered def render_markdown(text: str) -> str: """Convert markdown text to HTML using wenmode with Pygments syntax highlighting.""" return _render_markdown_memoized(text, escaping_user_renderer=False) _INLINE_PARA_BOUNDARY_RE = re.compile(r"

\s*

") def render_markdown_inline(text: str) -> str: """Render markdown collapsed to inline-safe HTML. Question/option text from AskUserQuestion is LLM-authored, so it carries real Markdown (inline code, emphasis, links). Callers embed the result inside inline wrappers (````, ``

  • ``), so this unwraps the enclosing ``

    `` and folds any paragraph boundaries into ``
    `` — a multi-paragraph free-form reply must not emit block ``

    `` inside an inline element. Uses the ``escape=True`` renderer so raw HTML in the text (``