""" 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 '
No output
' lines = output_str.split("\n") truncated, was_truncated = _truncate_output(output_str, max_lines) # Build line-numbered code block display_lines = truncated.split("\n") if was_truncated and collapse else lines code_parts = [] code_parts.append(f'
') code_parts.append('') for i, line in enumerate(display_lines, 1): escaped = _escape(line) if line else " " code_parts.append( f'' f'' f'' f'' ) code_parts.append('
{i}{escaped}
') if was_truncated and collapse: remaining = len(lines) - max_lines code_parts.append( f'
' f'... {remaining} more line{"s" if remaining != 1 else ""}' f'
' ) code_parts.append('
') return "\n".join(code_parts) def _render_write(self, tool_input: Any, tool_output: Any, options: Dict[str, Any]) -> str: """Render a file write operation.""" content = "" file_path = "" if isinstance(tool_input, dict): content = tool_input.get("content", "") file_path = tool_input.get("file_path", "") if not content: return self._render_generic(tool_input, tool_output, options) language = _detect_language(file_path) max_lines = options.get("max_output_lines", 50) # Show as code block with "new file" styling lines = content.split("\n") truncated, was_truncated = _truncate_output(content, max_lines) display_lines = truncated.split("\n") if was_truncated else lines parts = [] parts.append(f'
') parts.append('
New file
') parts.append('') for i, line in enumerate(display_lines, 1): escaped = _escape(line) if line else " " parts.append( f'' f'' f'' f'' ) parts.append('
{i}{escaped}
') if was_truncated: remaining = len(lines) - max_lines parts.append( f'
' f'... {remaining} more line{"s" if remaining != 1 else ""}' f'
' ) parts.append('
') # Status if tool_output: parts.append(f'
{_escape(str(tool_output))}
') return "\n".join(parts) def _render_generic(self, tool_input: Any, tool_output: Any, options: Dict[str, Any]) -> str: """Render a generic tool call as formatted JSON.""" parts = [] if tool_input: try: if isinstance(tool_input, dict): formatted = json.dumps(tool_input, indent=2, ensure_ascii=False) else: formatted = str(tool_input) except (TypeError, ValueError): formatted = str(tool_input) parts.append( f'
' f'
Input
' f'
{_escape(formatted)}
' f'
' ) if tool_output: output_str = str(tool_output) max_lines = options.get("max_output_lines", 50) truncated, was_truncated = _truncate_output(output_str, max_lines) truncated_div = '
... output truncated
' if was_truncated else "" parts.append( f'
' f'
Output
' f'
{_escape(truncated if was_truncated else output_str)}
' f'{truncated_div}' f'
' ) return "\n".join(parts) if parts else '
No data
' def _build_file_tree(self, turns: List[Dict[str, Any]]) -> Tuple[str, int]: """Build a file tree sidebar from all tool calls. Returns: Tuple of (html_string, file_count). """ files: Dict[str, Set[str]] = {} # path -> set of operations for turn in turns: for tc in turn.get("tool_calls", []): tool_name = tc.get("tool", "") tool_type = _classify_tool(tool_name) tool_input = tc.get("input", {}) if isinstance(tool_input, dict): file_path = tool_input.get("file_path", tool_input.get("path", "")) if file_path: if file_path not in files: files[file_path] = set() files[file_path].add(tool_type) if not files: return "", 0 # Operation badge colors (domain-specific, kept as-is) op_colors = { "read": "#1976d2", "edit": "#ef6c00", "write": "#388e3c", "search": "#7b1fa2", "bash": "#78909c", } parts = [] parts.append('
') parts.append('
') parts.append(f'Files ({len(files)})') parts.append('
') 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