"""
Coding Trace Display Type
Purpose-built rendering for agentic coding system traces (Claude Code, OpenCode,
Cursor, Aider, SWE-Agent). Renders tool calls with appropriate formatting:
- Code diffs (Edit/Write) with red/green highlighting
- Terminal blocks (Bash) with dark monospace styling
- Code blocks (Read/Grep/Glob) with line numbers
- File tree sidebar showing all files touched
- Collapsible long outputs
- Turn structure: User messages → assistant reasoning → tool calls
Usage:
In instance_display config:
fields:
- key: structured_turns
type: coding_trace
display_options:
show_file_tree: true
diff_view: unified
collapse_long_outputs: true
max_output_lines: 50
terminal_theme: dark
"""
import html
import json
import os
import re
from typing import Dict, Any, List, Optional, Set, Tuple
from .base import BaseDisplay
# Tool type classifications - order matters: more specific sets checked first
CODE_READ_TOOLS = {"Read", "read"}
CODE_EDIT_TOOLS = {"Edit", "edit", "Replace", "replace"}
CODE_WRITE_TOOLS = {"Write", "write", "Create", "create"}
TERMINAL_TOOLS = {"Bash", "bash", "Terminal", "terminal", "Shell", "shell", "Run", "run"}
SEARCH_TOOLS = {"Grep", "grep", "Glob", "glob", "Search", "search", "Find", "find"}
# File extension to language mapping
EXTENSION_LANGUAGES = {
".py": "python", ".js": "javascript", ".ts": "typescript",
".jsx": "jsx", ".tsx": "tsx", ".rb": "ruby", ".go": "go",
".rs": "rust", ".java": "java", ".c": "c", ".cpp": "cpp",
".h": "c", ".hpp": "cpp", ".cs": "csharp", ".swift": "swift",
".kt": "kotlin", ".scala": "scala", ".r": "r",
".sh": "bash", ".bash": "bash", ".zsh": "zsh",
".yaml": "yaml", ".yml": "yaml", ".json": "json",
".xml": "xml", ".html": "html", ".css": "css",
".sql": "sql", ".md": "markdown", ".toml": "toml",
".ini": "ini", ".cfg": "ini", ".dockerfile": "dockerfile",
}
# Badge colors for different tool types
TOOL_BADGE_COLORS = {
"read": ("#e3f2fd", "#1565c0", "#1976d2"), # Blue
"edit": ("#fff3e0", "#e65100", "#ef6c00"), # Orange
"write": ("#e8f5e9", "#2e7d32", "#388e3c"), # Green
"bash": ("#263238", "#b0bec5", "#78909c"), # Dark
"search": ("#f3e5f5", "#6a1b9a", "#7b1fa2"), # Purple
"generic": ("#f5f5f5", "#424242", "#616161"), # Grey
}
def _detect_language(file_path: str) -> str:
"""Detect programming language from file extension."""
if not file_path:
return ""
ext = os.path.splitext(file_path)[1].lower()
return EXTENSION_LANGUAGES.get(ext, "")
def _classify_tool(tool_name: str) -> str:
"""Classify a tool into a rendering category."""
if tool_name in SEARCH_TOOLS:
return "search"
if tool_name in CODE_READ_TOOLS:
return "read"
if tool_name in CODE_EDIT_TOOLS:
return "edit"
if tool_name in CODE_WRITE_TOOLS:
return "write"
if tool_name in TERMINAL_TOOLS:
return "bash"
return "generic"
def _escape(text: str) -> str:
"""HTML-escape text."""
return html.escape(str(text), quote=True)
def _truncate_output(text: str, max_lines: int) -> Tuple[str, bool]:
"""Truncate text to max_lines, return (text, was_truncated)."""
if not text or max_lines <= 0:
return text, False
lines = text.split("\n")
if len(lines) <= max_lines:
return text, False
return "\n".join(lines[:max_lines]), True
class CodingTraceDisplay(BaseDisplay):
"""
Display type for coding agent traces with rich tool call rendering.
Renders agent sessions with proper formatting for code diffs,
terminal output, file reads, and search results.
"""
name = "coding_trace"
required_fields = ["key"]
optional_fields = {
"show_file_tree": True,
"diff_view": "unified", # "unified" or "side_by_side"
"collapse_long_outputs": True,
"max_output_lines": 50,
"terminal_theme": "dark", # "dark" or "light"
"show_step_numbers": True,
"show_tool_badges": True,
"show_reasoning": True,
"compact": False,
}
description = "Coding agent trace display with diff rendering, terminal blocks, and file tree"
supports_span_target = True
def render(self, field_config: Dict[str, Any], data: Any) -> str:
if not data:
return '
No trace data provided
'
options = self.get_display_options(field_config)
field_key = _escape(field_config.get("key", ""))
is_span_target = field_config.get("span_target", False)
# Parse turns from data
turns = self._normalize_turns(data)
if not turns:
empty_html = '
No trace steps found
'
if is_span_target:
return self.render_span_wrapper(field_key, empty_html, "")
return empty_html
# Build file tree
file_tree_html = ""
file_count = 0
if options.get("show_file_tree", True):
file_tree_html, file_count = self._build_file_tree(turns)
# Build turn cards
turns_html = self._build_turns(turns, options, field_key, is_span_target)
# Build summary with collapse/expand toggle
total_tools = sum(len(t.get("tool_calls", [])) for t in turns)
summary_html = self._build_summary(turns, file_tree_html, file_count)
# Wrap in span target if needed
if is_span_target:
plain_text = self._extract_plain_text(turns)
reasoning_html = self._extract_reasoning_html(turns)
inner = reasoning_html
span_wrapper = self.render_span_wrapper(field_key, inner, plain_text)
else:
span_wrapper = ""
layout_class = "coding-trace-with-sidebar" if file_tree_html else ""
# Auto-collapse sidebar when only 1 file
sidebar_class = "ct-sidebar-collapsed" if file_count <= 1 and file_tree_html else ""
# Client-side JS for collapse/expand and sidebar toggle
js_init = self._build_js_init(field_key)
return f'''
{summary_html}
{f'
{file_tree_html}
' if file_tree_html else ''}
{span_wrapper}
{turns_html}
'''
def _normalize_turns(self, data: Any) -> List[Dict[str, Any]]:
"""Normalize various input formats to a list of structured turns."""
if isinstance(data, list):
turns = []
for item in data:
if isinstance(item, dict):
turns.append(self._normalize_single_turn(item))
elif isinstance(item, str):
turns.append({"role": "user", "content": item, "tool_calls": []})
return turns
if isinstance(data, dict):
# Single turn
return [self._normalize_single_turn(data)]
return []
def _normalize_single_turn(self, item: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize a single turn dict."""
role = item.get("role", "assistant")
reasoning = item.get("reasoning", item.get("content", item.get("text", "")))
tool_calls = item.get("tool_calls", [])
# Handle string content (user messages)
if isinstance(reasoning, list):
# Content blocks format
text_parts = []
extracted_tools = []
for block in reasoning:
if isinstance(block, dict):
if block.get("type") == "text":
text_parts.append(block.get("text", ""))
elif block.get("type") == "tool_use":
extracted_tools.append({
"tool": block.get("name", "unknown"),
"input": block.get("input", {}),
"output": "",
"output_type": "generic",
})
elif isinstance(block, str):
text_parts.append(block)
reasoning = "\n".join(text_parts)
if extracted_tools and not tool_calls:
tool_calls = extracted_tools
return {
"role": role,
"content": str(reasoning) if reasoning else "",
"tool_calls": tool_calls,
}
def _build_summary(self, turns: List[Dict[str, Any]],
file_tree_html: str = "", file_count: int = 0) -> str:
"""Build a summary header with counts and toggle buttons."""
total_tools = sum(len(t.get("tool_calls", [])) for t in turns)
assistant_turns = sum(1 for t in turns if t.get("role") != "user")
# Count tool types
tool_counts: Dict[str, int] = {}
for turn in turns:
for tc in turn.get("tool_calls", []):
tool_type = _classify_tool(tc.get("tool", ""))
tool_counts[tool_type] = tool_counts.get(tool_type, 0) + 1
badges = []
for tool_type, count in sorted(tool_counts.items()):
bg, fg, _ = TOOL_BADGE_COLORS.get(tool_type, TOOL_BADGE_COLORS["generic"])
badges.append(
f''
f'{count} {tool_type}'
)
# Toggle buttons
sidebar_toggle = ""
if file_tree_html:
sidebar_toggle = (
''
)
collapse_toggle = ""
if total_tools > 2:
collapse_toggle = (
''
)
return f'''
{assistant_turns} turn{"s" if assistant_turns != 1 else ""}·{total_tools} tool call{"s" if total_tools != 1 else ""}
{" ".join(badges)}
{sidebar_toggle}
{collapse_toggle}
'''
def _build_turns(self, turns: List[Dict[str, Any]], options: Dict[str, Any],
field_key: str, is_span_target: bool) -> str:
"""Build HTML for all turns."""
parts = []
step_counter = 0
show_numbers = options.get("show_step_numbers", True)
max_lines = options.get("max_output_lines", 50)
collapse = options.get("collapse_long_outputs", True)
show_reasoning = options.get("show_reasoning", True)
for i, turn in enumerate(turns):
role = turn.get("role", "assistant")
if role == "user":
parts.append(self._render_user_message(turn, i))
continue
step_counter += 1
# Assistant turn
turn_parts = []
# Step header
if show_numbers:
turn_parts.append(
f'
'
f'Step {step_counter}'
f'
'
)
# Reasoning text
content = turn.get("content", "")
if content and show_reasoning:
escaped = _escape(content)
turn_parts.append(
f'
{escaped}
'
)
# Tool calls
tool_calls = turn.get("tool_calls", [])
for j, tc in enumerate(tool_calls):
tc_html = self._render_tool_call(tc, options, f"{i}-{j}")
turn_parts.append(tc_html)
parts.append(
f'
'
f'{"".join(turn_parts)}'
f'
'
)
return "\n".join(parts)
def _render_user_message(self, turn: Dict[str, Any], index: int) -> str:
"""Render a user message bubble."""
content = _escape(turn.get("content", ""))
return (
f'
'
f'
User
'
f'
{content}
'
f'
'
)
def _render_tool_call(self, tc: Dict[str, Any], options: Dict[str, Any],
tc_id: str) -> str:
"""Render a single tool call with appropriate formatting."""
tool_name = tc.get("tool", "unknown")
tool_input = tc.get("input", {})
tool_output = tc.get("output", "")
output_type = tc.get("output_type", "")
tool_type = _classify_tool(tool_name)
# If output_type not specified, infer from tool
if not output_type:
output_type = tool_type
# Badge
bg, fg, border = TOOL_BADGE_COLORS.get(tool_type, TOOL_BADGE_COLORS["generic"])
badge = (
f'{_escape(tool_name)}'
)
# File path header (if applicable)
file_path = ""
if isinstance(tool_input, dict):
file_path = tool_input.get("file_path", tool_input.get("path", ""))
file_header = ""
if file_path:
file_header = f'{_escape(file_path)}'
# Render input/output based on tool type
if tool_type == "edit":
body = self._render_diff(tool_input, tool_output, options)
elif tool_type == "bash":
body = self._render_terminal(tool_input, tool_output, options)
elif tool_type in ("read", "search"):
body = self._render_code_output(tool_input, tool_output, options)
elif tool_type == "write":
body = self._render_write(tool_input, tool_output, options)
else:
body = self._render_generic(tool_input, tool_output, options)
return (
f'
'
f'
{badge}{file_header}
'
f'
{body}
'
f'
'
)
def _render_diff(self, tool_input: Any, tool_output: Any,
options: Dict[str, Any]) -> str:
"""Render an edit as a unified diff."""
if not isinstance(tool_input, dict):
return self._render_generic(tool_input, tool_output, options)
old_string = tool_input.get("old_string", "")
new_string = tool_input.get("new_string", "")
file_path = tool_input.get("file_path", "")
if not old_string and not new_string:
return self._render_generic(tool_input, tool_output, options)
# Build unified diff view
old_lines = old_string.split("\n") if old_string else []
new_lines = new_string.split("\n") if new_string else []
diff_parts = []
diff_parts.append('
')
# Removed lines
for line in old_lines:
escaped = _escape(line)
diff_parts.append(
f'
'
f'-'
f'{escaped}'
f'
'
)
# Added lines
for line in new_lines:
escaped = _escape(line)
diff_parts.append(
f'
'
f'+'
f'{escaped}'
f'
'
)
diff_parts.append('
')
# Status message
status = ""
if tool_output:
output_str = str(tool_output)
if output_str:
status = f'
{_escape(output_str)}
'
return "\n".join(diff_parts) + status
def _render_terminal(self, tool_input: Any, tool_output: Any,
options: Dict[str, Any]) -> str:
"""Render a terminal command and its output."""
command = ""
if isinstance(tool_input, dict):
command = tool_input.get("command", tool_input.get("cmd", ""))
elif isinstance(tool_input, str):
command = tool_input
output_str = str(tool_output) if tool_output else ""
max_lines = options.get("max_output_lines", 50)
collapse = options.get("collapse_long_outputs", True)
parts = []
parts.append('
')
# Command line
if command:
parts.append(
f'
'
f'$ '
f'{_escape(command)}'
f'
'
)
# Output
if output_str:
truncated, was_truncated = _truncate_output(output_str, max_lines)
if was_truncated and collapse:
parts.append(
f''
f'Output ({len(output_str.splitlines())} lines — click to expand)'
f'
{_escape(output_str)}
'
f''
f'
{_escape(truncated)}
'
)
else:
parts.append(
f'
{_escape(output_str)}
'
)
parts.append('
')
return "\n".join(parts)
def _render_code_output(self, tool_input: Any, tool_output: Any,
options: Dict[str, Any]) -> str:
"""Render a code read/search result with line numbers."""
file_path = ""
if isinstance(tool_input, dict):
file_path = tool_input.get("file_path", tool_input.get("path", ""))
output_str = str(tool_output) if tool_output else ""
language = _detect_language(file_path)
max_lines = options.get("max_output_lines", 50)
collapse = options.get("collapse_long_outputs", True)
if not output_str:
return '
')
for path in sorted(files.keys()):
ops = files[path]
# Pick the most significant operation for the name color
op = "write" if "write" in ops else "edit" if "edit" in ops else "read"
color = op_colors.get(op, "#666")
basename = os.path.basename(path) or path
op_badges = " ".join(
f''
f'{o[0].upper()}'
for o in sorted(ops)
)
parts.append(
f'
'
f'{_escape(basename)}'
f'{op_badges}'
f'
'
)
parts.append('
')
parts.append('
')
return "\n".join(parts), len(files)
def _build_js_init(self, field_key: str) -> str:
"""Build client-side JS for collapse/expand and sidebar toggle."""
esc_key = _escape(field_key)
return f'''
(function() {{
var container = document.querySelector('.coding-trace-display[data-field-key="{esc_key}"]');
if (!container) return;
// Collapse/expand tool outputs
var toggleBtn = container.querySelector('[data-action="toggle-tools"]');
if (toggleBtn) {{
var collapsed = false;
toggleBtn.addEventListener('click', function() {{
collapsed = !collapsed;
container.querySelectorAll('.ct-tool-call').forEach(function(tc) {{
tc.classList.toggle('ct-tool-collapsed', collapsed);
}});
toggleBtn.textContent = collapsed ? 'Expand outputs' : 'Collapse outputs';
}});
}}
// Toggle sidebar
var sidebarBtn = container.querySelector('[data-action="toggle-sidebar"]');
var sidebar = document.getElementById('ct-sidebar-{esc_key}');
if (sidebarBtn && sidebar) {{
sidebarBtn.addEventListener('click', function() {{
sidebar.classList.toggle('ct-sidebar-collapsed');
}});
}}
}})();
'''
def _extract_plain_text(self, turns: List[Dict[str, Any]]) -> str:
"""Extract plain text from reasoning for span annotation."""
parts = []
for turn in turns:
content = turn.get("content", "")
if content and turn.get("role") != "user":
parts.append(content)
return "\n".join(parts)
def _extract_reasoning_html(self, turns: List[Dict[str, Any]]) -> str:
"""Extract reasoning HTML for span target wrapper."""
parts = []
for turn in turns:
content = turn.get("content", "")
if content and turn.get("role") != "user":
parts.append(_escape(content))
return " ".join(parts)
def get_css_classes(self, field_config: Dict[str, Any]) -> List[str]:
classes = super().get_css_classes(field_config)
if field_config.get("span_target"):
classes.append("span-target-field")
return classes
def get_data_attributes(self, field_config: Dict[str, Any], data: Any) -> Dict[str, str]:
attrs = super().get_data_attributes(field_config, data)
if field_config.get("span_target"):
attrs["span-target"] = "true"
return attrs