mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-22 00:00:27 +00:00
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
This commit is contained in:
parent
ce7e73b503
commit
0c6ac46646
6 changed files with 502 additions and 9 deletions
|
|
@ -24,6 +24,7 @@ import type {
|
||||||
import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai";
|
import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai";
|
||||||
import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
|
import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
|
||||||
import { getAuthPath } from "../config.js";
|
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 BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor.js";
|
||||||
import {
|
import {
|
||||||
type CompactionResult,
|
type CompactionResult,
|
||||||
|
|
@ -34,7 +35,8 @@ import {
|
||||||
prepareCompaction,
|
prepareCompaction,
|
||||||
shouldCompact,
|
shouldCompact,
|
||||||
} from "./compaction/index.js";
|
} 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 {
|
import type {
|
||||||
ExtensionRunner,
|
ExtensionRunner,
|
||||||
SessionBeforeCompactResult,
|
SessionBeforeCompactResult,
|
||||||
|
|
@ -2148,7 +2150,21 @@ export class AgentSession {
|
||||||
*/
|
*/
|
||||||
async exportToHtml(outputPath?: string): Promise<string> {
|
async exportToHtml(outputPath?: string): Promise<string> {
|
||||||
const themeName = this.settingsManager.getTheme();
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
258
packages/coding-agent/src/core/export-html/ansi-to-html.ts
Normal file
258
packages/coding-agent/src/core/export-html/ansi-to-html.ts
Normal file
|
|
@ -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, """)
|
||||||
|
.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 += "</span>";
|
||||||
|
inSpan = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the codes
|
||||||
|
applySgrCode(params, style);
|
||||||
|
|
||||||
|
// Open new span if we have any styling
|
||||||
|
if (hasStyle(style)) {
|
||||||
|
result += `<span style="${styleToInlineCSS(style)}">`;
|
||||||
|
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 += "</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => `<div class="ansi-line">${ansiToHtml(line) || " "}</div>`).join("\n");
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,36 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||||
import { basename, join } from "path";
|
import { basename, join } from "path";
|
||||||
import { APP_NAME, getExportTemplateDir } from "../../config.js";
|
import { APP_NAME, getExportTemplateDir } from "../../config.js";
|
||||||
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.js";
|
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.js";
|
||||||
|
import type { SessionEntry } from "../session-manager.js";
|
||||||
import { SessionManager } 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 {
|
export interface ExportOptions {
|
||||||
outputPath?: string;
|
outputPath?: string;
|
||||||
themeName?: string;
|
themeName?: string;
|
||||||
|
/** Optional tool renderer for custom tools */
|
||||||
|
toolRenderer?: ToolHtmlRenderer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Info about Codex injection to show inline with model_change entries */
|
/** 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 */
|
/** Info for rendering Codex injection inline with model_change entries */
|
||||||
codexInjectionInfo?: CodexInjectionInfo;
|
codexInjectionInfo?: CodexInjectionInfo;
|
||||||
tools?: { name: string; description: string }[];
|
tools?: { name: string; description: string }[];
|
||||||
|
/** Pre-rendered HTML for custom tool calls/results, keyed by tool call ID */
|
||||||
|
renderedTools?: Record<string, RenderedToolHtml>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -176,6 +203,54 @@ function generateHtml(sessionData: SessionData, themeName?: string): string {
|
||||||
.replace("{{HIGHLIGHT_JS}}", hljsJs);
|
.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<string, RenderedToolHtml> {
|
||||||
|
const renderedTools: Record<string, RenderedToolHtml> = {};
|
||||||
|
|
||||||
|
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.
|
* Export session to HTML using SessionManager and AgentState.
|
||||||
* Used by TUI's /export command.
|
* 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");
|
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<string, RenderedToolHtml> | 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 = {
|
const sessionData: SessionData = {
|
||||||
header: sm.getHeader(),
|
header: sm.getHeader(),
|
||||||
entries: sm.getEntries(),
|
entries,
|
||||||
leafId: sm.getLeafId(),
|
leafId: sm.getLeafId(),
|
||||||
systemPrompt: state?.systemPrompt,
|
systemPrompt: state?.systemPrompt,
|
||||||
codexInjectionInfo: await buildCodexInjectionInfo(state?.tools),
|
codexInjectionInfo: await buildCodexInjectionInfo(state?.tools),
|
||||||
tools: state?.tools?.map((t) => ({ name: t.name, description: t.description })),
|
tools: state?.tools?.map((t) => ({ name: t.name, description: t.description })),
|
||||||
|
renderedTools,
|
||||||
};
|
};
|
||||||
|
|
||||||
const html = generateHtml(sessionData, opts.themeName);
|
const html = generateHtml(sessionData, opts.themeName);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
bytes[i] = binary.charCodeAt(i);
|
bytes[i] = binary.charCodeAt(i);
|
||||||
}
|
}
|
||||||
const data = JSON.parse(new TextDecoder('utf-8').decode(bytes));
|
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
|
// URL PARAMETER HANDLING
|
||||||
|
|
@ -911,11 +911,41 @@
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
html += `<div class="tool-header"><span class="tool-name">${escapeHtml(name)}</span></div>`;
|
// Check for pre-rendered custom tool HTML
|
||||||
html += `<div class="tool-output"><pre>${escapeHtml(JSON.stringify(args, null, 2))}</pre></div>`;
|
const rendered = renderedTools?.[call.id];
|
||||||
if (result) {
|
if (rendered?.callHtml || rendered?.resultHtml) {
|
||||||
const output = getResultText();
|
// Custom tool with pre-rendered HTML from TUI renderer
|
||||||
if (output) html += formatExpandableOutput(output, 10);
|
if (rendered.callHtml) {
|
||||||
|
html += `<div class="tool-header ansi-rendered">${rendered.callHtml}</div>`;
|
||||||
|
} else {
|
||||||
|
html += `<div class="tool-header"><span class="tool-name">${escapeHtml(name)}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 += `<div class="tool-output expandable ansi-rendered" onclick="this.classList.toggle('expanded')">
|
||||||
|
<div class="output-preview">${preview}<div class="expand-hint">... (${lines.length - 10} more lines)</div></div>
|
||||||
|
<div class="output-full">${rendered.resultHtml}</div>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
html += `<div class="tool-output ansi-rendered">${rendered.resultHtml}</div>`;
|
||||||
|
}
|
||||||
|
} 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 += `<div class="tool-header"><span class="tool-name">${escapeHtml(name)}</span></div>`;
|
||||||
|
html += `<div class="tool-output"><pre>${escapeHtml(JSON.stringify(args, null, 2))}</pre></div>`;
|
||||||
|
if (result) {
|
||||||
|
const output = getResultText();
|
||||||
|
if (output) html += formatExpandableOutput(output, 10);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
90
packages/coding-agent/src/core/export-html/tool-renderer.ts
Normal file
90
packages/coding-agent/src/core/export-html/tool-renderer.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -189,6 +189,17 @@ export class ExtensionRunner {
|
||||||
return tools;
|
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<string, ExtensionFlag> {
|
getFlags(): Map<string, ExtensionFlag> {
|
||||||
const allFlags = new Map<string, ExtensionFlag>();
|
const allFlags = new Map<string, ExtensionFlag>();
|
||||||
for (const ext of this.extensions) {
|
for (const ext of this.extensions) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue