| """ |
| 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 |
|
|
|
|
| |
| 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"} |
|
|
| |
| 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", |
| } |
|
|
| |
| TOOL_BADGE_COLORS = { |
| "read": ("#e3f2fd", "#1565c0", "#1976d2"), |
| "edit": ("#fff3e0", "#e65100", "#ef6c00"), |
| "write": ("#e8f5e9", "#2e7d32", "#388e3c"), |
| "bash": ("#263238", "#b0bec5", "#78909c"), |
| "search": ("#f3e5f5", "#6a1b9a", "#7b1fa2"), |
| "generic": ("#f5f5f5", "#424242", "#616161"), |
| } |
|
|
|
|
| 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", |
| "collapse_long_outputs": True, |
| "max_output_lines": 50, |
| "terminal_theme": "dark", |
| "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) |
|
|
| |
| 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 |
|
|
| |
| file_tree_html = "" |
| file_count = 0 |
| if options.get("show_file_tree", True): |
| file_tree_html, file_count = self._build_file_tree(turns) |
|
|
| |
| turns_html = self._build_turns(turns, options, field_key, is_span_target) |
|
|
| |
| total_tools = sum(len(t.get("tool_calls", [])) for t in turns) |
| summary_html = self._build_summary(turns, file_tree_html, file_count) |
|
|
| |
| 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 "" |
| |
| sidebar_class = "ct-sidebar-collapsed" if file_count <= 1 and file_tree_html else "" |
|
|
| |
| 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): |
| |
| 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", []) |
|
|
| |
| if isinstance(reasoning, list): |
| |
| 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") |
|
|
| |
| 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>' |
| ) |
|
|
| |
| 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 |
|
|
| |
| turn_parts = [] |
|
|
| |
| if show_numbers: |
| turn_parts.append( |
| f'<div class="ct-turn-header">' |
| f'<span class="ct-step-num">Step {step_counter}</span>' |
| f'</div>' |
| ) |
|
|
| |
| content = turn.get("content", "") |
| if content and show_reasoning: |
| escaped = _escape(content) |
| turn_parts.append( |
| f'<div class="ct-reasoning">{escaped}</div>' |
| ) |
|
|
| |
| 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 not output_type: |
| output_type = tool_type |
|
|
| |
| 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 = "" |
| 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>' |
|
|
| |
| 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) |
|
|
| |
| 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">') |
|
|
| |
| 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>' |
| ) |
|
|
| |
| 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 = "" |
| 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">') |
|
|
| |
| if command: |
| parts.append( |
| f'<div class="ct-terminal-cmd">' |
| f'<span class="ct-terminal-prompt">$</span> ' |
| f'{_escape(command)}' |
| f'</div>' |
| ) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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>') |
|
|
| |
| 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]] = {} |
|
|
| 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 |
|
|
| |
| 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] |
| |
| 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 |
|
|