// Control UI chat module implements tool cards behavior. import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { html, nothing } from "lit"; import { keyed } from "lit/directives/keyed.js"; import { icons, type IconName } from "../../../components/icons.ts"; import { isMarkdownBlockArtText } from "../../../components/markdown.ts"; import "../../../components/tooltip.ts"; import "../../../components/mcp-app-view.ts"; import { t } from "../../../i18n/index.ts"; import type { ToolCard, ToolCardOutcome } from "../../../lib/chat/chat-types.ts"; import { resolveToolCallView, type ToolCallView } from "../../../lib/chat/tool-call-view.ts"; import { formatDistinctCollapsedToolSummaryText, formatCollapsedToolPreviewText, formatCollapsedToolSummaryText, isToolCardError, resolveToolCardOutcome, type ToolPreview, } from "../../../lib/chat/tool-cards.ts"; import { formatToolDetail, resolveCanvasIframeUrl, resolveEmbedSandbox, resolveToolDisplay, type EmbedSandboxMode, } from "../../../lib/chat/tool-display.ts"; import { getToolCallTitle } from "../tool-titles.ts"; import { renderDiffBlock, renderDiffStatChips } from "./chat-diff-render.ts"; import type { SidebarContent } from "./chat-sidebar.ts"; type FullMessageRequest = NonNullable; export function shouldToggleSelectableDisclosure(event: MouseEvent): boolean { if (event.detail === 0) { return true; } const target = event.currentTarget; const selection = window.getSelection(); if (!(target instanceof Node) || !selection || selection.isCollapsed) { return true; } return ![selection.anchorNode, selection.focusNode].some( (node) => node !== null && target.contains(node), ); } function formatToolOutputForSidebar(text: string): string { if (isMarkdownBlockArtText(text)) { return "```\n" + text + "\n```"; } const trimmed = text.trim(); if (trimmed.startsWith("{") || trimmed.startsWith("[")) { try { return "```json\n" + JSON.stringify(JSON.parse(trimmed), null, 2) + "\n```"; } catch { return text; } } return text; } function renderToolIcon(name: string) { return icons[name as IconName] ?? icons.puzzle; } function formatPayloadForSidebar( text: string | undefined, language: "json" | "text" = "text", ): string { if (!text?.trim()) { return ""; } if (language === "json") { return `\`\`\`json ${text} \`\`\``; } const formatted = formatToolOutputForSidebar(text); if (formatted.includes("```")) { return formatted; } return `\`\`\`text ${text} \`\`\``; } export function buildToolCardSidebarContent(card: ToolCard): string { const display = resolveToolDisplay({ name: card.name, args: card.args }); const detail = formatToolDetail(display); const isError = isToolCardError(card); const outcome = resolveToolCardOutcome(card, false); const sections = [`## ${display.label}`, `**${t("chat.toolCards.tool")}:** \`${display.name}\``]; if (detail) { sections.push(`**${t("chat.toolCards.summary")}:** ${detail}`); } if (card.inputText?.trim()) { const inputIsJson = typeof card.args === "object" && card.args !== null; sections.push( `### ${t("chat.toolCards.toolInput")}\n${formatPayloadForSidebar(card.inputText, inputIsJson ? "json" : "text")}`, ); } if (card.outputText?.trim()) { sections.push( `### ${t(isError ? "chat.toolCards.toolError" : "chat.toolCards.toolOutput")}\n${formatToolOutputForSidebar(card.outputText)}`, ); } else { sections.push( isError ? `### ${t("chat.toolCards.toolError")}\n*${t("chat.toolCards.noOutputFailed")}*` : outcome === "succeeded" ? `### ${t("chat.toolCards.toolOutput")}\n*${t("chat.toolCards.noOutputSucceeded")}*` : `### ${t("chat.toolCards.toolOutput")}\n*${t("chat.toolCards.noResult")}*`, ); } return sections.join("\n\n"); } function handleRawDetailsToggle(event: Event) { const button = event.currentTarget as HTMLButtonElement | null; const root = button?.closest(".chat-tool-card__raw"); const body = root?.querySelector(".chat-tool-card__raw-body"); if (!button || !body) { return; } const expanded = button.getAttribute("aria-expanded") === "true"; button.setAttribute("aria-expanded", String(!expanded)); body.hidden = expanded; } // Sandboxed widget documents report their content height via postMessage so the // preview iframe can fit short/tall widgets. The event source must be one of our // preview frames and the height is clamped, so widget code can only resize its // own frame within the same bounds the preview contract allows. const WIDGET_SIZE_MESSAGE_TYPE = "openclaw:widget-size"; const WIDGET_FRAME_MIN_HEIGHT = 160; const WIDGET_FRAME_MAX_HEIGHT = 1200; // Preview frames render inside lit shadow roots, so a document query cannot // find them; frames register themselves on load and are dropped once detached. const widgetFrameRegistry = new Set(); // Reported heights keyed by frame src: lit re-renders re-apply the style // binding, so the template must read the reported height back or it resets. const widgetFrameHeightsBySrc = new Map(); const WIDGET_FRAME_HEIGHTS_MAX_ENTRIES = 100; let widgetSizeListenerInstalled = false; function rememberWidgetFrameHeight(src: string, height: number) { if ( !widgetFrameHeightsBySrc.has(src) && widgetFrameHeightsBySrc.size >= WIDGET_FRAME_HEIGHTS_MAX_ENTRIES ) { const oldest = widgetFrameHeightsBySrc.keys().next().value; if (oldest !== undefined) { widgetFrameHeightsBySrc.delete(oldest); } } widgetFrameHeightsBySrc.set(src, height); } function registerWidgetFrame(event: Event) { const frame = event.currentTarget; if (frame instanceof HTMLIFrameElement) { widgetFrameRegistry.add(frame); } } function installWidgetSizeListener() { if (widgetSizeListenerInstalled || typeof window === "undefined") { return; } widgetSizeListenerInstalled = true; window.addEventListener("message", (event: MessageEvent) => { const data = event.data as { type?: unknown; height?: unknown } | null; if (!data || data.type !== WIDGET_SIZE_MESSAGE_TYPE || typeof data.height !== "number") { return; } for (const frame of widgetFrameRegistry) { if (!frame.isConnected) { widgetFrameRegistry.delete(frame); continue; } if (frame.contentWindow === event.source) { const height = Math.min( Math.max(Math.trunc(data.height), WIDGET_FRAME_MIN_HEIGHT), WIDGET_FRAME_MAX_HEIGHT, ); // The stylesheet floors the frame at min-height 420px; reported sizes // must override both properties to fit short widgets. frame.style.height = `${height}px`; frame.style.minHeight = `${height}px`; const src = frame.getAttribute("src"); if (src) { rememberWidgetFrameHeight(src, height); } return; } } }); } function renderPreviewFrame(params: { title: string; src?: string; height?: number; sandbox?: string; }) { installWidgetSizeListener(); const sandbox = params.sandbox ?? ""; const src = params.src ?? ""; const reportedHeight = src ? widgetFrameHeightsBySrc.get(src) : undefined; const height = reportedHeight ?? params.height; return keyed( `${sandbox}\u0000${src}\u0000${params.height ?? ""}`, html` `, ); } export function renderToolPreview( preview: ToolPreview | undefined, surface: "chat_tool" | "chat_message" | "sidebar", options?: { onOpenSidebar?: (content: SidebarContent) => void; rawText?: string | null; canvasPluginSurfaceUrl?: string | null; embedSandboxMode?: EmbedSandboxMode; allowExternalEmbedUrls?: boolean; sessionKey?: string; }, ) { if (!preview) { return nothing; } if ( preview.kind !== "canvas" || surface === "chat_tool" || (preview.mcpApp && surface !== "chat_message") ) { return nothing; } if (preview.surface !== "assistant_message") { return nothing; } return html`
${preview.title?.trim() || "Canvas"}
${preview.mcpApp ? html`` : renderPreviewFrame({ title: preview.title?.trim() || "Canvas", src: resolveCanvasIframeUrl( preview.url, options?.canvasPluginSurfaceUrl, options?.allowExternalEmbedUrls ?? false, ), height: preview.preferredHeight, sandbox: resolveEmbedSandbox(options?.embedSandboxMode ?? "scripts", preview.sandbox), })}
`; } function buildSidebarContent( value: string, options?: { rawText?: string | null; fullMessageRequest?: FullMessageRequest; }, ): SidebarContent { return { kind: "markdown", content: value, ...(options?.rawText ? { rawText: options.rawText } : {}), ...(options?.fullMessageRequest ? { fullMessageRequest: options.fullMessageRequest } : {}), }; } export function buildPreviewSidebarContent( preview: ToolPreview, rawText?: string | null, options?: { fullMessageRequest?: FullMessageRequest }, ): SidebarContent | null { if (preview.kind !== "canvas" || preview.render !== "url" || !preview.viewId || !preview.url) { return null; } return { kind: "canvas", docId: preview.viewId, entryUrl: preview.url, ...(preview.title ? { title: preview.title } : {}), ...(preview.preferredHeight ? { preferredHeight: preview.preferredHeight } : {}), // The per-preview sandbox ceiling must survive the sidebar conversion, or a // trusted global embed mode would re-grant same-origin to widget script. ...(preview.sandbox ? { sandbox: preview.sandbox } : {}), ...(rawText ? { rawText } : {}), ...(options?.fullMessageRequest ? { fullMessageRequest: options.fullMessageRequest } : {}), }; } function buildToolSidebarFullMessageRequest( card: ToolCard, sessionKey: string | undefined, ): FullMessageRequest | undefined { if (!sessionKey || !card.messageId) { return undefined; } // A transcript entry can contain multiple tool blocks. Until the request can // identify a specific block, upgrading by message id can show the wrong tool. return undefined; } export function renderRawOutputToggle(text: string) { return html`
`; } function renderToolDataBlock(params: { label: string; text: string }) { const { label, text } = params; const codeClass = isMarkdownBlockArtText(text) ? "markdown-block-art" : ""; return html`
${icons.zap} ${label}
${text}
`; } // ── Kind-aware tool rows (command / read / edit / write / search / fetch) ── const TOOL_ROW_VERB_KEYS: Partial> = { read: "chat.toolCards.verbs.read", search: "chat.toolCards.verbs.searched", fetch: "chat.toolCards.verbs.fetched", }; const MUTATION_VERB_KEYS = { edit: { running: "chat.toolCards.verbs.editing", succeeded: "chat.toolCards.verbs.edited", fallback: "chat.toolCards.verbs.edit", }, write: { running: "chat.toolCards.verbs.writing", succeeded: "chat.toolCards.verbs.wrote", fallback: "chat.toolCards.verbs.write", }, } as const; function resolveToolRowVerb( kind: ToolCallView["kind"], outcome: ToolCardOutcome, ): string | undefined { if (kind === "edit" || kind === "write") { const keys = MUTATION_VERB_KEYS[kind]; const key = outcome === "running" ? keys.running : outcome === "succeeded" ? keys.succeeded : keys.fallback; return t(key); } const key = TOOL_ROW_VERB_KEYS[kind]; return key ? t(key) : undefined; } const TOOL_ROW_ICONS: Partial> = { command: "terminal", read: "fileText", edit: "penLine", write: "fileCode", search: "search", fetch: "globe", }; function firstCommandLine(command: string): string { const line = command.split("\n")[0]?.trim() ?? ""; return truncateUtf16Safe(line, 120); } function renderToolRowContent(card: ToolCard, view: ToolCallView, outcome: ToolCardOutcome) { if (view.kind === "command" && view.command) { const commandPreview = firstCommandLine(view.command); const aiTitle = getToolCallTitle(card.name, card.args); if (aiTitle) { return html` ${aiTitle} ${commandPreview} `; } return html` ${renderHighlightedCommand(commandPreview)} `; } const verb = resolveToolRowVerb(view.kind, outcome); if (verb && view.target) { return html` ${verb} ${view.target} ${outcome === "succeeded" && view.stat ? renderDiffStatChips(view.stat) : nothing} ${view.targetDetail ? html`${view.targetDetail}` : nothing} `; } // Generic tools keep the resolver-driven label + detail. const display = resolveToolDisplay({ name: card.name, args: card.args, detailMode: "explain" }); const summary = resolveCollapsedToolSummaryParts({ card, displayLabel: display.label, displayDetail: display.detail, isError: outcome === "failed", }); const displayLabel = formatCollapsedToolSummaryText(summary.label) ?? summary.label; const displayName = formatDistinctCollapsedToolSummaryText(summary.name, displayLabel); const aiTitle = getToolCallTitle(card.name, card.args); if (aiTitle) { return html` ${aiTitle} ${displayLabel} `; } return html` ${displayLabel} ${displayName ? html`${displayName}` : nothing} `; } // ── Command syntax highlighting ── type CommandToken = { text: string; cls: "name" | "flag" | "str" | "num" | "op" | "plain" | "ws" }; const COMMAND_HIGHLIGHT_MAX_CHARS = 2_000; const COMMAND_OP_CHARS = new Set(["|", ";", "&", "<", ">"]); /** Small shell-ish tokenizer for display colors only; never used for execution. */ function tokenizeCommand(command: string): CommandToken[] { const tokens: CommandToken[] = []; let index = 0; let expectName = true; while (index < command.length) { const char = command.charAt(index); if (/\s/.test(char)) { let end = index; while (end < command.length && /\s/.test(command.charAt(end))) { end++; } tokens.push({ text: command.slice(index, end), cls: "ws" }); index = end; continue; } if (char === "'" || char === '"') { let end = index + 1; while (end < command.length && command.charAt(end) !== char) { end += command.charAt(end) === "\\" ? 2 : 1; } end = Math.min(end + 1, command.length); tokens.push({ text: command.slice(index, end), cls: "str" }); index = end; expectName = false; continue; } if (COMMAND_OP_CHARS.has(char)) { let end = index; while (end < command.length && COMMAND_OP_CHARS.has(command.charAt(end))) { end++; } tokens.push({ text: command.slice(index, end), cls: "op" }); index = end; expectName = true; continue; } let end = index; while ( end < command.length && !/\s/.test(command.charAt(end)) && !COMMAND_OP_CHARS.has(command.charAt(end)) && command.charAt(end) !== "'" && command.charAt(end) !== '"' ) { end++; } const word = command.slice(index, end); const cls = expectName ? "name" : word.startsWith("-") ? "flag" : /^\d+(?:[.,]\d+)?$/.test(word) ? "num" : "plain"; tokens.push({ text: word, cls }); index = end; expectName = false; } return tokens; } export function renderHighlightedCommand(command: string) { if (command.length > COMMAND_HIGHLIGHT_MAX_CHARS) { return html`${command}`; } return html`${tokenizeCommand(command).map((token) => token.cls === "ws" || token.cls === "plain" ? html`${token.text}` : html`${token.text}`, )}`; } // ── Key-value args display (generic tools) ── const KV_MAX_KEYS = 12; const KV_MAX_VALUE_CHARS = 400; function formatKeyValue(value: unknown): string { if (typeof value === "string") { return truncateUtf16Safe(value, KV_MAX_VALUE_CHARS); } if (value === null || value === undefined) { return String(value); } if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { return String(value); } try { return truncateUtf16Safe(JSON.stringify(value), KV_MAX_VALUE_CHARS); } catch { return Object.prototype.toString.call(value); } } function renderArgsKeyValueList(args: Record) { return html`
${Object.entries(args).map( ([key, value]) => html`
${key}: ${formatKeyValue(value)}
`, )}
`; } function canRenderArgsAsKeyValue(args: unknown): args is Record { if (!args || typeof args !== "object" || Array.isArray(args)) { return false; } const keys = Object.keys(args as Record); return keys.length > 0 && keys.length <= KV_MAX_KEYS; } // Args already represented in the collapsed row / header detail for kinds that // summarize their primary target; everything else stays auditable on expand. const ROW_SUMMARIZED_ARG_KEYS: Partial>> = { read: new Set(["path", "file_path", "filePath", "notebook_path"]), search: new Set(["pattern", "query", "glob", "path"]), fetch: new Set(["url"]), }; function extraArgsBeyondRowTarget( args: unknown, kind: ToolCallView["kind"], ): Record | null { if (!args || typeof args !== "object" || Array.isArray(args)) { return null; } const summarized = ROW_SUMMARIZED_ARG_KEYS[kind]; if (!summarized) { return args as Record; } const extras = Object.fromEntries( Object.entries(args as Record).filter(([key]) => !summarized.has(key)), ); return Object.keys(extras).length > 0 ? extras : null; } function resolveToolWorkspaceFilePath(card: ToolCard, view: ToolCallView): string | null { if (card.args && typeof card.args === "object" && !Array.isArray(card.args)) { const args = card.args as Record; for (const key of ["path", "file_path", "filePath", "notebook_path"]) { const value = args[key]; if (typeof value === "string" && value.trim()) { return value; } } } const fallback = `${view.targetDetail ? `${view.targetDetail}/` : ""}${view.target ?? ""}`; return fallback.trim() || null; } function renderToolWorkspaceFilePath( label: string, path: string | null, onOpenWorkspaceFile?: (target: { path: string; line?: number | null }) => void, ) { return path && onOpenWorkspaceFile ? html` ` : html`
${label}
`; } function renderTerminalBlock(command: string, output: string | undefined, isError: boolean) { return html`
$${renderHighlightedCommand(command)}
${output?.trim() ? html`
${output}
` : nothing}
`; } export function resolveCollapsedToolDetail(card: ToolCard, displayDetail: string | undefined) { const directDetail = displayDetail?.trim(); if (directDetail) { return displayDetail; } if (typeof card.args !== "string") { return undefined; } const inputText = card.inputText?.trim() ? card.inputText : card.args; return formatCollapsedToolPreviewText(inputText); } function resolveCollapsedToolSummaryParts(params: { card: ToolCard; displayLabel: string; displayDetail: string | undefined; isError: boolean; }): { label: string; name?: string } { if (params.isError) { return { label: t("chat.toolCards.toolError"), name: params.displayLabel }; } const displayDetail = params.displayDetail?.trim(); if (displayDetail) { return { label: params.displayLabel, name: displayDetail }; } return { label: typeof params.card.args === "string" ? (resolveCollapsedToolDetail(params.card, undefined) ?? params.displayLabel) : params.displayLabel, }; } export function isRunningToolCard(card: ToolCard, runActive: boolean | undefined): boolean { // Only live tool-stream cards can be running; historical transcript calls // without results (aborted runs) must stay inert during later runs. The // result event ends the running state — partial streamed output does not. return resolveToolCardOutcome(card, runActive) === "running"; } /** Plain-text row label, e.g. for the group header while a tool is running. */ export function resolveToolRowText(card: ToolCard, runActive?: boolean): string { const view = resolveToolCallView({ name: card.name, args: card.args, details: card.details }); if (view.kind === "command" && view.command) { return `$ ${firstCommandLine(view.command)}`; } const verb = resolveToolRowVerb(view.kind, resolveToolCardOutcome(card, runActive)); if (verb && view.target) { return `${verb} ${view.target}`; } const display = resolveToolDisplay({ name: card.name, args: card.args, detailMode: "explain" }); return display.label; } export function renderToolCard( card: ToolCard, opts: { expanded: boolean; onToggleExpanded: (id: string) => void; runActive?: boolean; sessionKey?: string; agentId?: string; onOpenSidebar?: (content: SidebarContent) => void; onOpenWorkspaceFile?: (target: { path: string; line?: number | null }) => void; canvasPluginSurfaceUrl?: string | null; embedSandboxMode?: EmbedSandboxMode; allowExternalEmbedUrls?: boolean; }, ) { const view = resolveToolCallView({ name: card.name, args: card.args, details: card.details }); const display = resolveToolDisplay({ name: card.name, args: card.args, detailMode: "explain" }); const outcome = resolveToolCardOutcome(card, opts.runActive); const isError = outcome === "failed"; const isRunning = outcome === "running"; const icon = TOOL_ROW_ICONS[view.kind] ?? display.icon; return html`
${opts.expanded ? html`
${renderExpandedToolCardContent( card, opts.sessionKey, opts.onOpenSidebar, opts.canvasPluginSurfaceUrl, opts.embedSandboxMode ?? "scripts", opts.allowExternalEmbedUrls ?? false, opts.runActive, opts.onOpenWorkspaceFile, )}
` : nothing}
`; } export function renderExpandedToolCardContent( card: ToolCard, sessionKey?: string, onOpenSidebar?: (content: SidebarContent) => void, canvasPluginSurfaceUrl?: string | null, embedSandboxMode: EmbedSandboxMode = "scripts", allowExternalEmbedUrls = false, runActive?: boolean, onOpenWorkspaceFile?: (target: { path: string; line?: number | null }) => void, ) { const view = resolveToolCallView({ name: card.name, args: card.args, details: card.details }); const display = resolveToolDisplay({ name: card.name, args: card.args }); // File/search rows already carry their target; the "with …" connector only // reads well for generic tools ("with query …"), not "with from sessions.ts". const detail = view.kind === "read" || view.kind === "search" || view.kind === "fetch" ? display.detail : formatToolDetail(display); const hasOutput = Boolean(card.outputText?.trim()); const hasInput = Boolean(card.inputText?.trim()); const isError = isToolCardError(card); const outcome = resolveToolCardOutcome(card, runActive); const workspaceFilePath = view.kind === "read" || view.kind === "edit" || view.kind === "write" ? resolveToolWorkspaceFilePath(card, view) : null; const canOpenSidebar = Boolean(onOpenSidebar); const fullMessageRequest = buildToolSidebarFullMessageRequest(card, sessionKey); const previewSidebarContent = card.preview?.kind === "canvas" ? buildPreviewSidebarContent(card.preview, card.outputText, { fullMessageRequest }) : null; const sidebarActionContent = previewSidebarContent ?? buildSidebarContent(buildToolCardSidebarContent(card), { fullMessageRequest, rawText: card.outputText ?? null, }); const visiblePreview = card.preview ? renderToolPreview(card.preview, "chat_tool", { onOpenSidebar, rawText: card.outputText, canvasPluginSurfaceUrl, embedSandboxMode, allowExternalEmbedUrls, sessionKey, }) : nothing; const sidebarAction = canOpenSidebar ? html`
` : nothing; // Command calls render terminal-style: `$ command` + raw output. Remaining // args (workdir, timeout, env…) stay visible as key-value rows so identical // commands in different contexts remain distinguishable in the audit trail. if (view.kind === "command" && view.command && !card.preview) { const argsRecord = card.args && typeof card.args === "object" && !Array.isArray(card.args) ? (card.args as Record) : null; const extraArgs = Object.fromEntries( Object.entries(argsRecord ?? {}).filter(([key]) => key !== "command"), ); return html`
${sidebarAction} ${renderTerminalBlock( view.command, card.outputText ?? (isError ? t("chat.toolCards.noOutputFailed") : undefined), isError, )} ${Object.keys(extraArgs).length > 0 ? renderArgsKeyValueList(extraArgs) : nothing}
`; } // Edits and writes with a resolvable diff render it inline; the raw tool // output stays reachable behind the raw-details toggle. if ((view.kind === "edit" || view.kind === "write") && view.diff && view.diff.length > 0) { return html`
${renderToolWorkspaceFilePath( `${view.targetDetail ? `${view.targetDetail}/` : ""}${view.target ?? ""}`, workspaceFilePath, onOpenWorkspaceFile, )} ${sidebarAction}
${renderDiffBlock(view.diff, outcome)} ${isError && hasOutput ? renderToolDataBlock({ label: t("chat.toolCards.toolError"), text: card.outputText! }) : hasOutput ? renderRawOutputToggle(card.outputText!) : nothing}
`; } // File reads and searches summarize their primary target in the row, so the // full args JSON is noise — but any remaining args (filters, limits, request // options…) stay visible as key-value rows for auditability. const summarizedKind = view.kind === "read" || view.kind === "search" || view.kind === "fetch"; const inputBlockArgs = summarizedKind ? extraArgsBeyondRowTarget(card.args, view.kind) : card.args; const showInputBlock = hasInput && (!summarizedKind || inputBlockArgs !== null); return html`
${detail || canOpenSidebar ? html`
${detail ? view.kind === "read" ? renderToolWorkspaceFilePath(detail, workspaceFilePath, onOpenWorkspaceFile) : html`
${detail}
` : nothing} ${sidebarAction}
` : nothing} ${showInputBlock ? canRenderArgsAsKeyValue(inputBlockArgs) ? renderArgsKeyValueList(inputBlockArgs) : renderToolDataBlock({ label: t("chat.toolCards.toolInput"), text: card.inputText!, }) : nothing} ${hasOutput ? card.preview ? html`${visiblePreview} ${renderRawOutputToggle(card.outputText!)}` : renderToolDataBlock({ label: t(isError ? "chat.toolCards.toolError" : "chat.toolCards.toolOutput"), text: card.outputText!, }) : isError ? renderToolDataBlock({ label: t("chat.toolCards.toolError"), text: t("chat.toolCards.noOutputFailed"), }) : nothing}
`; }