From 0c6ac46646a728bc1c5640e5fae0e1ba4a458a24 Mon Sep 17 00:00:00 2001 From: Aliou Diallo Date: Fri, 16 Jan 2026 00:32:31 +0100 Subject: [PATCH] feat(coding-agent): Custom tool export rendering in export (#702) * coding-agent: add ANSI-to-HTML converter for export * coding-agent: add getToolDefinition method to ExtensionRunner * coding-agent: add tool HTML renderer factory for custom tools * coding-agent: add custom tool pre-rendering to HTML export * coding-agent: render pre-rendered custom tools in HTML export * coding-agent: integrate tool renderer in exportToHtml --- .../coding-agent/src/core/agent-session.ts | 20 +- .../src/core/export-html/ansi-to-html.ts | 258 ++++++++++++++++++ .../src/core/export-html/index.ts | 90 +++++- .../src/core/export-html/template.js | 42 ++- .../src/core/export-html/tool-renderer.ts | 90 ++++++ .../src/core/extensions/runner.ts | 11 + 6 files changed, 502 insertions(+), 9 deletions(-) create mode 100644 packages/coding-agent/src/core/export-html/ansi-to-html.ts create mode 100644 packages/coding-agent/src/core/export-html/tool-renderer.ts diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 94368f50..bcd7bd43 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -24,6 +24,7 @@ import type { import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai"; import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai"; import { getAuthPath } from "../config.js"; +import { theme } from "../modes/interactive/theme/theme.js"; import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor.js"; import { type CompactionResult, @@ -34,7 +35,8 @@ import { prepareCompaction, shouldCompact, } from "./compaction/index.js"; -import { exportSessionToHtml } from "./export-html/index.js"; +import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js"; +import { createToolHtmlRenderer } from "./export-html/tool-renderer.js"; import type { ExtensionRunner, SessionBeforeCompactResult, @@ -2148,7 +2150,21 @@ export class AgentSession { */ async exportToHtml(outputPath?: string): Promise { const themeName = this.settingsManager.getTheme(); - return await exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName }); + + // Create tool renderer if we have an extension runner (for custom tool HTML rendering) + let toolRenderer: ToolHtmlRenderer | undefined; + if (this._extensionRunner) { + toolRenderer = createToolHtmlRenderer({ + getToolDefinition: (name) => this._extensionRunner!.getToolDefinition(name), + theme, + }); + } + + return await exportSessionToHtml(this.sessionManager, this.state, { + outputPath, + themeName, + toolRenderer, + }); } // ========================================================================= diff --git a/packages/coding-agent/src/core/export-html/ansi-to-html.ts b/packages/coding-agent/src/core/export-html/ansi-to-html.ts new file mode 100644 index 00000000..92a3974e --- /dev/null +++ b/packages/coding-agent/src/core/export-html/ansi-to-html.ts @@ -0,0 +1,258 @@ +/** + * ANSI escape code to HTML converter. + * + * Converts terminal ANSI color/style codes to HTML with inline styles. + * Supports: + * - Standard foreground colors (30-37) and bright variants (90-97) + * - Standard background colors (40-47) and bright variants (100-107) + * - 256-color palette (38;5;N and 48;5;N) + * - RGB true color (38;2;R;G;B and 48;2;R;G;B) + * - Text styles: bold (1), dim (2), italic (3), underline (4) + * - Reset (0) + */ + +// Standard ANSI color palette (0-15) +const ANSI_COLORS = [ + "#000000", // 0: black + "#800000", // 1: red + "#008000", // 2: green + "#808000", // 3: yellow + "#000080", // 4: blue + "#800080", // 5: magenta + "#008080", // 6: cyan + "#c0c0c0", // 7: white + "#808080", // 8: bright black + "#ff0000", // 9: bright red + "#00ff00", // 10: bright green + "#ffff00", // 11: bright yellow + "#0000ff", // 12: bright blue + "#ff00ff", // 13: bright magenta + "#00ffff", // 14: bright cyan + "#ffffff", // 15: bright white +]; + +/** + * Convert 256-color index to hex. + */ +function color256ToHex(index: number): string { + // Standard colors (0-15) + if (index < 16) { + return ANSI_COLORS[index]; + } + + // Color cube (16-231): 6x6x6 = 216 colors + if (index < 232) { + const cubeIndex = index - 16; + const r = Math.floor(cubeIndex / 36); + const g = Math.floor((cubeIndex % 36) / 6); + const b = cubeIndex % 6; + const toComponent = (n: number) => (n === 0 ? 0 : 55 + n * 40); + const toHex = (n: number) => toComponent(n).toString(16).padStart(2, "0"); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + } + + // Grayscale (232-255): 24 shades + const gray = 8 + (index - 232) * 10; + const grayHex = gray.toString(16).padStart(2, "0"); + return `#${grayHex}${grayHex}${grayHex}`; +} + +/** + * Escape HTML special characters. + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +interface TextStyle { + fg: string | null; + bg: string | null; + bold: boolean; + dim: boolean; + italic: boolean; + underline: boolean; +} + +function createEmptyStyle(): TextStyle { + return { + fg: null, + bg: null, + bold: false, + dim: false, + italic: false, + underline: false, + }; +} + +function styleToInlineCSS(style: TextStyle): string { + const parts: string[] = []; + if (style.fg) parts.push(`color:${style.fg}`); + if (style.bg) parts.push(`background-color:${style.bg}`); + if (style.bold) parts.push("font-weight:bold"); + if (style.dim) parts.push("opacity:0.6"); + if (style.italic) parts.push("font-style:italic"); + if (style.underline) parts.push("text-decoration:underline"); + return parts.join(";"); +} + +function hasStyle(style: TextStyle): boolean { + return style.fg !== null || style.bg !== null || style.bold || style.dim || style.italic || style.underline; +} + +/** + * Parse ANSI SGR (Select Graphic Rendition) codes and update style. + */ +function applySgrCode(params: number[], style: TextStyle): void { + let i = 0; + while (i < params.length) { + const code = params[i]; + + if (code === 0) { + // Reset all + style.fg = null; + style.bg = null; + style.bold = false; + style.dim = false; + style.italic = false; + style.underline = false; + } else if (code === 1) { + style.bold = true; + } else if (code === 2) { + style.dim = true; + } else if (code === 3) { + style.italic = true; + } else if (code === 4) { + style.underline = true; + } else if (code === 22) { + // Reset bold/dim + style.bold = false; + style.dim = false; + } else if (code === 23) { + style.italic = false; + } else if (code === 24) { + style.underline = false; + } else if (code >= 30 && code <= 37) { + // Standard foreground colors + style.fg = ANSI_COLORS[code - 30]; + } else if (code === 38) { + // Extended foreground color + if (params[i + 1] === 5 && params.length > i + 2) { + // 256-color: 38;5;N + style.fg = color256ToHex(params[i + 2]); + i += 2; + } else if (params[i + 1] === 2 && params.length > i + 4) { + // RGB: 38;2;R;G;B + const r = params[i + 2]; + const g = params[i + 3]; + const b = params[i + 4]; + style.fg = `rgb(${r},${g},${b})`; + i += 4; + } + } else if (code === 39) { + // Default foreground + style.fg = null; + } else if (code >= 40 && code <= 47) { + // Standard background colors + style.bg = ANSI_COLORS[code - 40]; + } else if (code === 48) { + // Extended background color + if (params[i + 1] === 5 && params.length > i + 2) { + // 256-color: 48;5;N + style.bg = color256ToHex(params[i + 2]); + i += 2; + } else if (params[i + 1] === 2 && params.length > i + 4) { + // RGB: 48;2;R;G;B + const r = params[i + 2]; + const g = params[i + 3]; + const b = params[i + 4]; + style.bg = `rgb(${r},${g},${b})`; + i += 4; + } + } else if (code === 49) { + // Default background + style.bg = null; + } else if (code >= 90 && code <= 97) { + // Bright foreground colors + style.fg = ANSI_COLORS[code - 90 + 8]; + } else if (code >= 100 && code <= 107) { + // Bright background colors + style.bg = ANSI_COLORS[code - 100 + 8]; + } + // Ignore unrecognized codes + + i++; + } +} + +// Match ANSI escape sequences: ESC[ followed by params and ending with 'm' +const ANSI_REGEX = /\x1b\[([\d;]*)m/g; + +/** + * Convert ANSI-escaped text to HTML with inline styles. + */ +export function ansiToHtml(text: string): string { + const style = createEmptyStyle(); + let result = ""; + let lastIndex = 0; + let inSpan = false; + + // Reset regex state + ANSI_REGEX.lastIndex = 0; + + let match = ANSI_REGEX.exec(text); + while (match !== null) { + // Add text before this escape sequence + const beforeText = text.slice(lastIndex, match.index); + if (beforeText) { + result += escapeHtml(beforeText); + } + + // Parse SGR parameters + const paramStr = match[1]; + const params = paramStr ? paramStr.split(";").map((p) => parseInt(p, 10) || 0) : [0]; + + // Close existing span if we have one + if (inSpan) { + result += ""; + inSpan = false; + } + + // Apply the codes + applySgrCode(params, style); + + // Open new span if we have any styling + if (hasStyle(style)) { + result += ``; + inSpan = true; + } + + lastIndex = match.index + match[0].length; + match = ANSI_REGEX.exec(text); + } + + // Add remaining text + const remainingText = text.slice(lastIndex); + if (remainingText) { + result += escapeHtml(remainingText); + } + + // Close any open span + if (inSpan) { + result += ""; + } + + return result; +} + +/** + * Convert array of ANSI-escaped lines to HTML. + * Each line is wrapped in a div element. + */ +export function ansiLinesToHtml(lines: string[]): string { + return lines.map((line) => `
${ansiToHtml(line) || " "}
`).join("\n"); +} diff --git a/packages/coding-agent/src/core/export-html/index.ts b/packages/coding-agent/src/core/export-html/index.ts index aa1ae913..28a6b949 100644 --- a/packages/coding-agent/src/core/export-html/index.ts +++ b/packages/coding-agent/src/core/export-html/index.ts @@ -4,11 +4,36 @@ import { existsSync, readFileSync, writeFileSync } from "fs"; import { basename, join } from "path"; import { APP_NAME, getExportTemplateDir } from "../../config.js"; import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.js"; +import type { SessionEntry } from "../session-manager.js"; import { SessionManager } from "../session-manager.js"; +/** + * Interface for rendering custom tools to HTML. + * Used by agent-session to pre-render extension tool output. + */ +export interface ToolHtmlRenderer { + /** Render a tool call to HTML. Returns undefined if tool has no custom renderer. */ + renderCall(toolName: string, args: unknown): string | undefined; + /** Render a tool result to HTML. Returns undefined if tool has no custom renderer. */ + renderResult( + toolName: string, + result: Array<{ type: string; text?: string; data?: string; mimeType?: string }>, + details: unknown, + isError: boolean, + ): string | undefined; +} + +/** Pre-rendered HTML for a custom tool call and result */ +interface RenderedToolHtml { + callHtml?: string; + resultHtml?: string; +} + export interface ExportOptions { outputPath?: string; themeName?: string; + /** Optional tool renderer for custom tools */ + toolRenderer?: ToolHtmlRenderer; } /** Info about Codex injection to show inline with model_change entries */ @@ -138,6 +163,8 @@ interface SessionData { /** Info for rendering Codex injection inline with model_change entries */ codexInjectionInfo?: CodexInjectionInfo; tools?: { name: string; description: string }[]; + /** Pre-rendered HTML for custom tool calls/results, keyed by tool call ID */ + renderedTools?: Record; } /** @@ -176,6 +203,54 @@ function generateHtml(sessionData: SessionData, themeName?: string): string { .replace("{{HIGHLIGHT_JS}}", hljsJs); } +/** Built-in tool names that have custom rendering in template.js */ +const BUILTIN_TOOLS = new Set(["bash", "read", "write", "edit", "ls", "find", "grep"]); + +/** + * Pre-render custom tools to HTML using their TUI renderers. + */ +function preRenderCustomTools( + entries: SessionEntry[], + toolRenderer: ToolHtmlRenderer, +): Record { + const renderedTools: Record = {}; + + for (const entry of entries) { + if (entry.type !== "message") continue; + const msg = entry.message; + + // Find tool calls in assistant messages + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "toolCall" && !BUILTIN_TOOLS.has(block.name)) { + const callHtml = toolRenderer.renderCall(block.name, block.arguments); + if (callHtml) { + renderedTools[block.id] = { callHtml }; + } + } + } + } + + // Find tool results + if (msg.role === "toolResult" && msg.toolCallId) { + const toolName = msg.toolName || ""; + // Only render if we have a pre-rendered call OR it's not a built-in tool + const existing = renderedTools[msg.toolCallId]; + if (existing || !BUILTIN_TOOLS.has(toolName)) { + const resultHtml = toolRenderer.renderResult(toolName, msg.content, msg.details, msg.isError || false); + if (resultHtml) { + renderedTools[msg.toolCallId] = { + ...existing, + resultHtml, + }; + } + } + } + } + + return renderedTools; +} + /** * Export session to HTML using SessionManager and AgentState. * Used by TUI's /export command. @@ -195,13 +270,26 @@ export async function exportSessionToHtml( throw new Error("Nothing to export yet - start a conversation first"); } + const entries = sm.getEntries(); + + // Pre-render custom tools if a tool renderer is provided + let renderedTools: Record | undefined; + if (opts.toolRenderer) { + renderedTools = preRenderCustomTools(entries, opts.toolRenderer); + // Only include if we actually rendered something + if (Object.keys(renderedTools).length === 0) { + renderedTools = undefined; + } + } + const sessionData: SessionData = { header: sm.getHeader(), - entries: sm.getEntries(), + entries, leafId: sm.getLeafId(), systemPrompt: state?.systemPrompt, codexInjectionInfo: await buildCodexInjectionInfo(state?.tools), tools: state?.tools?.map((t) => ({ name: t.name, description: t.description })), + renderedTools, }; const html = generateHtml(sessionData, opts.themeName); diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index 05c55029..9c97e8a1 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -12,7 +12,7 @@ bytes[i] = binary.charCodeAt(i); } const data = JSON.parse(new TextDecoder('utf-8').decode(bytes)); - const { header, entries, leafId: defaultLeafId, systemPrompt, codexInjectionInfo, tools } = data; + const { header, entries, leafId: defaultLeafId, systemPrompt, codexInjectionInfo, tools, renderedTools } = data; // ============================================================ // URL PARAMETER HANDLING @@ -911,11 +911,41 @@ break; } default: { - html += `
${escapeHtml(name)}
`; - html += `
${escapeHtml(JSON.stringify(args, null, 2))}
`; - if (result) { - const output = getResultText(); - if (output) html += formatExpandableOutput(output, 10); + // Check for pre-rendered custom tool HTML + const rendered = renderedTools?.[call.id]; + if (rendered?.callHtml || rendered?.resultHtml) { + // Custom tool with pre-rendered HTML from TUI renderer + if (rendered.callHtml) { + html += `
${rendered.callHtml}
`; + } else { + html += `
${escapeHtml(name)}
`; + } + + if (rendered.resultHtml) { + // Apply same truncation as built-in tools (10 lines) + const lines = rendered.resultHtml.split('\n'); + if (lines.length > 10) { + const preview = lines.slice(0, 10).join('\n'); + html += ``; + } else { + html += `
${rendered.resultHtml}
`; + } + } else if (result) { + // Fallback to JSON for result if no pre-rendered HTML + const output = getResultText(); + if (output) html += formatExpandableOutput(output, 10); + } + } else { + // Fallback to JSON display (existing behavior) + html += `
${escapeHtml(name)}
`; + html += `
${escapeHtml(JSON.stringify(args, null, 2))}
`; + if (result) { + const output = getResultText(); + if (output) html += formatExpandableOutput(output, 10); + } } } } diff --git a/packages/coding-agent/src/core/export-html/tool-renderer.ts b/packages/coding-agent/src/core/export-html/tool-renderer.ts new file mode 100644 index 00000000..455dd0cc --- /dev/null +++ b/packages/coding-agent/src/core/export-html/tool-renderer.ts @@ -0,0 +1,90 @@ +/** + * Tool HTML renderer for custom tools in HTML export. + * + * Renders custom tool calls and results to HTML by invoking their TUI renderers + * and converting the ANSI output to HTML. + */ + +import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; +import type { Theme } from "../../modes/interactive/theme/theme.js"; +import type { ToolDefinition } from "../extensions/types.js"; +import { ansiLinesToHtml } from "./ansi-to-html.js"; + +export interface ToolHtmlRendererDeps { + /** Function to look up tool definition by name */ + getToolDefinition: (name: string) => ToolDefinition | undefined; + /** Theme for styling */ + theme: Theme; + /** Terminal width for rendering (default: 100) */ + width?: number; +} + +export interface ToolHtmlRenderer { + /** Render a tool call to HTML. Returns undefined if tool has no custom renderer. */ + renderCall(toolName: string, args: unknown): string | undefined; + /** Render a tool result to HTML. Returns undefined if tool has no custom renderer. */ + renderResult( + toolName: string, + result: Array<{ type: string; text?: string; data?: string; mimeType?: string }>, + details: unknown, + isError: boolean, + ): string | undefined; +} + +/** + * Create a tool HTML renderer. + * + * The renderer looks up tool definitions and invokes their renderCall/renderResult + * methods, converting the resulting TUI Component output (ANSI) to HTML. + */ +export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRenderer { + const { getToolDefinition, theme, width = 100 } = deps; + + return { + renderCall(toolName: string, args: unknown): string | undefined { + try { + const toolDef = getToolDefinition(toolName); + if (!toolDef?.renderCall) { + return undefined; + } + + const component = toolDef.renderCall(args, theme); + const lines = component.render(width); + return ansiLinesToHtml(lines); + } catch { + // On error, return undefined to trigger JSON fallback + return undefined; + } + }, + + renderResult( + toolName: string, + result: Array<{ type: string; text?: string; data?: string; mimeType?: string }>, + details: unknown, + isError: boolean, + ): string | undefined { + try { + const toolDef = getToolDefinition(toolName); + if (!toolDef?.renderResult) { + return undefined; + } + + // Build AgentToolResult from content array + // Cast content since session storage uses generic object types + const agentToolResult = { + content: result as (TextContent | ImageContent)[], + details, + isError, + }; + + // Always render expanded, client-side will apply truncation + const component = toolDef.renderResult(agentToolResult, { expanded: true, isPartial: false }, theme); + const lines = component.render(width); + return ansiLinesToHtml(lines); + } catch { + // On error, return undefined to trigger JSON fallback + return undefined; + } + }, + }; +} diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 5194c8f6..04e62666 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -189,6 +189,17 @@ export class ExtensionRunner { return tools; } + /** Get a tool definition by name. Returns undefined if not found. */ + getToolDefinition(toolName: string): RegisteredTool["definition"] | undefined { + for (const ext of this.extensions) { + const tool = ext.tools.get(toolName); + if (tool) { + return tool.definition; + } + } + return undefined; + } + getFlags(): Map { const allFlags = new Map(); for (const ext of this.extensions) {