mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-29 11:19:44 +02:00
[eric] split: extract ToolCallBubble parsers + colorize + chrome
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
@@ -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 (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: c.accent.primary,
|
||||
animation: 'tool-pulse 1.5s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
fontFamily: c.font.mono,
|
||||
color: c.text.tertiary,
|
||||
minWidth: 28,
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{display}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -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 (
|
||||
<span key={i}>
|
||||
<span style={{ color: tc.DIM_COLOR }}>{line.slice(0, colonIdx + 1)}</span>
|
||||
<span style={{ color: tc.CMD_COLOR }}>{line.slice(colonIdx + 1)}</span>
|
||||
{nl}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={i} style={{ color: tc.CMD_COLOR }}>{line}{nl}</span>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isBashTool(toolName)) return <span style={{ color: tc.CMD_COLOR }}>{text}</span>;
|
||||
|
||||
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 <span key={i} style={{ color: tc.PATH_COLOR }}>{line}{nl}</span>;
|
||||
if (line.startsWith('+ '))
|
||||
return <span key={i} style={{ color: tc.ADD_COLOR }}>{line}{nl}</span>;
|
||||
if (line.startsWith('- '))
|
||||
return <span key={i} style={{ color: tc.DEL_COLOR }}>{line}{nl}</span>;
|
||||
return <span key={i} style={{ color: tc.CMD_COLOR }}>{line}{nl}</span>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 <span key={i} style={{ color: tc.PATH_COLOR }}>{line}{nl}</span>;
|
||||
return <span key={i} style={{ color: tc.CMD_COLOR, opacity: 0.7 }}>{line}{nl}</span>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (n === 'read' || n === 'glob' || n === 'webfetch') {
|
||||
if (/^\//.test(text) || text.includes('/'))
|
||||
return <span style={{ color: tc.PATH_COLOR }}>{text}</span>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<span key={i}>
|
||||
<span style={{ color: tc.DIM_COLOR }}>pattern: </span>
|
||||
<span style={{ color: tc.WARN_COLOR }}>{line.slice(9)}</span>
|
||||
{nl}
|
||||
</span>
|
||||
);
|
||||
if (line.startsWith('path:'))
|
||||
return (
|
||||
<span key={i}>
|
||||
<span style={{ color: tc.DIM_COLOR }}>path: </span>
|
||||
<span style={{ color: tc.PATH_COLOR }}>{line.slice(6)}</span>
|
||||
{nl}
|
||||
</span>
|
||||
);
|
||||
return <span key={i} style={{ color: tc.CMD_COLOR }}>{line}{nl}</span>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <span style={{ color: tc.CMD_COLOR }}>{text}</span>;
|
||||
}
|
||||
|
||||
export function colorizeOutput(toolName: string, text: string, tc: TermColors): React.ReactNode {
|
||||
if (!text) return <span style={{ color: tc.DIM_COLOR, fontStyle: 'italic' }}>(empty)</span>;
|
||||
|
||||
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 <span key={i} style={{ color: tc.PATH_COLOR }}>{line}{nl}</span>;
|
||||
|
||||
if (n === 'grep' || n === 'ripgrep') {
|
||||
const grepMatch = line.match(/^(\S+?:\d+[:-])/);
|
||||
if (grepMatch) {
|
||||
return (
|
||||
<span key={i}>
|
||||
<span style={{ color: tc.PATH_COLOR }}>{grepMatch[1]}</span>
|
||||
<span style={{ color: tc.OUTPUT_COLOR }}>{line.slice(grepMatch[1].length)}</span>
|
||||
{nl}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const fileHeader = line.match(/^(\S+\.\w+)$/);
|
||||
if (fileHeader)
|
||||
return <span key={i} style={{ color: tc.PATH_COLOR, fontWeight: 600 }}>{line}{nl}</span>;
|
||||
}
|
||||
|
||||
if (line.startsWith('@@') && line.includes('@@'))
|
||||
return <span key={i} style={{ color: tc.DIFF_HEADER_COLOR }}>{line}{nl}</span>;
|
||||
if (line.startsWith('+'))
|
||||
return <span key={i} style={{ color: tc.ADD_COLOR }}>{line}{nl}</span>;
|
||||
if (line.startsWith('-'))
|
||||
return <span key={i} style={{ color: tc.DEL_COLOR }}>{line}{nl}</span>;
|
||||
|
||||
if (/\b[Ee]rror\b/.test(line))
|
||||
return <span key={i} style={{ color: tc.STDERR_COLOR }}>{line}{nl}</span>;
|
||||
if (/\b[Ww]arning\b/.test(line))
|
||||
return <span key={i} style={{ color: tc.WARN_COLOR }}>{line}{nl}</span>;
|
||||
|
||||
if (n === 'read') {
|
||||
const lineNumMatch = line.match(/^(\s*\d+\s*[|:])/);
|
||||
if (lineNumMatch) {
|
||||
return (
|
||||
<span key={i}>
|
||||
<span style={{ color: tc.NUM_COLOR, opacity: 0.6 }}>{lineNumMatch[1]}</span>
|
||||
<span style={{ color: tc.OUTPUT_COLOR }}>{line.slice(lineNumMatch[1].length)}</span>
|
||||
{nl}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <span key={i} style={{ color: tc.OUTPUT_COLOR }}>{line}{nl}</span>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
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} `;
|
||||
}
|
||||
Reference in New Issue
Block a user