Conversation
Claude Code's Skill invocation lands on disk as three disjoint
entries:
1. assistant `Skill` tool_use
2. user tool_result with the literal string "Launching skill: <name>"
3. user `isMeta=True` entry whose `sourceToolUseID` matches (1) and
whose text is the expanded skill body (markdown, often 100+
lines)
Rendered as-is, (3) shows up as a disjoint "🧑 User (slash command)"
block visually unrelated to (1) — which is exactly what #93 flags:
"seeing the skill content as a user message (right aligned) breaks
the 'flow'."
The linkage we need is already in the data. (3) carries a top-level
`sourceToolUseID` equal to (1)'s tool_use id. No heuristic, no
parent-chain walk, no proximity guess — a field lookup.
## Changes
- `UserTranscriptEntry` gains `sourceToolUseID: Optional[str]` so
the field survives Pydantic parsing.
- `MessageMeta` gains `source_tool_use_id` and `meta_factory` forwards
the value through.
- `ToolUseMessage` gains `skill_body: Optional[str]` — set only for
Skill invocations that found a matching (3).
- `_pair_skill_tool_uses(ctx)` in renderer.py walks ctx.messages
once: it indexes slash-command TemplateMessages by
`meta.source_tool_use_id`, then for each `ToolUseMessage` with
`tool_name == "Skill"` attaches the matching slash-command's
text as `skill_body` and marks both the slash-command and the
matching "Launching skill" tool_result as consumed. Consumed
indices feed through the existing `_reindex_filtered_context` so
the rest of the pipeline sees a clean, gap-free list.
- Called before the detail-level post-render filter so the body
survives alongside the tool_use at HIGH — arguably correct per
main's "body is content of the Skill tool_use, not an independent
message" framing. MINIMAL/LOW still drop the whole skill
invocation since tool_use itself is filtered there.
- HTML renderer overrides `format_ToolUseMessage`: after the
standard params-table render, appends the body via
`render_markdown_collapsible(..., "skill-body", …)` so long
bodies collapse like existing slash-command / compacted-summary
rendering.
- Markdown renderer does the same with raw markdown passthrough.
- New `.skill-body` CSS rule gives the embedded body a left border
and inset padding so it reads as nested content under the
tool invocation, not a sibling message.
## Tests (8 new in test_skill_pairing.py)
Template-level:
- Body folds into the ToolUseMessage as skill_body
- Slash-command TemplateMessage is consumed (removed from ctx.messages)
- "Launching skill: X" tool_result is dropped
- Non-Skill tool_uses (e.g. Bash) are untouched — their tool_result stays
- isMeta entries without sourceToolUseID still render as slash-commands
- Orphan skill body (pointing at a missing tool_use) stays as a standalone
slash-command
Renderer output:
- HTML: `.skill-body` class and rendered markdown (<strong> etc.) land
in the output; "Launching skill:" string is absent
- Markdown: raw markdown passes through; "Launching skill:" absent
## Scope checks
- No new pair primitive — uses `_reindex_filtered_context` to drop
consumed messages.
- Narrow dispatch: only `tool_name == "Skill"` is paired.
- Fall-back-clean: entries without `sourceToolUseID` (older Claude
Code versions) behave identically to current main.
- Edge-case snapshot regenerated mechanically (CSS addition only).
All 922 unit tests pass. pyright / ty / ruff clean on modified files.
Refs #93.
📝 WalkthroughWalkthroughThe PR implements pairing of Claude Code's "Skill" tool invocations with their expanded body content, embedding the skill body directly within the tool-use block instead of rendering it as a separate user message. Changes span the data model, message pairing logic, metadata factory, and renderers. Changes
Sequence DiagramsequenceDiagram
participant Transcript as Transcript<br/>(raw entries)
participant Pairing as Skill Pairing<br/>Logic
participant Messages as Message<br/>Stream
participant Renderer as HTML/Markdown<br/>Renderer
participant Output as Rendered<br/>Output
Transcript->>Pairing: tool_use("Skill", id="X")<br/>tool_result("Launching...")<br/>user_meta(sourceToolUseID="X")
Pairing->>Pairing: Detect Skill tool_use
Pairing->>Pairing: Match meta entry by<br/>source_tool_use_id
Pairing->>Pairing: Extract skill_body from<br/>slash-command content
Pairing->>Messages: Attach skill_body to<br/>tool_use, remove meta<br/>& redundant tool_result
Pairing->>Messages: Reindex message stream
Messages->>Renderer: ToolUseMessage with<br/>skill_body populated
Renderer->>Output: Render tool_use +<br/>collapsible skill_body<br/>as single unit
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@claude_code_log/renderer.py`:
- Around line 2032-2091: The pairing currently matches only on tool_use_id and
can collide across sessions and can drop real results; modify
_pair_skill_tool_uses to key slash_by_source by (source_tool_use_id,
session_key) where session_key is taken from a session/render identifier on
messages (e.g. msg.meta.render_session_id or msg.meta.session_id) so lookups use
the same session, update the lookup and the later slash =
slash_by_source.get(...) usage to use that composite key, and when marking
matching tool_result entries for removal only include tool_result messages in
the same session whose visible text/content begins with "Launching skill:" (and
skip any tool_result that indicates an error/status != success if such meta
exists) instead of removing all tool_results with the same tool_use_id. This
ensures you only fold the intended slash body and drop the redundant "Launching
skill:" result within the same session.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 93790577-4afc-4767-906c-c6381acefe04
📒 Files selected for processing (8)
claude_code_log/factories/meta_factory.pyclaude_code_log/html/renderer.pyclaude_code_log/html/templates/components/message_styles.cssclaude_code_log/markdown/renderer.pyclaude_code_log/models.pyclaude_code_log/renderer.pytest/__snapshots__/test_snapshot_html.ambrtest/test_skill_pairing.py
| def _pair_skill_tool_uses(ctx: RenderingContext) -> None: | ||
| """Fold the `isMeta=True` user body of a Skill invocation into its tool_use. | ||
|
|
||
| Claude Code emits three separate entries for a Skill invocation: | ||
| 1. assistant `Skill` tool_use | ||
| 2. user tool_result containing the literal string "Launching skill: <name>" | ||
| 3. user `isMeta=True` entry whose `sourceToolUseID` matches (1) and whose | ||
| text is the expanded skill body (markdown, often 100+ lines). | ||
|
|
||
| Rendered as-is, (3) appears as a bare "🧑 User (slash command)" block | ||
| visually disjoint from (1). Pair them: attach (3)'s text as | ||
| `skill_body` on the Skill `ToolUseMessage`, drop (2) and (3) from | ||
| `ctx.messages`, and re-index so later passes see a clean slate. | ||
|
|
||
| See issue #93. | ||
| """ | ||
| # Build the lookup: tool_use_id -> UserSlashCommandMessage template | ||
| slash_by_source: dict[str, TemplateMessage] = {} | ||
| for msg in ctx.messages: | ||
| if ( | ||
| isinstance(msg.content, UserSlashCommandMessage) | ||
| and msg.meta.source_tool_use_id | ||
| ): | ||
| slash_by_source[msg.meta.source_tool_use_id] = msg | ||
|
|
||
| if not slash_by_source: | ||
| return | ||
|
|
||
| consumed_indices: set[int] = set() | ||
| for msg in ctx.messages: | ||
| if not ( | ||
| isinstance(msg.content, ToolUseMessage) and msg.content.tool_name == "Skill" | ||
| ): | ||
| continue | ||
| slash = slash_by_source.get(msg.content.tool_use_id) | ||
| if slash is None or not isinstance(slash.content, UserSlashCommandMessage): | ||
| continue | ||
| # Fold the body into the Skill tool_use and mark the slash-command consumed. | ||
| msg.content.skill_body = slash.content.text | ||
| if slash.message_index is not None: | ||
| consumed_indices.add(slash.message_index) | ||
| # The matching tool_result carries the redundant "Launching skill: ..." | ||
| # string; drop it too so the tool_use stands alone as one visual unit. | ||
| for other in ctx.messages: | ||
| if ( | ||
| other.type == "tool_result" | ||
| and other.tool_use_id == msg.content.tool_use_id | ||
| and other.message_index is not None | ||
| ): | ||
| consumed_indices.add(other.message_index) | ||
|
|
||
| if not consumed_indices: | ||
| return | ||
|
|
||
| kept = [ | ||
| msg | ||
| for msg in ctx.messages | ||
| if msg.message_index is None or msg.message_index not in consumed_indices | ||
| ] | ||
| _reindex_filtered_context(ctx, kept) |
There was a problem hiding this comment.
Scope Skill pairing and result removal more tightly.
This currently matches sourceToolUseID globally and drops every tool_result with the same tool_use_id. In combined/multi-session renders, an ID collision can fold the wrong slash body, and a real/error Skill result with the same ID would be hidden. Please key by session/render session and only drop the known redundant non-error "Launching skill:" result.
🐛 Proposed tightening
def _pair_skill_tool_uses(ctx: RenderingContext) -> None:
@@
- # Build the lookup: tool_use_id -> UserSlashCommandMessage template
- slash_by_source: dict[str, TemplateMessage] = {}
+ # Build the lookup: (render_session_id, tool_use_id) -> slash-command template
+ slash_by_source: dict[tuple[str, str], TemplateMessage] = {}
for msg in ctx.messages:
if (
isinstance(msg.content, UserSlashCommandMessage)
and msg.meta.source_tool_use_id
):
- slash_by_source[msg.meta.source_tool_use_id] = msg
+ slash_by_source[(msg.render_session_id, msg.meta.source_tool_use_id)] = msg
@@
- slash = slash_by_source.get(msg.content.tool_use_id)
+ slash = slash_by_source.get((msg.render_session_id, msg.content.tool_use_id))
if slash is None or not isinstance(slash.content, UserSlashCommandMessage):
continue
@@
for other in ctx.messages:
if (
- other.type == "tool_result"
- and other.tool_use_id == msg.content.tool_use_id
+ isinstance(other.content, ToolResultMessage)
+ and other.render_session_id == msg.render_session_id
+ and other.content.tool_use_id == msg.content.tool_use_id
+ and not other.content.is_error
and other.message_index is not None
):
- consumed_indices.add(other.message_index)
+ output = other.content.output
+ if (
+ isinstance(output, ToolResultContent)
+ and isinstance(output.content, str)
+ and output.content.strip().startswith("Launching skill:")
+ ):
+ consumed_indices.add(other.message_index)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@claude_code_log/renderer.py` around lines 2032 - 2091, The pairing currently
matches only on tool_use_id and can collide across sessions and can drop real
results; modify _pair_skill_tool_uses to key slash_by_source by
(source_tool_use_id, session_key) where session_key is taken from a
session/render identifier on messages (e.g. msg.meta.render_session_id or
msg.meta.session_id) so lookups use the same session, update the lookup and the
later slash = slash_by_source.get(...) usage to use that composite key, and when
marking matching tool_result entries for removal only include tool_result
messages in the same session whose visible text/content begins with "Launching
skill:" (and skip any tool_result that indicates an error/status != success if
such meta exists) instead of removing all tool_results with the same
tool_use_id. This ensures you only fold the intended slash body and drop the
redundant "Launching skill:" result within the same session.
Closes #93.
Claude Code's Skill invocation lands on disk as three disjoint entries:
Skilltool_use"Launching skill: <name>"isMeta=Trueentry whosesourceToolUseIDmatches (1) and whose text is the expanded skill body (markdown, often 100+ lines)Rendered as-is, (3) appears as a "🧑 User (slash command)" block visually disjoint from (1) — which is exactly what #93 flags: "seeing the skill content as a user message (right aligned) breaks the 'flow'."
The linkage is already in the data. (3) carries a top-level
sourceToolUseIDequal to (1)'s tool_use id. No heuristic, no parent-chain walk, no proximity guess — a field lookup.Changes
UserTranscriptEntry.sourceToolUseID: Optional[str]— Pydantic field.MessageMeta.source_tool_use_id— propagated viameta_factory(getattr-with-default handles older transcripts).ToolUseMessage.skill_body: Optional[str]— set only when paired._pair_skill_tool_uses(ctx)inrenderer.py: single-pass indexing of slash-command TemplateMessages bysource_tool_use_id, then folds the body into each SkillToolUseMessageand drops the slash-command + the redundant"Launching skill"tool_result. Runs right after_render_messages(before detail filtering) and reuses_reindex_filtered_contextfor the drops.format_ToolUseMessage: appends the body viarender_markdown_collapsible(same collapsible primitive as slash-command / compacted-summary)..skill-bodyCSS — left border + inset padding — makes the folded body read as nested content under the params table.Detail-level interaction
Pairing runs before
_filter_template_by_detail. At HIGH the body survives alongside the tool_use — intended, since once paired the body is content of the Skill tool_use, not an independent message. MINIMAL/LOW continue to drop the entire Skill invocation becauseToolUseMessageitself is in those exclude chains.Tests (8 new in
test/test_skill_pairing.py)Template-level:
ToolUseMessage.skill_body."Launching skill: X"tool_result is dropped.isMetaentries withoutsourceToolUseIDstill render as standalone slash-commands.Renderer output:
.skill-bodyclass + rendered markdown present,"Launching skill"absent."Launching skill"absent.Validation
just test(excluding tui/browser/pagination): 922 passed, 7 skipped.uv run pyrighton modified files: 0 errors, 0 warnings.uv run ty check: All checks passed (0 diagnostics).ruff format+ruff check: clean.Review
Monk approved at
dc1770a. Optional non-blocker flagged: the inner tool_result search in_pair_skill_tool_usesis O(N·M) for typical transcripts with a handful of Skills; could be tightened to O(N+M) by indexing tool_results once. Left for a later perf pass since the actual cost is negligible.Summary by CodeRabbit
New Features
Tests