Spaces:
Paused
Paused
| """ | |
| 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 '<div class="coding-trace-empty">No trace data provided</div>' | |
| 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 = '<div class="coding-trace-empty">No trace steps found</div>' | |
| 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''' | |
| <div class="coding-trace-display {layout_class}" data-field-key="{field_key}"> | |
| {summary_html} | |
| <div class="coding-trace-layout"> | |
| {f'<div class="coding-trace-sidebar {sidebar_class}" id="ct-sidebar-{field_key}">{file_tree_html}</div>' if file_tree_html else ''} | |
| <div class="coding-trace-main"> | |
| {span_wrapper} | |
| {turns_html} | |
| </div> | |
| </div> | |
| </div> | |
| <script>{js_init}</script> | |
| ''' | |
| 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'<span class="ct-summary-badge" style="background:{bg};color:{fg}">' | |
| f'{count} {tool_type}</span>' | |
| ) | |
| # Toggle buttons | |
| sidebar_toggle = "" | |
| if file_tree_html: | |
| sidebar_toggle = ( | |
| '<button type="button" class="ct-sidebar-toggle" ' | |
| 'data-action="toggle-sidebar" ' | |
| f'title="Toggle file tree">Files ({file_count})</button>' | |
| ) | |
| collapse_toggle = "" | |
| if total_tools > 2: | |
| collapse_toggle = ( | |
| '<button type="button" class="ct-toggle-tools" ' | |
| 'data-action="toggle-tools" ' | |
| 'title="Collapse/expand tool outputs">Collapse outputs</button>' | |
| ) | |
| return f''' | |
| <div class="ct-summary"> | |
| <span class="ct-summary-total">{assistant_turns} turn{"s" if assistant_turns != 1 else ""}</span> | |
| <span class="ct-summary-sep">·</span> | |
| <span class="ct-summary-tools">{total_tools} tool call{"s" if total_tools != 1 else ""}</span> | |
| {" ".join(badges)} | |
| {sidebar_toggle} | |
| {collapse_toggle} | |
| </div> | |
| ''' | |
| 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'<div class="ct-turn-header">' | |
| f'<span class="ct-step-num">Step {step_counter}</span>' | |
| f'</div>' | |
| ) | |
| # Reasoning text | |
| content = turn.get("content", "") | |
| if content and show_reasoning: | |
| escaped = _escape(content) | |
| turn_parts.append( | |
| f'<div class="ct-reasoning">{escaped}</div>' | |
| ) | |
| # 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'<div class="ct-turn ct-turn-assistant" data-turn-index="{i}">' | |
| f'{"".join(turn_parts)}' | |
| f'</div>' | |
| ) | |
| 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'<div class="ct-turn ct-turn-user" data-turn-index="{index}">' | |
| f'<div class="ct-user-badge">User</div>' | |
| f'<div class="ct-user-text">{content}</div>' | |
| f'</div>' | |
| ) | |
| 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'<span class="ct-tool-badge" style="background:{bg};color:{fg};' | |
| f'border:1px solid {border}">{_escape(tool_name)}</span>' | |
| ) | |
| # 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'<span class="ct-file-path">{_escape(file_path)}</span>' | |
| # 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'<div class="ct-tool-call ct-tool-{_escape(tool_type)}" data-tool-id="{_escape(tc_id)}">' | |
| f'<div class="ct-tool-header">{badge}{file_header}</div>' | |
| f'<div class="ct-tool-body">{body}</div>' | |
| f'</div>' | |
| ) | |
| 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('<div class="ct-diff">') | |
| # Removed lines | |
| for line in old_lines: | |
| escaped = _escape(line) | |
| diff_parts.append( | |
| f'<div class="ct-diff-line ct-diff-removed">' | |
| f'<span class="ct-diff-marker">-</span>' | |
| f'<span class="ct-diff-text">{escaped}</span>' | |
| f'</div>' | |
| ) | |
| # Added lines | |
| for line in new_lines: | |
| escaped = _escape(line) | |
| diff_parts.append( | |
| f'<div class="ct-diff-line ct-diff-added">' | |
| f'<span class="ct-diff-marker">+</span>' | |
| f'<span class="ct-diff-text">{escaped}</span>' | |
| f'</div>' | |
| ) | |
| diff_parts.append('</div>') | |
| # Status message | |
| status = "" | |
| if tool_output: | |
| output_str = str(tool_output) | |
| if output_str: | |
| status = f'<div class="ct-edit-status">{_escape(output_str)}</div>' | |
| 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('<div class="ct-terminal">') | |
| # Command line | |
| if command: | |
| parts.append( | |
| f'<div class="ct-terminal-cmd">' | |
| f'<span class="ct-terminal-prompt">$</span> ' | |
| f'{_escape(command)}' | |
| f'</div>' | |
| ) | |
| # Output | |
| if output_str: | |
| truncated, was_truncated = _truncate_output(output_str, max_lines) | |
| if was_truncated and collapse: | |
| parts.append( | |
| f'<details class="ct-terminal-output-details">' | |
| f'<summary>Output ({len(output_str.splitlines())} lines — click to expand)</summary>' | |
| f'<pre class="ct-terminal-output">{_escape(output_str)}</pre>' | |
| f'</details>' | |
| f'<pre class="ct-terminal-output ct-terminal-truncated">{_escape(truncated)}</pre>' | |
| ) | |
| else: | |
| parts.append( | |
| f'<pre class="ct-terminal-output">{_escape(output_str)}</pre>' | |
| ) | |
| parts.append('</div>') | |
| 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 '<div class="ct-code-empty">No output</div>' | |
| 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'<div class="ct-code-block" data-language="{_escape(language)}">') | |
| code_parts.append('<table class="ct-code-table">') | |
| for i, line in enumerate(display_lines, 1): | |
| escaped = _escape(line) if line else " " | |
| code_parts.append( | |
| f'<tr class="ct-code-line">' | |
| f'<td class="ct-line-num">{i}</td>' | |
| f'<td class="ct-line-content"><code>{escaped}</code></td>' | |
| f'</tr>' | |
| ) | |
| code_parts.append('</table>') | |
| if was_truncated and collapse: | |
| remaining = len(lines) - max_lines | |
| code_parts.append( | |
| f'<div class="ct-code-truncated">' | |
| f'... {remaining} more line{"s" if remaining != 1 else ""}' | |
| f'</div>' | |
| ) | |
| code_parts.append('</div>') | |
| 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'<div class="ct-write-block" data-language="{_escape(language)}">') | |
| parts.append('<div class="ct-write-label">New file</div>') | |
| parts.append('<table class="ct-code-table">') | |
| for i, line in enumerate(display_lines, 1): | |
| escaped = _escape(line) if line else " " | |
| parts.append( | |
| f'<tr class="ct-code-line ct-diff-added">' | |
| f'<td class="ct-line-num">{i}</td>' | |
| f'<td class="ct-line-content"><code>{escaped}</code></td>' | |
| f'</tr>' | |
| ) | |
| parts.append('</table>') | |
| if was_truncated: | |
| remaining = len(lines) - max_lines | |
| parts.append( | |
| f'<div class="ct-code-truncated">' | |
| f'... {remaining} more line{"s" if remaining != 1 else ""}' | |
| f'</div>' | |
| ) | |
| parts.append('</div>') | |
| # Status | |
| if tool_output: | |
| parts.append(f'<div class="ct-edit-status">{_escape(str(tool_output))}</div>') | |
| 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'<div class="ct-generic-input">' | |
| f'<div class="ct-generic-label">Input</div>' | |
| f'<pre class="ct-generic-pre">{_escape(formatted)}</pre>' | |
| f'</div>' | |
| ) | |
| 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 = '<div class="ct-code-truncated">... output truncated</div>' if was_truncated else "" | |
| parts.append( | |
| f'<div class="ct-generic-output">' | |
| f'<div class="ct-generic-label">Output</div>' | |
| f'<pre class="ct-generic-pre">{_escape(truncated if was_truncated else output_str)}</pre>' | |
| f'{truncated_div}' | |
| f'</div>' | |
| ) | |
| return "\n".join(parts) if parts else '<div class="ct-generic-empty">No data</div>' | |
| 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('<div class="ct-file-tree">') | |
| parts.append('<div class="ct-file-tree-header">') | |
| parts.append(f'<span>Files ({len(files)})</span>') | |
| parts.append('</div>') | |
| parts.append('<ul class="ct-file-list">') | |
| 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'<span class="ct-file-op" style="color:{op_colors.get(o, "#666")}">' | |
| f'{o[0].upper()}</span>' | |
| for o in sorted(ops) | |
| ) | |
| parts.append( | |
| f'<li class="ct-file-item" title="{_escape(path)}">' | |
| f'<span class="ct-file-name" style="color:{color}">{_escape(basename)}</span>' | |
| f'{op_badges}' | |
| f'</li>' | |
| ) | |
| parts.append('</ul>') | |
| parts.append('</div>') | |
| 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 "<br>".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 | |