Skip to content
Merged
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
84 changes: 78 additions & 6 deletions claude_code_log/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1138,11 +1138,60 @@ def _branch_label(branch_sid: str, preview: str) -> str:
return f"Branch • {_branch_label_suffix(branch_sid, preview)}"


def _tool_summary_label(tool_name: Optional[str], description: Optional[str]) -> str:
"""Compose a short ``Tool — description`` label for nav previews.

Used so a fork point or branch whose first message is a tool call reads as
e.g. ``Bash — Timeline d-N dependency check (retry)`` instead of an empty
preview (#179 follow-up / dev/tool-use-continuation).
"""
name = (tool_name or "Tool").strip()
desc = (description or "").strip().splitlines()[0].strip() if description else ""
if len(desc) > 70:
desc = desc[:70] + "…"
return f"{name} — {desc}" if desc else name


def _entry_nav_summary(entry: "TranscriptEntry") -> str:
"""Type-aware one-line summary of a transcript entry, for branch previews.

Mirrors ``_fork_point_preview``'s label shapes but reads the raw entry
(available at branch-header build time): user/assistant text → the text,
a tool call → ``Bash — <description>``, thinking → ``Thinking``. Returns
"" for entries with no summarisable content (system/hook/attachment), so
the caller walks to the branch's first meaningful message.
"""
if isinstance(entry, UserTranscriptEntry):
content = entry.message.content
if isinstance(content, str):
return create_session_preview(content)
items: list[ContentItem] = content
elif isinstance(entry, AssistantTranscriptEntry):
items = entry.message.content
else:
return ""
for item in items:
if isinstance(item, TextContent):
text = item.text.strip()
if text:
return create_session_preview(text)
elif isinstance(item, ToolUseContent):
description = item.input.get("description")
return _tool_summary_label(
item.name, description if isinstance(description, str) else None
)
elif isinstance(item, ThinkingContent):
return "Thinking"
return ""


def _fork_point_preview(fork_msg: "TemplateMessage", ctx: RenderingContext) -> str:
"""Get a meaningful preview for a fork point message.

If the fork point is a system hook (common with /rewind), walk up
to the parent message to find more descriptive content.
to the parent message to find more descriptive content. Tool-use and
thinking fork points (the common shape for tool-flow forks) get a
type-aware label so the fork point names the message it sits on.
"""
msg = fork_msg
# Walk up past system hooks to find a meaningful message
Expand All @@ -1168,14 +1217,20 @@ def _fork_point_preview(fork_msg: "TemplateMessage", ctx: RenderingContext) -> s
break
msg = parent

# Extract text from the found message
# Extract a label from the found message, type-aware so tool-use and
# thinking fork points name their message rather than rendering empty.
content = msg.content
if isinstance(content, AssistantTextMessage):
parts = [item.text for item in content.items if isinstance(item, TextContent)]
text = " ".join(parts).strip()
elif isinstance(content, UserTextMessage):
if isinstance(content, (AssistantTextMessage, UserTextMessage)):
parts = [item.text for item in content.items if isinstance(item, TextContent)]
text = " ".join(parts).strip()
elif isinstance(content, ToolUseMessage):
text = _tool_summary_label(
content.tool_name, getattr(content.input, "description", None)
)
elif isinstance(content, ThinkingMessage):
text = "Thinking"
elif isinstance(content, ToolResultMessage):
text = f"{content.tool_name} result" if content.tool_name else "Tool result"
else:
return ""

Expand Down Expand Up @@ -3741,6 +3796,10 @@ def _build_branch_header(
branch_uuids: list[str] = b_hier.get("uuids") or []
branch_preview = ""
if uuid_to_entry:
# Preferred: the first branch-local USER text. Scanning forward past a
# leading assistant turn (the canonical ``/exit`` → "No response
# requested." case) and staying bounded to ``branch_uuids`` keeps the
# spawned-agent inner prompt out of the label (see test_branch_label_source).
for branch_uuid in branch_uuids:
entry = uuid_to_entry.get(branch_uuid)
if entry is None:
Expand All @@ -3752,6 +3811,19 @@ def _build_branch_header(
if branch_text:
branch_preview = create_session_preview(branch_text)
break
# Fallback: a branch with no user text — an assistant continuation or
# tool-flow branch — gets a type-aware summary of its first meaningful
# message ("Thinking" / "Bash — <desc>") instead of a bare uuid label
# (dev/tool-use-continuation).
if not branch_preview:
for branch_uuid in branch_uuids:
entry = uuid_to_entry.get(branch_uuid)
if entry is None:
continue
summary = _entry_nav_summary(entry)
if summary:
branch_preview = summary
break
branch_title = _branch_label(branch_sid, branch_preview)

branch_header_meta = MessageMeta(
Expand Down
135 changes: 135 additions & 0 deletions test/test_fork_branch_labels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Type-aware fork-point / branch nav labels (dev/tool-use-continuation).

Tool-use and thinking fork points / branches previously rendered with an empty
preview (``Fork point (2 branches)`` / ``Branch • <uuid8>``). These pin the
enriched labels: a tool call reads as ``Bash — <description>``, thinking as
``Thinking``.
"""

from claude_code_log.factories import create_transcript_entry
from claude_code_log.models import (
AssistantTextMessage,
BashInput,
MessageMeta,
TextContent,
ToolUseMessage,
TranscriptEntry,
)
from claude_code_log.renderer import (
RenderingContext,
TemplateMessage,
_entry_nav_summary,
_fork_point_preview,
_tool_summary_label,
)


def _meta() -> MessageMeta:
return MessageMeta(uuid="u", session_id="s", timestamp="2025-01-01T00:00:00Z")


class TestToolSummaryLabel:
def test_name_and_description(self):
assert (
_tool_summary_label("Bash", "Timeline d-N dependency check (retry)")
== "Bash — Timeline d-N dependency check (retry)"
)

def test_name_only_when_no_description(self):
assert _tool_summary_label("Read", None) == "Read"
assert _tool_summary_label("Write", "") == "Write"

def test_default_name(self):
assert _tool_summary_label(None, None) == "Tool"

def test_description_truncated_and_first_line(self):
out = _tool_summary_label("Bash", "first line\nsecond line")
assert out == "Bash — first line"
long = _tool_summary_label("Bash", "x" * 100)
assert long.startswith("Bash — ") and long.endswith("…") and len(long) < 90


def _entry(content: list[dict]) -> TranscriptEntry:
raw = {
"type": "assistant",
"uuid": "e1",
"parentUuid": None,
"isSidechain": False,
"userType": "external",
"cwd": "/x",
"sessionId": "s",
"version": "1.0",
"timestamp": "2025-01-01T00:00:00Z",
"requestId": "r",
"message": {
"id": "m",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-20250514",
"stop_reason": "tool_use",
"stop_sequence": None,
"usage": {"input_tokens": 1, "output_tokens": 1},
"content": content,
},
}
return create_transcript_entry(raw)


class TestEntryNavSummary:
def test_tool_use_entry(self):
e = _entry(
[
{
"type": "tool_use",
"id": "t1",
"name": "Bash",
"input": {"command": "ls", "description": "List files"},
}
]
)
assert _entry_nav_summary(e) == "Bash — List files"

def test_tool_use_without_description(self):
e = _entry(
[
{
"type": "tool_use",
"id": "t1",
"name": "Read",
"input": {"file_path": "/x"},
}
]
)
assert _entry_nav_summary(e) == "Read"

def test_thinking_entry(self):
e = _entry(
[{"type": "thinking", "thinking": "pondering...", "signature": "sig"}]
)
assert _entry_nav_summary(e) == "Thinking"

def test_text_entry(self):
e = _entry([{"type": "text", "text": "Hello there"}])
assert _entry_nav_summary(e) == "Hello there"


class TestForkPointPreviewTypeAware:
def _ctx(self) -> RenderingContext:
return RenderingContext(messages=[])

def test_tool_use_fork_point(self):
content = ToolUseMessage(
meta=_meta(),
input=BashInput(command="ls", description="List files"),
tool_use_id="t1",
tool_name="Bash",
)
msg = TemplateMessage(content)
assert _fork_point_preview(msg, self._ctx()) == "Bash — List files"

def test_text_fork_point_unchanged(self):
content = AssistantTextMessage(
meta=_meta(), items=[TextContent(type="text", text="hi there")]
)
msg = TemplateMessage(content)
assert _fork_point_preview(msg, self._ctx()) == "hi there"
Loading