From 0731e2b293acf22b892f70ca69cca62047d1b0ee Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 23 May 2026 05:20:45 -0700 Subject: [PATCH] [eric] split: extract ToolCallBubble parsers + colorize + chrome --- .../app/pages/AgentChat/agentToolParsing.ts | 65 +++++ .../src/app/pages/AgentChat/mcpToolName.ts | 66 +++++ .../app/pages/AgentChat/toolBubbleChrome.tsx | 75 +++++ .../src/app/pages/AgentChat/toolColorize.tsx | 218 ++++++++++++++ .../app/pages/AgentChat/toolResultParsing.ts | 271 ++++++++++++++++++ 5 files changed, 695 insertions(+) create mode 100644 frontend/src/app/pages/AgentChat/agentToolParsing.ts create mode 100644 frontend/src/app/pages/AgentChat/mcpToolName.ts create mode 100644 frontend/src/app/pages/AgentChat/toolBubbleChrome.tsx create mode 100644 frontend/src/app/pages/AgentChat/toolColorize.tsx create mode 100644 frontend/src/app/pages/AgentChat/toolResultParsing.ts diff --git a/frontend/src/app/pages/AgentChat/agentToolParsing.ts b/frontend/src/app/pages/AgentChat/agentToolParsing.ts new file mode 100644 index 00000000..1bec7fdc --- /dev/null +++ b/frontend/src/app/pages/AgentChat/agentToolParsing.ts @@ -0,0 +1,65 @@ +import { parseMcpToolName } from './mcpToolName'; + +export function isBrowserAgentTool(name: string): boolean { + if (name === 'CreateBrowserAgent' || name === 'BrowserAgent' || name === 'BrowserAgents') return true; + const mcp = parseMcpToolName(name); + return mcp.isMcp && mcp.serverSlug === 'openswarm-browser-agent'; +} + +export function isInvokeAgentTool(name: string): boolean { + if (name === 'InvokeAgent') return true; + const mcp = parseMcpToolName(name); + return mcp.isMcp && mcp.serverSlug === 'openswarm-invoke-agent'; +} + +export function isCreateAgentTool(name: string): boolean { + return name === 'Agent'; +} + +export function parseInvokedSessionId(rawText: string): string | null { + const match = rawText.match(/\(forked session:\s*([a-f0-9]+)\)/); + return match ? match[1] : null; +} + +export interface InvokeAgentParsed { + agentName: string; + sessionId: string | null; + cost: string | null; + response: string; +} + +export function parseCreateAgentResult(rawText: string): string { + if (!rawText) return ''; + try { + const parsed = JSON.parse(rawText); + if (typeof parsed === 'string') return parsed; + if (typeof parsed === 'object' && parsed !== null) { + if (parsed.text) return parsed.text; + if (parsed.content) return typeof parsed.content === 'string' ? parsed.content : JSON.stringify(parsed.content); + if (parsed.result) return typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result); + } + } catch {} + return rawText; +} + +export function parseInvokeAgentResult(rawText: string): InvokeAgentParsed | null { + const headerMatch = rawText.match( + /\*\*Invoked Agent Result\*\*(?:\s*;\s*(.+?))?\s*\(forked session:\s*([a-f0-9]+)\)/, + ); + if (!headerMatch) return null; + + const agentName = headerMatch[1]?.trim() || 'Agent'; + const sessionId = headerMatch[2]; + + const costMatch = rawText.match(/\*Cost:\s*\$([0-9.]+)\*/); + const cost = costMatch ? costMatch[1] : null; + + const bodyStart = rawText.indexOf('\n\n'); + let response = bodyStart >= 0 ? rawText.slice(bodyStart + 2).trim() : ''; + if (response.startsWith('*Cost:')) { + const afterCost = response.indexOf('\n'); + response = afterCost >= 0 ? response.slice(afterCost + 1).trim() : ''; + } + + return { agentName, sessionId, cost, response }; +} diff --git a/frontend/src/app/pages/AgentChat/mcpToolName.ts b/frontend/src/app/pages/AgentChat/mcpToolName.ts new file mode 100644 index 00000000..0bb911d8 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/mcpToolName.ts @@ -0,0 +1,66 @@ +export interface McpToolInfo { + isMcp: boolean; + serverSlug: string; + action: string; + service: string; + displayName: string; +} + +export function parseMcpToolName(rawName: string): McpToolInfo { + const m = rawName.match(/^mcp__([^_]+(?:-[^_]+)*)__(.+)$/); + if (!m) return { isMcp: false, serverSlug: '', action: '', service: '', displayName: rawName }; + const serverSlug = m[1]; + const action = m[2]; + const spaced = action.replace(/_/g, ' ').toLowerCase(); + const display = spaced.charAt(0).toUpperCase() + spaced.slice(1); + + const lower = action.toLowerCase(); + let service = ''; + if (lower.includes('gmail') || lower.includes('email') || lower.includes('mail')) service = 'gmail'; + else if (lower.includes('calendar') || lower.includes('event') || lower.includes('freebusy')) service = 'calendar'; + else if (lower.includes('drive') || lower.includes('file')) service = 'drive'; + else if (lower.includes('sheet') || lower.includes('spreadsheet')) service = 'sheets'; + else if (lower.includes('doc') || lower.includes('paragraph')) service = 'docs'; + else if (lower.includes('contact')) service = 'contacts'; + + return { isMcp: true, serverSlug, action, service, displayName: display }; +} + +export function getMcpInputSummary(input: any): string { + if (!input || typeof input !== 'object') return ''; + const keys = Object.keys(input); + if (keys.length === 0) return ''; + if (keys.length === 1) { + const v = input[keys[0]]; + const s = typeof v === 'string' ? v : JSON.stringify(v); + return s.length > 60 ? s.slice(0, 60) + '…' : s; + } + return keys.slice(0, 3).map((k) => { + const v = input[k]; + const s = typeof v === 'string' ? v : JSON.stringify(v); + return `${k}: ${s.length > 30 ? s.slice(0, 30) + '…' : s}`; + }).join(' '); +} + +export function getMcpShortAction(mcpInfo: McpToolInfo): string { + const { action, service } = mcpInfo; + let short = action; + if (service && action.toLowerCase().startsWith(service.toLowerCase() + '_')) { + short = action.slice(service.length + 1); + } + const lower = short.replace(/_/g, ' ').toLowerCase(); + return lower.charAt(0).toUpperCase() + lower.slice(1); +} + +export function getGmailHeader(msg: any, name: string): string { + if (msg.payload?.headers && Array.isArray(msg.payload.headers)) { + const h = msg.payload.headers.find( + (hdr: any) => (hdr.name || '').toLowerCase() === name.toLowerCase() + ); + if (h) return h.value || ''; + } + if (msg.headers && typeof msg.headers === 'object' && !Array.isArray(msg.headers)) { + return msg.headers[name] || msg.headers[name.toLowerCase()] || ''; + } + return ''; +} diff --git a/frontend/src/app/pages/AgentChat/toolBubbleChrome.tsx b/frontend/src/app/pages/AgentChat/toolBubbleChrome.tsx new file mode 100644 index 00000000..267301c6 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/toolBubbleChrome.tsx @@ -0,0 +1,75 @@ +import React, { useState, useEffect } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +let toolCallKeyframesInjected = false; +export function ensureToolCallKeyframes() { + if (toolCallKeyframesInjected) return; + toolCallKeyframesInjected = true; + const style = document.createElement('style'); + style.setAttribute('data-tool-call-keyframes', ''); + style.textContent = ` +@keyframes tool-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} +@keyframes border-glow { + 0%, 100% { box-shadow: 0 0 0 0 rgba(var(--glow-rgb), 0); } + 50% { box-shadow: 0 0 10px 2px rgba(var(--glow-rgb), 0.12); } +} +@keyframes blink-cursor { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} +`; + document.head.appendChild(style); +} + +export const ElapsedTimer: React.FC<{ startTime: string }> = ({ startTime }) => { + const c = useClaudeTokens(); + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + const start = new Date(startTime).getTime(); + const tick = () => setElapsed(Math.floor((Date.now() - start) / 1000)); + tick(); + const interval = setInterval(tick, 1000); + return () => clearInterval(interval); + }, [startTime]); + + const mins = Math.floor(elapsed / 60); + const secs = elapsed % 60; + const display = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; + + return ( + + + + {display} + + + ); +}; + +export function formatElapsed(ms: number): string { + if (ms >= 60000) return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`; + if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`; + return `${ms}ms`; +} diff --git a/frontend/src/app/pages/AgentChat/toolColorize.tsx b/frontend/src/app/pages/AgentChat/toolColorize.tsx new file mode 100644 index 00000000..d3efdf8d --- /dev/null +++ b/frontend/src/app/pages/AgentChat/toolColorize.tsx @@ -0,0 +1,218 @@ +import React from 'react'; +import { useThemeMode } from '@/shared/styles/ThemeContext'; +import { parseMcpToolName } from './mcpToolName'; +import { isBashTool } from './toolResultParsing'; + +export interface TermColors { + TERM_BG: string; + TERM_BORDER: string; + PROMPT_COLOR: string; + CMD_COLOR: string; + OUTPUT_COLOR: string; + PATH_COLOR: string; + ADD_COLOR: string; + DEL_COLOR: string; + STDERR_COLOR: string; + WARN_COLOR: string; + NUM_COLOR: string; + DIM_COLOR: string; + DIFF_HEADER_COLOR: string; + SCROLLBAR_THUMB: string; +} + +const darkTermColors: TermColors = { + TERM_BG: '#131520', + TERM_BORDER: '#1e2030', + PROMPT_COLOR: '#7ec699', + CMD_COLOR: '#e8ecf4', + OUTPUT_COLOR: '#a0aab8', + PATH_COLOR: '#82aaff', + ADD_COLOR: '#7ec699', + DEL_COLOR: '#ff8787', + STDERR_COLOR: '#ff8787', + WARN_COLOR: '#ffcb6b', + NUM_COLOR: '#f78c6c', + DIM_COLOR: '#555b6e', + DIFF_HEADER_COLOR: '#c792ea', + SCROLLBAR_THUMB: '#2a2d3e', +}; + +const lightTermColors: TermColors = { + TERM_BG: '#f4f3ee', + TERM_BORDER: '#e2e0d8', + PROMPT_COLOR: '#2d7a3e', + CMD_COLOR: '#2a2a28', + OUTPUT_COLOR: '#555550', + PATH_COLOR: '#3060a8', + ADD_COLOR: '#2d7a3e', + DEL_COLOR: '#c03030', + STDERR_COLOR: '#c03030', + WARN_COLOR: '#8a6518', + NUM_COLOR: '#c05020', + DIM_COLOR: '#9e9c95', + DIFF_HEADER_COLOR: '#7c4daa', + SCROLLBAR_THUMB: '#ccc9c0', +}; + +export function useTermColors(): TermColors { + const { mode } = useThemeMode(); + return mode === 'dark' ? darkTermColors : lightTermColors; +} + +export function colorizeInput(toolName: string, text: string, tc: TermColors): React.ReactNode { + const n = toolName.toLowerCase(); + const mcp = parseMcpToolName(toolName); + + if (mcp.isMcp) { + const lines = text.split('\n'); + return ( + <> + {lines.map((line, i) => { + const nl = i < lines.length - 1 ? '\n' : ''; + const colonIdx = line.indexOf(':'); + if (colonIdx > 0 && colonIdx < 30) { + return ( + + {line.slice(0, colonIdx + 1)} + {line.slice(colonIdx + 1)} + {nl} + + ); + } + return {line}{nl}; + })} + + ); + } + + if (isBashTool(toolName)) return {text}; + + if (n === 'edit' || n === 'strreplace' || n === 'multiedit') { + const lines = text.split('\n'); + return ( + <> + {lines.map((line, i) => { + const nl = i < lines.length - 1 ? '\n' : ''; + if (i === 0 && (line.startsWith('/') || line.includes('.'))) + return {line}{nl}; + if (line.startsWith('+ ')) + return {line}{nl}; + if (line.startsWith('- ')) + return {line}{nl}; + return {line}{nl}; + })} + + ); + } + + if (n === 'write') { + const lines = text.split('\n'); + return ( + <> + {lines.map((line, i) => { + const nl = i < lines.length - 1 ? '\n' : ''; + if (i === 0 && (line.startsWith('/') || line.includes('.'))) + return {line}{nl}; + return {line}{nl}; + })} + + ); + } + + if (n === 'read' || n === 'glob' || n === 'webfetch') { + if (/^\//.test(text) || text.includes('/')) + return {text}; + } + + if (n === 'grep' || n === 'ripgrep') { + const lines = text.split('\n'); + return ( + <> + {lines.map((line, i) => { + const nl = i < lines.length - 1 ? '\n' : ''; + if (line.startsWith('pattern:')) + return ( + + pattern: + {line.slice(9)} + {nl} + + ); + if (line.startsWith('path:')) + return ( + + path: + {line.slice(6)} + {nl} + + ); + return {line}{nl}; + })} + + ); + } + + return {text}; +} + +export function colorizeOutput(toolName: string, text: string, tc: TermColors): React.ReactNode { + if (!text) return (empty); + + const lines = text.split('\n'); + const n = toolName.toLowerCase(); + + return ( + <> + {lines.map((line, i) => { + const nl = i < lines.length - 1 ? '\n' : ''; + const trimmed = line.trimStart(); + + if (/^\/\S+/.test(trimmed)) + return {line}{nl}; + + if (n === 'grep' || n === 'ripgrep') { + const grepMatch = line.match(/^(\S+?:\d+[:-])/); + if (grepMatch) { + return ( + + {grepMatch[1]} + {line.slice(grepMatch[1].length)} + {nl} + + ); + } + const fileHeader = line.match(/^(\S+\.\w+)$/); + if (fileHeader) + return {line}{nl}; + } + + if (line.startsWith('@@') && line.includes('@@')) + return {line}{nl}; + if (line.startsWith('+')) + return {line}{nl}; + if (line.startsWith('-')) + return {line}{nl}; + + if (/\b[Ee]rror\b/.test(line)) + return {line}{nl}; + if (/\b[Ww]arning\b/.test(line)) + return {line}{nl}; + + if (n === 'read') { + const lineNumMatch = line.match(/^(\s*\d+\s*[|:])/); + if (lineNumMatch) { + return ( + + {lineNumMatch[1]} + {line.slice(lineNumMatch[1].length)} + {nl} + + ); + } + } + + return {line}{nl}; + })} + + ); +} diff --git a/frontend/src/app/pages/AgentChat/toolResultParsing.ts b/frontend/src/app/pages/AgentChat/toolResultParsing.ts new file mode 100644 index 00000000..784d4a62 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/toolResultParsing.ts @@ -0,0 +1,271 @@ +import { AgentMessage } from '@/shared/state/agentsSlice'; +import { prettyPath, prettyUrl, quoteQuery, bashCommandDetail } from './toolLabels'; +import { parseMcpToolName, getMcpInputSummary, getGmailHeader } from './mcpToolName'; + +export function getToolData(call: AgentMessage) { + const content = typeof call.content === 'object' ? call.content : {}; + return { + toolName: content.tool || 'Unknown', + input: content.input || {}, + isDenied: content.approved === false, + toolId: content.id, + }; +} + +export function isBashTool(name: string) { + return name === 'Bash' || name === 'bash'; +} + +export function getInputSummary(toolName: string, input: any): string { + try { + const mcp = parseMcpToolName(toolName); + if (mcp.isMcp) return getMcpInputSummary(input); + + const n = toolName.toLowerCase(); + if (isBashTool(toolName)) { + return bashCommandDetail(input.command || ''); + } + if (n === 'read' || n === 'write' || n === 'edit' || n === 'multiedit' || n === 'strreplace') + return prettyPath(input.file_path || input.path || ''); + if (n === 'glob') return input.pattern || input.glob || input.glob_pattern || ''; + if (n === 'grep' || n === 'ripgrep') { + const pat = input.pattern || input.regex || ''; + const path = input.path || input.directory || ''; + const q = quoteQuery(pat); + return path ? `${q} in ${prettyPath(path)}` : q; + } + if (n === 'websearch') return quoteQuery(input.query || input.search_term || ''); + if (n === 'webfetch') return prettyUrl(input.url || ''); + if (n === 'todoread' || n === 'todowrite') return ''; + if (n === 'ls') return prettyPath(input.path || '.'); + if (n === 'mcpactivate') return ''; + if (n === 'mcpsearch' || n === 'outputsearch') return quoteQuery(input.query || ''); + if (n === 'outputactivate') return input.output_id || ''; + if (n === 'renderoutput') return input.output_id || ''; + return ''; + } catch { + return ''; + } +} + +function formatMcpInputDisplay(input: any): string { + if (!input || typeof input !== 'object') return String(input ?? ''); + return Object.entries(input) + .map(([k, v]) => { + const s = typeof v === 'string' ? v : JSON.stringify(v, null, 2); + return `${k}: ${s}`; + }) + .join('\n'); +} + +export function formatInputDisplay(toolName: string, input: any): string { + try { + const mcp = parseMcpToolName(toolName); + if (mcp.isMcp) return formatMcpInputDisplay(input); + + const n = toolName.toLowerCase(); + if (isBashTool(toolName)) return input.command || ''; + if (n === 'read') { + const p = input.file_path || input.path || ''; + const parts = [p]; + if (input.offset) parts.push(`offset: ${input.offset}`); + if (input.limit) parts.push(`limit: ${input.limit}`); + return parts.join(' '); + } + if (n === 'write') { + const p = input.file_path || input.path || ''; + const content = input.content || ''; + const preview = content.length > 300 ? content.slice(0, 300) + '\n…' : content; + return `${p}\n\n${preview}`; + } + if (n === 'edit' || n === 'strreplace') { + const p = input.file_path || input.path || ''; + const old = input.old_string || input.old_text || ''; + const nw = input.new_string || input.new_text || ''; + const lines = [p, '']; + if (old) { + const oldPreview = old.length > 200 ? old.slice(0, 200) + '…' : old; + lines.push(`- ${oldPreview.split('\n').join('\n- ')}`); + } + if (nw) { + const nwPreview = nw.length > 200 ? nw.slice(0, 200) + '…' : nw; + lines.push(`+ ${nwPreview.split('\n').join('\n+ ')}`); + } + return lines.join('\n'); + } + if (n === 'multiedit') { + const p = input.file_path || input.path || ''; + const edits = input.edits || []; + const lines = [p]; + for (const e of edits.slice(0, 3)) { + const old = e.old_string || e.old_text || ''; + lines.push(` - ${old.split('\n')[0].slice(0, 60)}…`); + } + if (edits.length > 3) lines.push(` … +${edits.length - 3} more edits`); + return lines.join('\n'); + } + if (n === 'glob') return input.pattern || input.glob || input.glob_pattern || ''; + if (n === 'grep' || n === 'ripgrep') { + const pat = input.pattern || input.regex || ''; + const path = input.path || input.directory || ''; + const parts = [`pattern: ${pat}`]; + if (path) parts.push(`path: ${path}`); + if (input.include) parts.push(`include: ${input.include}`); + return parts.join('\n'); + } + if (n === 'websearch') return input.query || input.search_term || ''; + if (n === 'webfetch') return input.url || ''; + } catch {} + if (typeof input === 'string') return input; + return JSON.stringify(input, null, 2); +} + +export interface ParsedBashResult { + type: 'bash'; + stdout: string; + stderr: string; + exitCode: number | null; +} + +export interface ParsedTextResult { + type: 'text'; + content: string; + isError?: boolean; +} + +export interface ParsedMcpResult { + type: 'mcp'; + service: string; + action: string; + data: Record; + rawText: string; +} + +export type ParsedResult = ParsedBashResult | ParsedTextResult | ParsedMcpResult; + +export function parseToolResult(toolName: string, rawText: string): ParsedResult { + if (isBashTool(toolName)) { + try { + const parsed = JSON.parse(rawText); + if (typeof parsed === 'object' && parsed !== null && 'stdout' in parsed) { + const exitMatch = (parsed.stdout || '').match(/[Ee]xit code:\s*(\d+)/); + return { + type: 'bash', + stdout: parsed.stdout || '', + stderr: parsed.stderr || '', + exitCode: exitMatch ? parseInt(exitMatch[1], 10) : null, + }; + } + } catch {} + } + + const mcp = parseMcpToolName(toolName); + if (mcp.isMcp) { + try { + let parsed = JSON.parse(rawText); + + if (Array.isArray(parsed) && parsed.some((b: any) => b?.type === 'text' && typeof b?.text === 'string')) { + const textContent = parsed + .filter((b: any) => b?.type === 'text') + .map((b: any) => b.text) + .join('\n'); + try { + parsed = JSON.parse(textContent); + } catch { + return { type: 'mcp', service: mcp.service, action: mcp.action, data: {}, rawText: textContent }; + } + } + + if (typeof parsed === 'object' && parsed !== null) { + return { type: 'mcp', service: mcp.service, action: mcp.action, data: parsed, rawText }; + } + } catch {} + return { type: 'mcp', service: mcp.service, action: mcp.action, data: {}, rawText }; + } + + try { + const parsed = JSON.parse(rawText); + if (typeof parsed === 'object' && parsed !== null) { + if ('stdout' in parsed) { + return { type: 'text', content: parsed.stdout || '' }; + } + if ('content' in parsed && typeof parsed.content === 'string') { + return { type: 'text', content: parsed.content, isError: !!parsed.is_error }; + } + if ('result' in parsed && typeof parsed.result === 'string') { + return { type: 'text', content: parsed.result }; + } + if ('output' in parsed && typeof parsed.output === 'string') { + return { type: 'text', content: parsed.output }; + } + const n = toolName.toLowerCase(); + if (n === 'glob' && Array.isArray(parsed)) { + return { type: 'text', content: parsed.join('\n') }; + } + } + } catch {} + + return { type: 'text', content: rawText }; +} + +export function getResultSummary(toolName: string, rawText: string): string { + const parsed = parseToolResult(toolName, rawText); + + if (parsed.type === 'bash') { + const lines = parsed.stdout.split('\n').filter((l) => l.trim()).length; + if (parsed.exitCode !== null && parsed.exitCode !== 0) return `exit ${parsed.exitCode}`; + if (parsed.stderr && !parsed.stdout) return 'stderr'; + return `${lines} line${lines !== 1 ? 's' : ''}`; + } + + if (parsed.type === 'mcp') { + const d = parsed.data; + if (parsed.service === 'gmail') { + const subj = d.subject || getGmailHeader(d, 'Subject'); + if (subj) return subj; + if (Array.isArray(d.messages)) return `${d.messages.length} email${d.messages.length !== 1 ? 's' : ''}`; + if (d.id || d.messageId) return 'sent'; + } + if (parsed.service === 'calendar') { + if (d.summary) return d.summary.slice(0, 40); + if (Array.isArray(d.items)) return `${d.items.length} event${d.items.length !== 1 ? 's' : ''}`; + } + if (parsed.service === 'drive') { + if (d.name) return d.name; + if (Array.isArray(d.files)) return `${d.files.length} file${d.files.length !== 1 ? 's' : ''}`; + } + if (d.error || d.is_error) return 'error'; + return ''; + } + + const text = parsed.content; + const lines = text.split('\n'); + const lineCount = lines.length; + const n = toolName.toLowerCase(); + + try { + if (n === 'glob') { + const fileCount = lines.filter((l) => l.trim()).length; + return `${fileCount} file${fileCount !== 1 ? 's' : ''}`; + } + if (n === 'grep' || n === 'ripgrep') { + const matchCount = lines.filter((l) => l.trim()).length; + return `${matchCount} match${matchCount !== 1 ? 'es' : ''}`; + } + if (n === 'read') return `${lineCount} lines`; + if (n === 'write') return ''; + if (n === 'edit' || n === 'multiedit' || n === 'strreplace') return ''; + if (n === 'websearch') return 'results'; + if (n === 'webfetch') return `${lineCount} lines`; + if (parsed.isError) return 'error'; + } catch {} + + return `${lineCount} line${lineCount !== 1 ? 's' : ''}`; +} + +export function getPromptPrefix(toolName: string): string { + if (isBashTool(toolName)) return '$ '; + const mcp = parseMcpToolName(toolName); + if (mcp.isMcp) return `❯ ${mcp.displayName} `; + return `❯ ${toolName} `; +}