Rendering Architecture¶
See application_model.md for the system overview.
This document describes how Claude Code transcript data flows from raw JSONL entries to final output (HTML, Markdown, JSON). The architecture separates concerns into distinct layers:
- Parsing Layer - Raw JSONL to typed transcript entries
- Factory Layer - Transcript entries to
MessageContentmodels - Rendering Layer - Format-neutral tree building and relationship processing
- Output Layer - Format-specific rendering (HTML, Markdown, JSON)
1. Data Flow Overview¶
JSONL File
↓ (parser.py)
list[TranscriptEntry]
↓ (factories/)
list[TemplateMessage] with MessageContent ← factory-layer
normalisation seam
(raw → display-polished)
↓ (renderer.py: generate_template_messages)
Tree of TemplateMessage (roots with children)
+ RenderingContext (message registry)
+ Session navigation data
↓ (html/renderer.py | markdown/renderer.py | json/renderer.py)
Final output (HTML, Markdown, or JSON)
The factory-layer seam matters: any cleanup that should appear
in every output format (slash-command normalisation, command-args
hardening, teammate session-color enrichment, etc.) lives at factory
time, in the typed MessageContent models. The three renderers are
pure consumers of the polished tree — they never re-implement
display polish per format. As a corollary, when a new output format
is added (JSON shipped this way in PR #36), it inherits all polish
for free as long as it consumes generate_template_messages'
output.
Key cardinality rules:
- Each transcript entry has a uuid, but a single entry's list[ContentItem] may be chunked and produce multiple MessageContent objects (e.g., tool_use items are split into separate messages)
- Each MessageContent gets exactly one TemplateMessage wrapper
- The message_index (assigned during registration) uniquely identifies a TemplateMessage within a render
2. Naming Conventions¶
The codebase uses consistent suffixes to distinguish layers:
| Suffix | Layer | Examples |
|---|---|---|
*Content |
ContentItem (JSONL parsing) | TextContent, ToolUseContent, ThinkingContent, ImageContent |
*Input |
Tool input models | BashInput, ReadInput, TaskInput |
*Output |
Tool output models | ReadOutput, EditOutput, TaskOutput |
*Message |
MessageContent (rendering) | UserTextMessage, ToolUseMessage, AssistantTextMessage |
*Model |
Pydantic JSONL models | UserMessageModel, AssistantMessageModel |
Key distinction:
- ToolUseContent is the raw JSONL content item
- ToolUseMessage is the render-time wrapper containing a typed ToolInput
- BashInput is a specific tool input model parsed from ToolUseContent.input
3. The Factory Layer¶
Factories (factories/) transform raw transcript data into typed MessageContent models. Each factory focuses on a specific message category:
| Factory | Creates | Key Function |
|---|---|---|
| meta_factory.py | MessageMeta |
create_meta(entry) |
| user_factory.py | User message types | create_user_message(meta, content_list, ...) |
| assistant_factory.py | Assistant messages | create_assistant_message(meta, items) |
| tool_factory.py | Tool use/result | create_tool_use_message(meta, item, ...) |
| system_factory.py | System messages | create_system_message(meta, ...) |
Factory Pattern¶
All factory functions require MessageMeta as the first parameter:
def create_user_message(
meta: MessageMeta,
content_list: list[ContentItem],
...
) -> UserTextMessage | UserSlashCommandMessage | ...
This ensures every MessageContent has valid metadata accessible via content.meta.
Tool Input Parsing¶
Tool inputs are parsed into typed models in tool_factory.py:create_tool_input():
TOOL_INPUT_MODELS: dict[str, type[ToolInput]] = {
"Bash": BashInput,
"Read": ReadInput,
"Write": WriteInput,
...
}
def create_tool_input(tool_use: ToolUseContent) -> ToolInput:
model_class = TOOL_INPUT_MODELS.get(tool_use.name)
if model_class:
return model_class.model_validate(tool_use.input)
return tool_use # Fallback to raw ToolUseContent
Tool Output Parsing¶
Tool outputs use a different approach than inputs. While inputs are parsed via Pydantic model_validate(), outputs are extracted from text using regex patterns since tool results arrive as text content:
TOOL_OUTPUT_PARSERS: dict[str, ToolOutputParser] = {
"Read": parse_read_output,
"Edit": parse_edit_output,
"Write": parse_write_output,
"Bash": parse_bash_output,
"Task": parse_task_output,
...
}
def create_tool_output(tool_name, tool_result, file_path) -> ToolOutput:
if parser := TOOL_OUTPUT_PARSERS.get(tool_name):
if parsed := parser(tool_result, file_path):
return parsed
return tool_result # Fallback to raw ToolResultContent
Each parser extracts text from ToolResultContent and parses patterns like:
- cat -n format: " 123→content" for file content with line numbers
- Structured prefixes: "The file ... has been updated." for edit results
4. The TemplateMessage Wrapper¶
TemplateMessage (in renderer.py) wraps MessageContent with render-time state:
MessageContent (pure transcript data):
- meta: MessageMeta - timestamp, session_id, uuid, is_sidechain, etc.
- message_type property - type identifier ("user", "assistant", etc.)
- has_markdown property - whether content contains markdown
TemplateMessage (render-time wrapper):
- content: MessageContent - the wrapped content
- meta property - delegates to content.meta (message.meta is message.content.meta)
- message_index: Optional[int] - unique index in RenderingContext registry
- message_id property - formatted as "d-{message_index}" for HTML element IDs
Relationship fields (populated by processing phases, using message_index for references):
- Pairing: pair_first, pair_last, pair_duration, is_first_in_pair, is_last_in_pair
- Hierarchy: ancestry (list of parent message_index values), children
- Fold/unfold: immediate_children_count, total_descendants_count
5. Format-Neutral Processing Pipeline¶
The core rendering pipeline is in generate_template_messages(). It returns:
- Tree of TemplateMessage - Session headers as roots with nested children
- Session navigation data - For table of contents
- RenderingContext - Message registry for
message_indexlookups
Doc links into
renderer.pyuse function/class names rather than line numbers — the file is large and churns, so line anchors drift. Search by symbol name.
Processing Phases¶
The pipeline runs many strictly-ordered in-place passes over a flat
list[TemplateMessage]. The four below are the conceptual backbone;
the remaining passes (listed in the addendum) refine ordering,
hierarchy, and cross-links on top of them.
Phase 1: Message Loop¶
_render_messages() creates TemplateMessage wrappers for each transcript entry. The loop handles:
- Inserting session headers at session boundaries (trunk + branch)
- Creating MessageContent via factories
- Registering messages in RenderingContext
Phase 2: Pairing¶
_identify_message_pairs() marks related messages: - Adjacent pairs: thinking+assistant, bash-input+output, system+slash-command - Indexed pairs: tool_use+tool_result (by tool_use_id)
After identification, _reorder_paired_messages() moves pair_last messages adjacent to their pair_first.
Phase 3: Hierarchy¶
_build_message_hierarchy() assigns ancestry based on message relationships:
- User messages at level 1
- Assistant/system at level 2
- Tool use/result at level 3
- Sidechain messages at level 4+
Phase 4: Tree Building¶
_build_message_tree() populates children lists from ancestry:
Session Header (root)
└─ User message
└─ Assistant message
└─ Tool use
└─ Tool result
└─ Sidechain assistant (Task result children)
Full pass ordering (addendum)¶
The four backbone phases above run within a longer ordered sequence.
In code order, generate_template_messages:
- Setup — filters warmup sessions, then prepares session metadata:
prepare_session_summaries+prepare_session_ai_titles(merged),prepare_session_team_names, and_extract_session_hierarchy. - Pre-render filtering —
_filter_messages(structural), then_filter_by_detail(entry-level, only below FULL). - Collect + render —
_collect_session_info, then_render_messages(Phase 1: wrappers, session headers, registration), then_pair_skill_tool_uses(which calls_reindex_filtered_contextinternally). - Junction linking — junction forward-link population on fork
points (
_link_junction_forwards). Branch-header previews are computed in step 3 by_build_branch_headerscanning the branch's DAG-line uuids; there's no separate back-fill pass. - Post-render detail filter —
_filter_template_by_detailfollowed immediately by_reindex_filtered_context(only below FULL). - Nav + structure —
prepare_session_navigation, then_reorder_session_template_messages,_identify_message_pairs(Phase 2),_reorder_paired_messages,_relocate_subagent_blocks,_build_message_hierarchy(Phase 3),_mark_messages_with_children,_build_message_tree(Phase 4),_cleanup_sidechain_duplicates. - Trailing metadata / link passes —
_populate_teammate_colors,_populate_task_metadata,_link_async_notifications,_link_tool_use_notifications,_link_cron_jobs_by_id,_link_task_id_consumers.
The code in generate_template_messages is the authoritative ordering.
6. RenderingContext¶
RenderingContext (in renderer.py) holds per-render state:
@dataclass
class RenderingContext:
messages: list[TemplateMessage] # All messages by index
tool_use_context: dict[str, ToolUseContent] # For result→use lookup
session_first_message: dict[str, int] # Session header indices
def register(self, message: TemplateMessage) -> int:
"""Assign message_index and add to registry."""
def get(self, message_index: int) -> Optional[TemplateMessage]:
"""Lookup by index."""
This enables parallel-safe rendering where each render operation gets its own context.
7. The Renderer Class Hierarchy¶
The base Renderer class (in renderer.py) defines the method-based dispatcher pattern. Subclasses implement format-specific rendering.
Dispatch Mechanism¶
The dispatcher finds methods by content type name and passes both the typed object and the TemplateMessage:
def _dispatch_format(self, obj: Any, message: TemplateMessage) -> str:
"""Dispatch to format_{ClassName}(obj, message) method."""
for cls in type(obj).__mro__:
if cls is object:
break
if method := getattr(self, f"format_{cls.__name__}", None):
return method(obj, message)
return ""
For example, ToolUseMessage with BashInput:
1. format_content(message) calls _dispatch_format(message.content, message)
2. Finds format_ToolUseMessage(content, message) which calls _dispatch_format(content.input, message)
3. Finds format_BashInput(input, message) for the specific tool
Consistent (obj, message) Signature¶
All format_* and title_* methods receive both parameters:
def format_BashInput(self, input: BashInput, _: TemplateMessage) -> str:
return format_bash_input(input)
def title_BashInput(self, input: BashInput, message: TemplateMessage) -> str:
return self._tool_title(message, "💻", input.description)
This design gives handlers access to:
- The typed object (input: BashInput) for type-safe field access without casting
- The full context (message: TemplateMessage) for paired message lookups, ancestry, etc.
Methods that don't need the message parameter use _ or _message (for LSP compliance in overrides).
Title Dispatch¶
Similar pattern for titles via title_{ClassName} methods:
def title_ToolUseMessage(self, content: ToolUseMessage, message: TemplateMessage) -> str:
if title := self._dispatch_title(content.input, message):
return title
return content.tool_name # Default fallback
Subclass Implementations¶
HtmlRenderer (html/renderer.py):
- Implements format_* methods by delegating to formatter functions
- _flatten_preorder() traverses tree, formats content, builds flat list for template
- Generates HTML via Jinja2 templates
MarkdownRenderer (markdown/renderer.py):
- Implements format_* methods inline
- Writes directly to file/string without templates
- Simpler structure suited to plain text output
JsonRenderer (json/renderer.py):
- Doesn't implement format_* per content type — instead serialises
the entire TemplateMessage subtree via dataclasses.asdict plus
a small _json_default shim for the Pydantic models embedded in
tool inputs/outputs (and for Enum/Path).
- Calls title_content(msg) to attach a per-node title that mirrors
what HTML/Markdown surface — the only place dispatcher methods are
reused.
- Output is a single JSON document per session (or per combined
transcript / projects index) with the message tree nested directly
under each node's children array. See application_model.md
§ 2.5 for the payload shape
and inheritance from the factory-layer normalisation seam.
8. HTML Formatter Organization¶
HTML formatters are split by message category:
| Module | Scope | Key Functions |
|---|---|---|
| user_formatters.py | User messages | format_user_text_model_content(), format_bash_input_content() |
| assistant_formatters.py | Assistant/thinking | format_assistant_text_content(), format_thinking_content() |
| system_formatters.py | System messages | format_system_content(), format_session_header_content() |
| tool_formatters.py | Tool inputs/outputs | format_bash_input(), format_read_output(), etc. |
| utils.py | Shared utilities | render_markdown(), escape_html(), CSS_CLASS_REGISTRY |
9. CSS Class Derivation¶
CSS classes are derived from content types using CSS_CLASS_REGISTRY in html/utils.py:
CSS_CLASS_REGISTRY: dict[type[MessageContent], list[str]] = {
SystemMessage: ["system"], # level added dynamically
UserTextMessage: ["user"],
UserSteeringMessage: ["user", "steering"],
ToolUseMessage: ["tool_use"],
ToolResultMessage: ["tool_result"], # error added dynamically
...
}
The function css_class_from_message() walks the content type's MRO to find matching classes, then adds dynamic modifiers (sidechain, error level).
See css-classes.md for the complete reference.
10. Key Architectural Decisions¶
Content as Source of Truth¶
MessageContent.meta holds all identity data. TemplateMessage.meta is the same object:
Note that meta.uuid is the original transcript entry's UUID. Since a single entry may be split into multiple MessageContent objects (e.g., multiple tool_use items), several messages can share the same UUID. Use message_index for unique identification within a render.
Tree-First Architecture¶
generate_template_messages() returns tree roots. Flattening for template rendering is an explicit step in HtmlRenderer._flatten_preorder(). This keeps the tree authoritative while supporting existing flat-list templates.
Separation of Concerns¶
- models.py: Pure data structures, no rendering logic
- factories/: Data transformation, no I/O. The
normalisation seam — display polish for all output formats
lives here, not in renderers (e.g.
simplify_command_tagslifting bare<command-name>X</command-name>to/X, with the same fix applied to bothsimplify_command_tagsandcreate_slash_command_messageso HTML/Markdown/JSON observe a single shape). - renderer.py: Format-neutral processing (pairing, hierarchy, tree)
- html/, markdown/, json/: Format-specific output generation, consuming the polished tree without re-implementing display rules.
Related Documentation¶
- messages.md - Complete message type reference
- css-classes.md - CSS class combinations and rules
- message-hierarchy.md - Fold/unfold state machine
- dag.md - DAG-based message architecture (replaces timestamp-based ordering)