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
70 changes: 62 additions & 8 deletions claude_code_log/factories/tool_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,11 @@ def _parse_cat_n_snippet(
system_reminder += line + "\n"
continue

# Parse regular code line (format: " 123→content")
match = re.match(r"\s+(\d+)→(.*)$", line)
# Parse regular code line. Two cat-n separator variants seen in the
# wild: the arrow form (" 123→content", used by Edit/Write result
# snippets) and the literal-tab form ("775\tcontent", used by Read
# results since at least Claude Code 2.1.x — see issue #170).
match = re.match(r"\s*(\d+)[\t→](.*)$", line)
if match:
line_num = int(match.group(1))
# Capture the first line number as offset
Expand Down Expand Up @@ -255,25 +258,75 @@ def _extract_tool_result_text(tool_result: ToolResultContent) -> str:


def parse_read_output(
tool_result: ToolResultContent, file_path: Optional[str]
tool_result: ToolResultContent,
file_path: Optional[str],
tool_use_result: Optional[ToolUseResult] = None,
) -> Optional[ReadOutput]:
"""Parse Read tool result into structured content.

Prefers the structured ``toolUseResult.file`` payload (clean content
plus accurate ``filePath`` / ``startLine`` / ``numLines`` / ``totalLines``
metadata) when available; falls back to re-parsing the ``cat -n``
formatted ``tool_result.content`` text for older transcripts.

Args:
tool_result: The tool result content
tool_result: The tool result content (cat-n formatted text)
file_path: Path to the file that was read (required for ReadOutput)
tool_use_result: Structured toolUseResult; ``file`` dict gives us
clean content + exact line metadata without re-parsing cat-n

Returns:
ReadOutput if parsing succeeds, None otherwise
"""
if not file_path:
return None

# Preferred path: structured toolUseResult.file is byte-clean and carries
# the exact line metadata. Avoids the lossy cat-n round-trip and gives us
# totalLines (which the text-only fallback cannot recover).
if isinstance(tool_use_result, dict):
file_info: dict[str, Any] = tool_use_result.get("file") or {}
content_field: Any = file_info.get("content")
if isinstance(content_field, str):
# ``numLines`` may legitimately be 0 for an empty-file read, so
# distinguish *absent* from *zero* explicitly — the
# ``... or default`` truthiness shortcut would silently promote
# the zero case to the fallback. Same for ``totalLines`` and
# ``startLine``. ``splitlines()`` over ``split("\n")`` for the
# absent-numLines fallback so content ending in ``\n`` does not
# tack on a phantom trailing element ("x\ny\n".splitlines() →
# ["x", "y"], length 2 not 3).
num_lines_raw = file_info.get("numLines")
num_lines = (
int(num_lines_raw)
if num_lines_raw is not None
else len(content_field.splitlines())
)
total_lines_raw = file_info.get("totalLines")
total_lines = (
int(total_lines_raw) if total_lines_raw is not None else num_lines
)
start_line_raw = file_info.get("startLine")
start_line = int(start_line_raw) if start_line_raw is not None else 1
return ReadOutput(
file_path=str(file_info.get("filePath") or file_path),
content=content_field,
start_line=start_line,
num_lines=num_lines,
total_lines=total_lines,
is_truncated=num_lines < total_lines,
system_reminder=None,
)

if not (content := _extract_tool_result_text(tool_result)):
return None

# Check if content matches the cat-n format pattern (line_number → content)
# Fallback: parse the cat-n formatted text. Accepts both the tab variant
# (Read tool result, Claude Code 2.1.x+) and the arrow variant
# (Edit/Write result snippets); see _parse_cat_n_snippet for the
# combined regex.
lines = content.split("\n")
if not lines or not re.match(r"\s+\d+", lines[0]):
if not lines or not re.match(r"\s*\d+[\t→]", lines[0]):
return None

result = _parse_cat_n_snippet(lines)
Expand All @@ -288,8 +341,8 @@ def parse_read_output(
content=code_content,
start_line=line_offset,
num_lines=num_lines,
total_lines=num_lines, # We don't know total from result
is_truncated=False, # Can't determine from result
total_lines=num_lines, # Not recoverable from text-only fallback
is_truncated=False, # Same — fallback can't tell
system_reminder=system_reminder,
)

Expand Down Expand Up @@ -1266,6 +1319,7 @@ def parse_taskstop_output(
"WebFetch",
"Bash",
"TaskStop",
"Read",
}


Expand Down
14 changes: 10 additions & 4 deletions claude_code_log/html/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -830,13 +830,19 @@ def title_WriteInput(self, input: WriteInput, message: TemplateMessage) -> str:
def title_ReadInput(self, input: ReadInput, message: TemplateMessage) -> str:
"""Title → '📄 Read <file_path>[, lines N-M]'."""
summary = input.file_path
# Add line range info if available
# Add line range info if available. ``offset`` in the Read tool's
# input is the 1-based starting line number (matches what the
# ``toolUseResult.file.startLine`` and the cat-n line numbers in
# the rendered content show). ``None`` or ``0`` both mean "start
# from line 1". The displayed range is inclusive on both ends, so
# the end is ``start + limit - 1`` — not ``start + limit``.
if input.limit is not None:
offset = input.offset or 0
start = input.offset if input.offset else 1
end = start + input.limit - 1
if input.limit == 1:
summary = f"{summary}, line {offset + 1}"
summary = f"{summary}, line {start}"
else:
summary = f"{summary}, lines {offset + 1}-{offset + input.limit}"
summary = f"{summary}, lines {start}-{end}"
return self._tool_title(message, "📄", summary)

def title_GlobInput(self, input: GlobInput, message: TemplateMessage) -> str:
Expand Down
2 changes: 2 additions & 0 deletions test/test_data/read_tool_pygments.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{"parentUuid":"accdfd56-c3cd-4a7f-8af8-64950d3e0c60","isSidechain":false,"userType":"external","cwd":"/home/cboos/Workspace/github/daain/claude-code-log/carol","sessionId":"5662de58-b7fb-4500-bf91-a60590136031","version":"2.1.132","gitBranch":"HEAD","entrypoint":"cli","requestId":"req_011CatnwLh8V3F79QLJRDHkM","message":{"model":"claude-opus-4-7","id":"msg_01CFwpE2Tz4Mp3mn6SAh5z5G","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VQrLRn8EY5jVKGvJMWDVRc","name":"Read","input":{"file_path":"/home/cboos/Workspace/github/daain/claude-code-log/carol/claude_code_log/converter.py","offset":775,"limit":20}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":0,"cache_creation_input_tokens":1000,"cache_read_input_tokens":1000,"output_tokens":50,"service_tier":"standard"}},"type":"assistant","uuid":"40a71f98-83db-404f-ac30-abb3ed710384","timestamp":"2026-05-10T11:16:00.071Z"}
{"parentUuid":"40a71f98-83db-404f-ac30-abb3ed710384","isSidechain":false,"userType":"external","cwd":"/home/cboos/Workspace/github/daain/claude-code-log/carol","sessionId":"5662de58-b7fb-4500-bf91-a60590136031","version":"2.1.132","gitBranch":"HEAD","entrypoint":"cli","promptId":"0b653878-b7fb-4c91-a36d-ca8e7fbe790e","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VQrLRn8EY5jVKGvJMWDVRc","type":"tool_result","content":"775\t with _dag_warnings_suppressed(silent):\n776\t tree = build_dag_from_entries(\n777\t all_messages, sidechain_uuids=unloaded_sidechain_uuids\n778\t )\n779\t dag_ordered = traverse_session_tree(tree)\n780\t\n781\t # Re-add summaries/ai-titles/queue-ops (excluded from DAG since they\n782\t # lack uuid).\n783\t non_dag_entries: list[TranscriptEntry] = [\n784\t e\n785\t for e in all_messages\n786\t if isinstance(\n787\t e,\n788\t (\n789\t SummaryTranscriptEntry,\n790\t AiTitleTranscriptEntry,\n791\t QueueOperationTranscriptEntry,\n792\t ),\n793\t )\n794\t ]"}]},"uuid":"fe7a0f22-ce99-4a3d-8d4e-9cfac6728071","timestamp":"2026-05-10T11:16:00.196Z","toolUseResult":{"type":"text","file":{"filePath":"/home/cboos/Workspace/github/daain/claude-code-log/carol/claude_code_log/converter.py","content":" with _dag_warnings_suppressed(silent):\n tree = build_dag_from_entries(\n all_messages, sidechain_uuids=unloaded_sidechain_uuids\n )\n dag_ordered = traverse_session_tree(tree)\n\n # Re-add summaries/ai-titles/queue-ops (excluded from DAG since they\n # lack uuid).\n non_dag_entries: list[TranscriptEntry] = [\n e\n for e in all_messages\n if isinstance(\n e,\n (\n SummaryTranscriptEntry,\n AiTitleTranscriptEntry,\n QueueOperationTranscriptEntry,\n ),\n )\n ]","numLines":20,"startLine":775,"totalLines":2953}},"sourceToolAssistantUUID":"40a71f98-83db-404f-ac30-abb3ed710384"}
Loading
Loading