Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion claude_code_log/factories/meta_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
that is shared across all message types.
"""

from ..models import BaseTranscriptEntry, MessageMeta
from ..models import AssistantTranscriptEntry, BaseTranscriptEntry, MessageMeta


def create_meta(transcript: BaseTranscriptEntry) -> MessageMeta:
Expand Down Expand Up @@ -33,4 +33,10 @@ def create_meta(transcript: BaseTranscriptEntry) -> MessageMeta:
cwd=transcript.cwd,
git_branch=transcript.gitBranch,
team_name=getattr(transcript, "teamName", None),
# The model id lives on the assistant message body, not the base entry.
model=(
transcript.message.model
if isinstance(transcript, AssistantTranscriptEntry)
else None
),
)
21 changes: 20 additions & 1 deletion claude_code_log/html/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1032,7 +1032,11 @@ def title_TaskInput(self, input: TaskInput, message: TemplateMessage) -> str:
if input.run_in_background
else ""
)
suffix = async_hint + self._agent_depth_badge(message)
suffix = (
async_hint
+ self._agent_depth_badge(message)
+ self._agent_model_badge(message)
)
if input.description and input.subagent_type:
escaped_desc = escape_html(input.description)
return (
Expand Down Expand Up @@ -1069,6 +1073,21 @@ def _agent_depth_badge(self, message: TemplateMessage) -> str:
f"Depth {spawned_depth}</span>"
)

def _agent_model_badge(self, message: TemplateMessage) -> str:
"""Model badge for a spawn card (#246): the model the spawned
sub-agent ran on, stamped on the always-visible Task/Agent launch
card by ``_surface_agent_models`` (``display_model``). Shown for
every spawn depth — unlike the depth badge, it isn't suppressed at
depth 1, since the top-level Explore/Task spawn is the common case a
reader wants to see. Empty when no model was resolved (e.g. an
interrupted spawn whose sub-agent produced no assistant turn)."""
if not message.display_model:
return ""
return (
f" <span class='message-model' title='Model this sub-agent ran on'>"
f"{escape_html(message.display_model)}</span>"
)

def _async_id_suffix(
self,
minted_id: Optional[str],
Expand Down
7 changes: 7 additions & 0 deletions claude_code_log/html/templates/components/message_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,13 @@
color: #888;
}

/* Model id surfaced on sub-agent message headers (issue #246) */
.message-model {
font-size: 0.75em;
font-family: var(--font-mono, 'SFMono-Regular', Consolas, monospace);
color: var(--text-secondary, #888);
}

/* Paired message styling */
.message.pair_first {
margin-bottom: 0;
Expand Down
2 changes: 1 addition & 1 deletion claude_code_log/html/templates/transcript.html
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ <h3>🔍 Search & Filter</h3>
card itself, not the whole subtree (issue #174 nested-DOM follow-up). #}
<div class='message-node'>
<div class='message session-header{% if message.is_branch_header %} branch-header{% endif %}' data-message-id='{{ message.message_id }}' data-session-id='{{ message.session_id }}' id='msg-{{ message.message_id }}'{% if message.branch_depth %} style='margin-left: {{ message.branch_depth * 2 }}em'{% endif %}>
<div class='header'>{% if message.is_branch_header %}&#x21b3; {% else %}Session: {% endif %}{{ html_content|safe }}</div>
<div class='header'>{% if message.is_branch_header %}&#x21b3; {% else %}Session: {% endif %}{{ html_content|safe }}{% if message.display_model and not message.is_branch_header %} <span class='message-model' title='Main agent model'>{{ message.display_model }}</span>{% endif %}</div>
{% if message.has_children %}
<div class='fold-bar' data-message-id='{{ message.message_id }}' data-border-color='session-header'>
{% if message.immediate_children_count == message.total_descendants_count %}
Expand Down
28 changes: 22 additions & 6 deletions claude_code_log/markdown/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,9 +706,14 @@ def format_SessionHeaderMessage(
return f'<a id="{_session_anchor(content)}"></a>'

def title_SessionHeaderMessage(
self, content: SessionHeaderMessage, _: TemplateMessage
self, content: SessionHeaderMessage, message: TemplateMessage
) -> str:
"""Title → '📋 Session `abc12345`: summary — Team: `t`' (or '🌿 Branch …').
"""Title → '📋 Session `abc12345`: summary — Model: `m` — Team: `t`'
(or '🌿 Branch …').

The ``— Model: `…``` segment (issue #246) carries the trunk/main
model and appears, before any Team suffix, only on non-branch
session headers that have a ``display_model`` set.

Branch session headers surface the ``Branch • <uuid8> • <preview>``
shape that the renderer's ``_branch_label`` helper composes for
Expand Down Expand Up @@ -739,6 +744,9 @@ def title_SessionHeaderMessage(
title = f"📋 Session `{session_short}`: {content.summary}"
else:
title = f"📋 Session `{session_short}`"
# Main agent model, surfaced once on the session header (issue #246).
if message.display_model:
title = f"{title} — Model: {_inline_code(message.display_model)}"
if content.team_name:
# Boundary hygiene: a malformed transcript could in theory
# carry a backtick in teamName. CommonMark code spans don't
Expand Down Expand Up @@ -1796,22 +1804,30 @@ def title_GrepInput(self, input: GrepInput, _: TemplateMessage) -> str:
base = f"🔎 Grep `{input.pattern}`"
return f"{base} in `{input.path}`" if input.path else base

def title_TaskInput(self, input: TaskInput, _: TemplateMessage) -> str:
"""Title → '🤖 Task (subagent): *description* [async #<id>]'.
def title_TaskInput(self, input: TaskInput, message: TemplateMessage) -> str:
"""Title → '🤖 Task (subagent): *description* [async #<id>] · `model`'.

``[async]`` muted hint when ``run_in_background=True``; once the
launch confirmation has been parsed, the minted ``#<agent_id>``
is appended (PR #158 follow-up, parallels the Bash spawn shape).
The trailing `` · `model` `` is the model the spawned sub-agent ran
on (issue #246), stamped on the spawn card by ``_surface_agent_models``.
"""
subagent = f" ({input.subagent_type})" if input.subagent_type else ""
async_hint = (
" " + self._async_id_hint(input.minted_agent_id)
if input.run_in_background
else ""
)
model_hint = (
f" · {_inline_code(message.display_model)}" if message.display_model else ""
)
if desc := input.description:
return f"🤖 Task{subagent}: *{self._escape_stars(desc)}*{async_hint}"
return f"🤖 Task{subagent}{async_hint}"
return (
f"🤖 Task{subagent}: *{self._escape_stars(desc)}*"
f"{async_hint}{model_hint}"
)
return f"🤖 Task{subagent}{async_hint}{model_hint}"

@staticmethod
def _async_id_hint(minted_id: Optional[str]) -> str:
Expand Down
5 changes: 5 additions & 0 deletions claude_code_log/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,11 @@ class MessageMeta:
cwd: str = ""
git_branch: Optional[str] = None
team_name: Optional[str] = None # Active team name (teammates feature)
# The model id the assistant entry ran on (AssistantMessageModel.model),
# e.g. ``claude-opus-4-8``. Only assistant entries carry it; None elsewhere.
# Surfaced on sub-agent message headers so a reader can see which model a
# spawned agent used (issue #246).
model: Optional[str] = None

@classmethod
def empty(cls, uuid: str = "") -> "MessageMeta":
Expand Down
83 changes: 83 additions & 0 deletions claude_code_log/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,14 @@ def __init__(
# spawn that produced no transcript at all (#213 visual layer).
self.spawns_collapsed_transcript: bool = False

# Model id to surface in this message's header (issue #246). Set by
# _surface_agent_models once per agent context — on the session header
# (the trunk/main model) and on the first message of each sub-agent
# (the model that sub-agent ran on) — so the id shows once rather than
# on every message. None elsewhere. The raw per-entry value lives on
# ``meta.model``; this is the render-once decision derived from it.
self.display_model: Optional[str] = None

# Per-render annotations populated by the HTML renderer's tree walk
# (HtmlRenderer._annotate_tree_for_render). The recursive template
# macro reads these instead of receiving a flat (msg, title, html,
Expand Down Expand Up @@ -891,6 +899,12 @@ def generate_template_messages(
with log_timing("Link async notifications", t_start):
_link_async_notifications(ctx, detail)

# Surface the model each agent ran on (issue #246). Runs here — after
# pairing and async linking — so a spawn card can reach its paired
# tool_result and its hoisted ``minted_agent_id`` to resolve the sub-agent.
with log_timing("Surface agent models", t_start):
_surface_agent_models(ctx)

# Link parsed dynamic-workflow runs to their Workflow tool_use by taskId
# (#174 PR3) so the formatter can render snapshot-first meta (and step 3
# can splice the phase/agent tree).
Expand Down Expand Up @@ -4844,6 +4858,75 @@ def _render_messages(
return ctx


def _surface_agent_models(ctx: RenderingContext) -> None:
"""Mark which messages should display their model id (issue #246).

Surfaced once per agent context rather than on every message, and on a
node that stays visible when the agent's transcript is folded:
- the **session header** carries the trunk/main agent's model (the first
non-sidechain assistant model seen for that session);
- each **spawn card** (the Task/Agent ``tool_use`` that opens a sub-agent)
carries the model that sub-agent ran on — looked up from the sub-agent's
own first assistant model via ``spawned_agent_id``. The spawn card sits
at the *parent's* depth and stays on screen even when the sub-agent's
transcript collapses, so the model shows exactly where the reader looks
for it (issue #246 follow-up).

A mid-course ``/model`` switch surfaces as its own command message, so a
single first-seen value per context is enough. ``meta.model`` is only set
on assistant entries, so a truthy value already filters to assistant-origin
chunks (text or tool_use).
"""
from .models import TaskInput, ToolUseMessage

# Pass 1: collect each sub-agent's model (first-seen) and stamp the
# trunk/main model onto each session header.
agent_model: dict[str, str] = {}
seen_sessions: set[str] = set()
for msg in ctx.messages:
# ctx.messages may carry None slots (ghosted entries); skip them.
if msg is None:
continue
model = msg.meta.model
if not model:
continue
if msg.meta.is_sidechain:
agent_id = msg.meta.agent_id
if agent_id and agent_id not in agent_model:
agent_model[agent_id] = model
else:
session_id = msg.meta.session_id
if session_id and session_id not in seen_sessions:
seen_sessions.add(session_id)
header_index = ctx.session_first_message.get(session_id)
header = (
ctx.messages[header_index] if header_index is not None else None
)
if header is not None:
header.display_model = model

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Pass 2: stamp each sub-agent's model onto its spawn card — the Task/Agent
# ``tool_use`` that opens it, which carries the depth badge and stays visible
# when the sub-agent's transcript folds. Gated strictly to ``TaskInput`` so a
# regular tool inside a sub-agent never picks up the model. The spawned
# agent id is resolved from whichever linkage the transcript carries: the
# async ``minted_agent_id`` on the input, else the paired tool_result's
# ``spawned_agent_id`` (#213) or ``agent_id`` (async #90 ``toolUseResult``).
for msg in ctx.messages:
if msg is None or not isinstance(msg.content, ToolUseMessage):
continue
task_input = msg.content.input
if not isinstance(task_input, TaskInput):
continue
spawned = task_input.minted_agent_id
if not spawned and msg.pair_last is not None:
result = ctx.messages[msg.pair_last]
if result is not None:
spawned = result.meta.spawned_agent_id or result.meta.agent_id
if spawned and spawned in agent_model:
msg.display_model = agent_model[spawned]


# -- Project Index Generation -------------------------------------------------


Expand Down
25 changes: 24 additions & 1 deletion dev-docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ transcript:
0.5em) so very deep chains (79 levels seen in the wild) stay
on-screen — depth is carried by the badge + colour, not the indent.

Two card-level annotations complete the layer:
Three card-level annotations complete the layer:

- **Depth badge** (`_agent_depth_badge`, `html/renderer.py`): a "Depth
N" pill on a spawn card showing the depth of the sub-agent it *opens*
Expand All @@ -332,6 +332,29 @@ Two card-level annotations complete the layer:
shown is its whole transcript, distinct from a spawn that produced
none. Nested-only (`agent_depth >= 1`); trunk-level direct
sub-agents keep their pre-#213 rendering.
- **Model id** (`TemplateMessage.display_model`, set by
`_surface_agent_models` in `renderer.py`; issue #246): the model an
agent ran on, surfaced **once per agent context** and on a node that
stays visible when the agent's transcript folds — on the session
header (the trunk/main model, from the first non-sidechain assistant
entry) and on each **spawn card** (the Task/Agent `tool_use` that
opens a sub-agent). The sub-agent's model comes from its own first
assistant entry, joined back to the spawn card via `tool_use_id`
(`spawned_agent_id` lands on the tool_result; the two-pass helper maps
agent→model, then stamps the paired tool_use — pairing indices aren't
assigned yet, so it uses the id join, not `pair_first`). The spawn
card sits at the parent's depth and stays on screen even when the
sub-agent collapses, so the model shows where the reader looks — and
co-locates with the depth badge. A mid-course `/model` switch surfaces
as its own command message, so a single first-seen value per context
suffices. The raw per-entry value is `MessageMeta.model` (only
assistant entries carry it); `display_model` is the render-once
decision. HTML renders a muted `.message-model` pill — in the spawn
card title (`_agent_model_badge`, beside the depth badge) and inline
on the session-header line; Markdown appends `` · `…` `` to the Task
title and an inline `— Model:` on the session heading. Unlike the
depth badge it is **not** suppressed at depth 1 (the top-level
Explore/Task spawn is the common case a reader wants).

### 5.5 Fixture

Expand Down
Loading
Loading