const COMMAND_OUTPUT_MAX_DISPLAY_CHARS = 80_000 const ANSI_ESCAPE_PATTERN = // eslint-disable-next-line no-control-regex /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g /** * Tool UI Handler * Renders tool executions as expandable activity cards. */ export default class ToolUIHandler { constructor(feedElement, scrollDownCallback, formatMessageCallback) { this.feed = feedElement this.scrollDown = scrollDownCallback this.formatMessage = formatMessageCallback this.toolGroups = new Map() } /** * Handle tool output messages with interactive activity cards. */ handleToolOutput(data) { if (data.activityType === 'agent_skill') { return this.handleAgentSkillActivity(data) } const { toolkitName, toolName, toolGroupId, answer, replaceMessageId, key } = data if (replaceMessageId) { this.replaceToolMessage(replaceMessageId, data) return } const answerKey = key ? key.split('.').pop() : 'unknown' const groupId = toolGroupId || `${toolkitName}_${toolName}_${Date.now()}` const mode = data.toolDisplayMode === 'activity_card' ? 'activity_card' : 'legacy' let toolGroupContainer = this.toolGroups.get(groupId) if (!toolGroupContainer) { toolGroupContainer = this.createToolGroupContainer({ groupId, toolkitName, toolName, answerKey, mode, data }) this.toolGroups.set(groupId, toolGroupContainer) } if (toolGroupContainer.mode === 'activity_card') { this.updateActivityCard(toolGroupContainer, data, answerKey) } else { this.addLegacyToolMessage(toolGroupContainer, answer) } this.scrollDown() return { groupId, isNewGroup: toolGroupContainer.isNew } } handleAgentSkillActivity(data) { const agentSkill = data.agentSkill || {} const skillId = agentSkill.id || agentSkill.name || 'agent-skill' const groupId = data.toolGroupId || `agent_skill_${skillId}_${Date.now()}` let activityContainer = this.toolGroups.get(groupId) if (!activityContainer) { activityContainer = this.createAgentSkillActivityCard({ groupId, agentSkill }) this.toolGroups.set(groupId, activityContainer) } this.updateAgentSkillActivityCard(activityContainer, data) this.scrollDown() return { groupId, isNewGroup: activityContainer.isNew } } createToolGroupContainer(params) { return params.mode === 'activity_card' ? this.createActivityCardContainer(params) : this.createLegacyToolGroupContainer(params) } getRemixIconClass(iconName) { const fallbackIconName = 'ri-magic-line' if (typeof iconName !== 'string') { return fallbackIconName } const trimmedIconName = iconName.trim() if (!trimmedIconName || trimmedIconName.includes(' ')) { return fallbackIconName } return trimmedIconName.startsWith('ri-') ? trimmedIconName : `ri-${trimmedIconName}` } getToolActivityIconClass(data) { return this.getRemixIconClass(data.toolIconName || data.toolkitIconName) } /** * Create the legacy shell-like tool group container. */ createLegacyToolGroupContainer({ groupId, toolkitName, toolName, answerKey }) { const groupContainer = document.createElement('div') groupContainer.className = 'tool-group-container' groupContainer.setAttribute('data-tool-group-id', groupId) const toolHeader = document.createElement('button') toolHeader.className = 'tool-header' toolHeader.setAttribute('type', 'button') toolHeader.innerHTML = ` ${toolkitName} toolkit → ${toolName} → ${answerKey} ` const toolContent = document.createElement('div') toolContent.className = 'tool-content' const shellOutput = document.createElement('div') shellOutput.className = 'shell-output' toolContent.appendChild(shellOutput) groupContainer.appendChild(toolHeader) groupContainer.appendChild(toolContent) this.addExpandCollapseHandler(toolHeader, toolContent) this.feed.appendChild(groupContainer) return { mode: 'legacy', container: groupContainer, toolHeader, toolContent, shellOutput, isNew: true } } createActivityCardContainer({ groupId, toolkitName, toolName, answerKey, data }) { const groupContainer = document.createElement('div') groupContainer.className = 'tool-group-container tool-activity-card' groupContainer.setAttribute('data-tool-group-id', groupId) const toolHeader = document.createElement('button') toolHeader.className = 'tool-header tool-activity-header' toolHeader.setAttribute('type', 'button') const heading = document.createElement('div') heading.className = 'tool-activity-heading' const icon = document.createElement('i') icon.className = `${this.getToolActivityIconClass(data)} tool-icon` const titleBlock = document.createElement('div') titleBlock.className = 'tool-activity-title-block' const title = document.createElement('span') title.className = 'tool-title' title.textContent = data.stepLabel || this.humanizeFunctionName(answerKey) const subtitle = document.createElement('span') subtitle.className = 'tool-subtitle' subtitle.textContent = `${toolkitName} toolkit • ${toolName}` titleBlock.appendChild(title) titleBlock.appendChild(subtitle) heading.appendChild(icon) heading.appendChild(titleBlock) const meta = document.createElement('div') meta.className = 'tool-activity-meta' const statusChip = document.createElement('span') statusChip.className = 'tool-status-chip running' statusChip.textContent = 'Running' const expandIcon = document.createElement('i') expandIcon.className = 'ri-arrow-down-s-line expand-icon' meta.appendChild(statusChip) meta.appendChild(expandIcon) toolHeader.appendChild(heading) toolHeader.appendChild(meta) const toolContent = document.createElement('div') toolContent.className = 'tool-content' const summary = document.createElement('div') summary.className = 'tool-activity-summary' summary.textContent = 'Preparing tool activity...' const sections = document.createElement('div') sections.className = 'tool-activity-sections' const inputPanel = this.createActivityPanel( 'Input', 'Parameters sent to the function' ) const outputPanel = this.createActivityPanel( 'Result', 'What the function returned' ) sections.appendChild(inputPanel.panel) sections.appendChild(outputPanel.panel) const rawDetails = document.createElement('details') rawDetails.className = 'tool-raw-details' const rawSummary = document.createElement('summary') rawSummary.textContent = 'Raw data' const rawContent = document.createElement('div') rawContent.className = 'tool-raw-content' rawDetails.appendChild(rawSummary) rawDetails.appendChild(rawContent) toolContent.appendChild(summary) toolContent.appendChild(sections) toolContent.appendChild(rawDetails) groupContainer.appendChild(toolHeader) groupContainer.appendChild(toolContent) this.addExpandCollapseHandler(toolHeader, toolContent) this.feed.appendChild(groupContainer) return { mode: 'activity_card', container: groupContainer, toolHeader, toolContent, summary, title, subtitle, statusChip, inputBody: inputPanel.body, outputBody: outputPanel.body, rawContent, rawDetails, rawInput: null, rawOutput: null, commandOutputText: '', preparationLog: [], isNew: true } } createAgentSkillActivityCard({ groupId, agentSkill }) { const groupContainer = document.createElement('div') groupContainer.className = 'tool-group-container tool-activity-card agent-skill-activity-card' groupContainer.setAttribute('data-tool-group-id', groupId) const skillName = agentSkill.name || agentSkill.id || 'Agent Skill' const skillPath = agentSkill.skillPath || 'SKILL.md' const toolHeader = document.createElement('button') toolHeader.className = 'tool-header tool-activity-header' toolHeader.setAttribute('type', 'button') const heading = document.createElement('div') heading.className = 'tool-activity-heading' const icon = document.createElement('i') icon.className = 'ri-book-ai-line tool-icon' const titleBlock = document.createElement('div') titleBlock.className = 'tool-activity-title-block' const title = document.createElement('span') title.className = 'tool-title' title.textContent = `Agent Skill: ${skillName}` const subtitle = document.createElement('span') subtitle.className = 'tool-subtitle' subtitle.textContent = skillPath subtitle.setAttribute('title', skillPath) titleBlock.appendChild(title) titleBlock.appendChild(subtitle) heading.appendChild(icon) heading.appendChild(titleBlock) const meta = document.createElement('div') meta.className = 'tool-activity-meta' const statusChip = document.createElement('span') statusChip.className = 'tool-status-chip selected' statusChip.textContent = 'In use' const expandIcon = document.createElement('i') expandIcon.className = 'ri-arrow-down-s-line expand-icon' meta.appendChild(statusChip) meta.appendChild(expandIcon) toolHeader.appendChild(heading) toolHeader.appendChild(meta) const toolContent = document.createElement('div') toolContent.className = 'tool-content' const summary = document.createElement('div') summary.className = 'tool-activity-summary' summary.textContent = 'Following SKILL.md instructions for this step.' const sections = document.createElement('div') sections.className = 'tool-activity-sections single' const skillPanel = this.createActivityPanel( 'Skill', 'Agent Skill context for this step' ) sections.appendChild(skillPanel.panel) toolContent.appendChild(summary) toolContent.appendChild(sections) groupContainer.appendChild(toolHeader) groupContainer.appendChild(toolContent) this.addExpandCollapseHandler(toolHeader, toolContent) this.feed.appendChild(groupContainer) return { mode: 'agent_skill', container: groupContainer, title, subtitle, statusChip, skillBody: skillPanel.body, isNew: true } } /** * Create a titled content panel used by the activity card sections. */ createActivityPanel(title, description) { const panel = document.createElement('section') panel.className = 'tool-activity-panel' const heading = document.createElement('div') heading.className = 'tool-panel-heading' const titleElement = document.createElement('span') titleElement.className = 'tool-panel-title' titleElement.textContent = title const descriptionElement = document.createElement('span') descriptionElement.className = 'tool-panel-description' descriptionElement.textContent = description const body = document.createElement('div') body.className = 'tool-panel-body' heading.appendChild(titleElement) heading.appendChild(descriptionElement) panel.appendChild(heading) panel.appendChild(body) return { panel, body } } /** * Add expand/collapse functionality to tool header. */ addExpandCollapseHandler(toolHeader, toolContent) { toolHeader.addEventListener('click', () => { const isExpanded = toolContent.classList.contains('expanded') const expandIcon = toolHeader.querySelector('.expand-icon') if (isExpanded) { toolContent.classList.remove('expanded') expandIcon?.classList.remove('rotated') } else { toolContent.classList.add('expanded') expandIcon?.classList.add('rotated') } }) } /** * Update the activity card with the latest input/output state. */ updateActivityCard(toolGroupContainer, data, answerKey) { const title = data.stepLabel || this.humanizeFunctionName(data.functionName || answerKey) toolGroupContainer.title.textContent = title if (data.functionName) { const functionLabel = this.humanizeFunctionName(data.functionName) toolGroupContainer.subtitle.textContent = `${data.toolkitName} toolkit • ${data.toolName} • ${functionLabel}` } if (data.toolPhase === 'input') { toolGroupContainer.summary.textContent = data.stepLabel || data.functionName ? `Preparing ${title.toLowerCase()}...` : 'Preparing tool activity...' this.setStatusChip(toolGroupContainer.statusChip, 'running') const parsedInput = this.parseToolInput(data.toolInput) toolGroupContainer.rawInput = parsedInput ?? data.toolInput ?? null this.renderValuePreview( toolGroupContainer.inputBody, parsedInput ?? data.toolInput, 'No input' ) if (!toolGroupContainer.rawOutput) { this.renderPlaceholder( toolGroupContainer.outputBody, 'Waiting for function output...' ) } } if (data.toolPhase === 'preparation') { const progressMessage = data.message || data.answer || '' this.setStatusChip(toolGroupContainer.statusChip, 'running') if (progressMessage) { toolGroupContainer.summary.textContent = progressMessage toolGroupContainer.preparationLog.push(progressMessage) this.renderPreparationLog( toolGroupContainer.outputBody, toolGroupContainer.preparationLog ) toolGroupContainer.rawOutput = { preparation: toolGroupContainer.preparationLog } } } if (data.toolPhase === 'output_delta') { const outputDelta = data.outputDelta || data.answer || '' this.setStatusChip(toolGroupContainer.statusChip, 'running') if (outputDelta) { toolGroupContainer.summary.textContent = 'Receiving command output...' toolGroupContainer.commandOutputText = this.mergeCommandOutput( toolGroupContainer.commandOutputText, outputDelta ) this.renderCommandOutputLog( toolGroupContainer.outputBody, toolGroupContainer.commandOutputText ) toolGroupContainer.rawOutput = { output: toolGroupContainer.commandOutputText } } } if (data.toolPhase === 'output') { const isError = data.status === 'error' const isObserved = data.status === 'observed' toolGroupContainer.summary.textContent = data.message || (isError ? 'The function failed.' : isObserved ? 'The function returned an observation.' : 'The function completed.') this.setStatusChip( toolGroupContainer.statusChip, isError ? 'error' : isObserved ? 'observed' : 'success' ) const outputPayload = { message: data.message, output: data.output } toolGroupContainer.rawOutput = outputPayload this.renderOutputPreview(toolGroupContainer.outputBody, data) } this.renderRawData(toolGroupContainer) if (toolGroupContainer.isNew) { toolGroupContainer.isNew = false } } updateAgentSkillActivityCard(activityContainer, data) { const agentSkill = data.agentSkill || {} const skillName = agentSkill.name || agentSkill.id || 'Agent Skill' const skillPath = agentSkill.skillPath || '' activityContainer.title.textContent = `Agent Skill: ${skillName}` activityContainer.subtitle.textContent = skillPath || 'SKILL.md' activityContainer.subtitle.setAttribute( 'title', skillPath || 'SKILL.md' ) this.setStatusChip(activityContainer.statusChip, 'selected') this.renderValuePreview( activityContainer.skillBody, { name: skillName, description: agentSkill.description || '', root_path: agentSkill.rootPath || '', skill_path: skillPath }, 'No Agent Skill metadata' ) if (activityContainer.isNew) { activityContainer.isNew = false } } /** * Add a tool message to the legacy shell output. */ addLegacyToolMessage(toolGroupContainer, message) { const messageElement = document.createElement('div') messageElement.className = 'shell-message' const formattedMessage = this.formatMessage(message) messageElement.innerHTML = `> ${formattedMessage}` toolGroupContainer.shellOutput.appendChild(messageElement) if (toolGroupContainer.isNew) { toolGroupContainer.isNew = false } } /** * Render the output preview block. */ renderOutputPreview(container, data) { container.innerHTML = '' if (data.message) { const summary = document.createElement('div') summary.className = 'tool-output-summary' summary.innerHTML = this.formatMessage(data.message) container.appendChild(summary) } if ( typeof data.output === 'undefined' || data.output === null || (typeof data.output === 'object' && !Array.isArray(data.output) && Object.keys(data.output).length === 0) ) { if (!data.message) { this.renderPlaceholder(container, 'No output') } return } const preview = this.buildValueNode(data.output) container.appendChild(preview) } /** * Render preparation progress before the final tool output is available. */ renderPreparationLog(container, messages) { container.innerHTML = '' if (!messages.length) { this.renderPlaceholder(container, 'Waiting for function output...') return } const list = document.createElement('ul') list.className = 'tool-value-list' messages.forEach((message) => { const item = document.createElement('li') item.textContent = message list.appendChild(item) }) container.appendChild(list) } /** * Merge terminal output chunks while respecting carriage-return progress * updates used by CLI tools to repaint the current line. */ mergeCommandOutput(currentOutput, nextChunk) { const cleanChunk = this.cleanCommandOutputChunk(nextChunk) if (!cleanChunk) { return currentOutput || '' } let output = currentOutput || '' let replaceCurrentLine = false for (const char of cleanChunk) { if (char === '\r') { replaceCurrentLine = true continue } if (char === '\n') { output += '\n' replaceCurrentLine = false continue } if (replaceCurrentLine) { const lineStartIndex = output.lastIndexOf('\n') + 1 output = output.slice(0, lineStartIndex) replaceCurrentLine = false } output += char } if (output.length <= COMMAND_OUTPUT_MAX_DISPLAY_CHARS) { return output } return output.slice(output.length - COMMAND_OUTPUT_MAX_DISPLAY_CHARS) } /** * Strip terminal control sequences that do not render usefully in HTML. */ cleanCommandOutputChunk(chunk) { const withoutAnsi = String(chunk || '').replace(ANSI_ESCAPE_PATTERN, '') const output = [] for (const char of withoutAnsi) { if (char === '\b') { output.pop() continue } output.push(char) } return output.join('') } /** * Render live command output in the result panel. */ renderCommandOutputLog(container, outputText) { container.innerHTML = '' if (!outputText) { this.renderPlaceholder(container, 'Waiting for function output...') return } const output = document.createElement('pre') output.className = 'tool-command-output' output.textContent = outputText container.appendChild(output) output.scrollTop = output.scrollHeight } /** * Render a value preview block. */ renderValuePreview(container, value, emptyLabel) { container.innerHTML = '' if ( value === null || typeof value === 'undefined' || value === '' || (Array.isArray(value) && value.length === 0) || (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) ) { this.renderPlaceholder(container, emptyLabel) return } container.appendChild(this.buildValueNode(value)) } /** * Render an empty state placeholder. */ renderPlaceholder(container, text) { container.innerHTML = '' const placeholder = document.createElement('div') placeholder.className = 'tool-empty-state' placeholder.textContent = text container.appendChild(placeholder) } /** * Build the most appropriate preview node for a value. */ buildValueNode(value) { if (Array.isArray(value)) { return this.buildArrayNode(value) } if (value && typeof value === 'object') { return this.buildObjectNode(value) } return this.buildScalarNode(value) } /** * Build a list preview for array values. */ buildArrayNode(value) { const list = document.createElement('ul') list.className = 'tool-value-list' value.slice(0, 8).forEach((item) => { const itemElement = document.createElement('li') itemElement.appendChild(this.buildScalarNode(this.stringifyCompact(item))) list.appendChild(itemElement) }) if (value.length > 8) { const more = document.createElement('li') more.className = 'tool-empty-state' more.textContent = `+${value.length - 8} more item(s)` list.appendChild(more) } return list } /** * Build a key/value preview for object values. */ buildObjectNode(value) { const entries = Object.entries(value) if (entries.length === 0) { const empty = document.createElement('div') empty.className = 'tool-empty-state' empty.textContent = 'No data' return empty } const wrapper = document.createElement('div') wrapper.className = 'tool-kv-list' entries.slice(0, 8).forEach(([key, itemValue]) => { const row = document.createElement('div') row.className = 'tool-kv-row' const keyElement = document.createElement('span') keyElement.className = 'tool-kv-key' keyElement.textContent = this.humanizeFunctionName(key) const valueElement = document.createElement('div') valueElement.className = 'tool-kv-value' valueElement.appendChild( this.buildScalarNode(this.stringifyCompact(itemValue)) ) row.appendChild(keyElement) row.appendChild(valueElement) wrapper.appendChild(row) }) if (entries.length > 8) { const more = document.createElement('div') more.className = 'tool-empty-state' more.textContent = `+${entries.length - 8} more field(s)` wrapper.appendChild(more) } return wrapper } /** * Build a formatted scalar value preview. */ buildScalarNode(value) { const wrapper = document.createElement('div') wrapper.className = 'tool-rich-text' wrapper.innerHTML = this.formatMessage( typeof value === 'string' ? value : String(value ?? '') ) return wrapper } /** * Render raw input/output payloads behind a disclosure block. */ renderRawData(toolGroupContainer) { const payload = {} if (toolGroupContainer.rawInput !== null) { payload['input'] = toolGroupContainer.rawInput } if (toolGroupContainer.rawOutput !== null) { payload['result'] = toolGroupContainer.rawOutput } if (Object.keys(payload).length === 0) { toolGroupContainer.rawDetails.removeAttribute('open') toolGroupContainer.rawContent.innerHTML = '' return } toolGroupContainer.rawContent.innerHTML = this.formatMessage( JSON.stringify(payload, null, 2) ) } /** * Parse tool input JSON when possible. */ parseToolInput(toolInput) { if (typeof toolInput !== 'string' || !toolInput.trim()) { return null } try { return JSON.parse(toolInput) } catch { return toolInput } } /** * Update the status chip for the activity card. */ setStatusChip(chip, status) { chip.classList.remove('running', 'success', 'error', 'selected', 'observed') if (status === 'error') { chip.classList.add('error') chip.textContent = 'Failed' return } if (status === 'success') { chip.classList.add('success') chip.textContent = 'Done' return } if (status === 'selected') { chip.classList.add('selected') chip.textContent = 'In use' return } if (status === 'observed') { chip.classList.add('observed') chip.textContent = 'Observed' return } chip.classList.add('running') chip.textContent = 'Running' } /** * Humanize function and field names for display. */ humanizeFunctionName(value) { return String(value || 'unknown') .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/[_-]+/g, ' ') .replace(/\s+/g, ' ') .trim() .replace(/^\w/, (char) => char.toUpperCase()) } /** * Produce a compact preview string for nested values. */ stringifyCompact(value) { if (typeof value === 'string') { const trimmed = value.trim() if (!trimmed) { return '(empty)' } return trimmed.length > 180 ? `${trimmed.slice(0, 177)}...` : trimmed } if (value === null || typeof value === 'undefined') { return '(empty)' } if (typeof value === 'object') { try { const serialized = JSON.stringify(value) return serialized.length > 180 ? `${serialized.slice(0, 177)}...` : serialized } catch { return String(value) } } return String(value) } /** * Replace a tool message (for progress updates, etc.). */ replaceToolMessage(replaceMessageId, newData) { const existingMessage = document.querySelector( `[data-message-id="${replaceMessageId}"]` ) if (existingMessage && existingMessage.closest('.tool-group-container')) { const formattedMessage = this.formatMessage(newData.answer) existingMessage.innerHTML = `> ${formattedMessage}` } else { this.handleToolOutput(newData) } } /** * Get tool group info for saving to localStorage. */ getToolGroupInfo(groupId, toolkitName, toolName, message) { return { originalString: `[TOOL_OUTPUT:${groupId}] ${toolkitName} → ${toolName}`, messageId: `tool-${groupId}`, formattedMessage: this.formatMessage(message) } } /** * Check if a message is a tool output marker. */ static isToolOutputMarker(messageString) { return messageString && messageString.startsWith('[TOOL_OUTPUT:') } /** * Clear all tool groups. */ clearToolGroups() { this.toolGroups.clear() } /** * Get the number of active tool groups. */ getToolGroupCount() { return this.toolGroups.size } /** * Get a specific tool group by ID. */ getToolGroup(groupId) { return this.toolGroups.get(groupId) } /** * Remove a tool group. */ removeToolGroup(groupId) { const toolGroup = this.toolGroups.get(groupId) if (toolGroup) { toolGroup.container.remove() this.toolGroups.delete(groupId) } } }