[Haik]: Agentic Refactor 5, Tool Ui Approvals and Questions

This commit is contained in:
haikdc
2026-03-30 16:39:06 -07:00
parent 35e2503999
commit 8ccc3b4c5b
8 changed files with 494 additions and 793 deletions
+10 -195
View File
@@ -1,195 +1,10 @@
import React, { useState, useMemo } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Chip from '@mui/material/Chip';
import Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import ExtensionIcon from '@mui/icons-material/Extension';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { ApprovalRequest } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { parseMcpToolName, useMcpToolMeta, getMcpInputSummary } from './approvalUtils';
import ToolPreview, { getToolIcon, CodeBlock } from './ToolPreview';
import { QuestionForm } from './QuestionForm';
export { QuestionForm } from './QuestionForm';
export type { QuestionFormProps } from './QuestionForm';
export { BatchApprovalBar } from './BatchApprovalBar';
export { parseMcpToolName, useMcpToolMeta } from './approvalUtils';
export type { ParsedTool } from './approvalUtils';
export { getToolIcon } from './ToolPreview';
interface Props {
request: ApprovalRequest;
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onDeny: (requestId: string, message?: string) => void;
}
const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) => {
const c = useClaudeTokens();
const [denyMessage, setDenyMessage] = useState('');
const [showDenyInput, setShowDenyInput] = useState(false);
const [detailsExpanded, setDetailsExpanded] = useState(false);
const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]);
const meta = useMcpToolMeta(parsed);
const accentColor = meta.integration?.color || c.status.warning;
const summary = parsed.isMcp ? getMcpInputSummary(parsed.actionName, request.tool_input) : '';
if (!parsed.isMcp) {
return (
<Box sx={{ bgcolor: c.status.warningBg, border: '1px solid rgba(128,92,31,0.2)', borderRadius: 2.5, p: 2, mx: 2, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.25 }}>
<Box sx={{ color: c.status.warning, display: 'flex', alignItems: 'center' }}>
{getToolIcon(request.tool_name)}
</Box>
<Typography sx={{ color: c.status.warning, fontWeight: 700, fontSize: '0.85rem' }}>
Permission Required
</Typography>
<Chip label={request.tool_name} size="small" sx={{
height: 20, fontSize: '0.7rem', fontWeight: 600, fontFamily: c.font.mono,
bgcolor: 'rgba(128,92,31,0.15)', color: c.status.warning, border: 'none',
}} />
</Box>
<Box sx={{ mb: 1.5 }}>
<ToolPreview request={request} tokens={c} />
</Box>
{showDenyInput && (
<TextField placeholder="Reason for denying (optional)..." value={denyMessage}
onChange={(e) => setDenyMessage(e.target.value)} fullWidth size="small"
sx={{
mb: 1.5, '& .MuiOutlinedInput-root': {
color: c.text.primary, fontSize: '0.8rem',
'& fieldset': { borderColor: c.border.strong },
'&.Mui-focused fieldset': { borderColor: c.status.error },
},
}} />
)}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="contained" startIcon={<CheckIcon />} onClick={() => onApprove(request.id)}
sx={{ bgcolor: c.status.success, '&:hover': { bgcolor: '#1e4d15' }, fontWeight: 600, fontSize: '0.8rem' }}>
Approve
</Button>
{showDenyInput ? (
<Button variant="contained" startIcon={<CloseIcon />}
onClick={() => { onDeny(request.id, denyMessage || undefined); setShowDenyInput(false); setDenyMessage(''); }}
sx={{ bgcolor: c.status.error, '&:hover': { bgcolor: '#8f2828' }, fontWeight: 600, fontSize: '0.8rem' }}>
Deny
</Button>
) : (
<Button variant="outlined" onClick={() => setShowDenyInput(true)}
sx={{ borderColor: c.status.error, color: c.status.error, '&:hover': { borderColor: '#8f2828', bgcolor: 'rgba(181,51,51,0.04)' }, fontWeight: 600, fontSize: '0.8rem' }}>
Deny
</Button>
)}
</Box>
</Box>
);
}
return (
<Box sx={{
bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderLeft: `3px solid ${accentColor}`,
borderRadius: 2.5, p: 0, mx: 2, mb: 1, overflow: 'hidden',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, px: 2, pt: 1.75, pb: 0.5 }}>
<Box sx={{
width: 32, height: 32, borderRadius: 1.5, bgcolor: `${accentColor}14`,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
{meta.integration?.icon || <ExtensionIcon sx={{ fontSize: 18, color: accentColor }} />}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.9rem' }}>
{parsed.displayName}
</Typography>
<Chip label={meta.serverLabel || parsed.serverSlug} size="small" sx={{
height: 18, fontSize: '0.65rem', fontWeight: 500,
bgcolor: `${accentColor}12`, color: accentColor, border: 'none',
'& .MuiChip-label': { px: 0.6 },
}} />
</Box>
{meta.description && (
<Typography sx={{
color: c.text.tertiary, fontSize: '0.78rem', lineHeight: 1.3, mt: 0.15,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{meta.description}
</Typography>
)}
</Box>
</Box>
<Box sx={{ px: 2, pt: 1, pb: 0.5 }}>
{summary && (
<Box sx={{
display: 'flex', alignItems: 'center', gap: 0.5, cursor: 'pointer',
'&:hover .expand-icon': { color: c.text.secondary },
}} onClick={() => setDetailsExpanded((v) => !v)}>
<Typography sx={{
color: c.text.secondary, fontSize: '0.82rem', fontFamily: c.font.mono,
flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{summary}
</Typography>
<IconButton className="expand-icon" size="small" sx={{ color: c.text.ghost, p: 0.25, flexShrink: 0 }}>
{detailsExpanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Box>
)}
<Collapse in={detailsExpanded || !summary}>
<Box sx={{ mt: summary ? 0.75 : 0 }}>
<CodeBlock tokens={c}>{JSON.stringify(request.tool_input, null, 2)}</CodeBlock>
</Box>
</Collapse>
</Box>
{showDenyInput && (
<Box sx={{ px: 2, pb: 0.5 }}>
<TextField placeholder="Reason for denying (optional)..." value={denyMessage}
onChange={(e) => setDenyMessage(e.target.value)} fullWidth size="small" autoFocus
sx={{
'& .MuiOutlinedInput-root': {
color: c.text.primary, fontSize: '0.8rem',
'& fieldset': { borderColor: c.border.strong },
'&.Mui-focused fieldset': { borderColor: c.status.error },
},
}} />
</Box>
)}
<Box sx={{ display: 'flex', gap: 1, px: 2, pt: 1, pb: 1.75 }}>
<Button variant="contained" startIcon={<CheckIcon />} onClick={() => onApprove(request.id)}
sx={{ bgcolor: c.status.success, '&:hover': { bgcolor: '#1e4d15' }, fontWeight: 600, fontSize: '0.8rem', textTransform: 'none', borderRadius: 1.5, px: 2 }}>
Approve
</Button>
{showDenyInput ? (
<Button variant="contained" startIcon={<CloseIcon />}
onClick={() => { onDeny(request.id, denyMessage || undefined); setShowDenyInput(false); setDenyMessage(''); }}
sx={{ bgcolor: c.status.error, '&:hover': { bgcolor: '#8f2828' }, fontWeight: 600, fontSize: '0.8rem', textTransform: 'none', borderRadius: 1.5, px: 2 }}>
Deny
</Button>
) : (
<Button variant="outlined" onClick={() => setShowDenyInput(true)}
sx={{ borderColor: c.status.error, color: c.status.error, '&:hover': { borderColor: '#8f2828', bgcolor: 'rgba(181,51,51,0.04)' }, fontWeight: 600, fontSize: '0.8rem', textTransform: 'none', borderRadius: 1.5, px: 2 }}>
Deny
</Button>
)}
</Box>
</Box>
);
};
const ApprovalBar: React.FC<Props> = (props) => {
if (props.request.tool_name === 'AskUserQuestion') {
return <QuestionForm request={props.request} onApprove={props.onApprove} onDeny={props.onDeny} />;
}
return <GenericApprovalBar {...props} />;
};
export default ApprovalBar;
/**
* Re-export stub — logic moved to toolkit/approval-tools.tsx.
* Kept for backward compatibility with AgentChat, DynamicIsland, and Dashboard imports.
*/
export { ApprovalRouter as default } from './toolkit/approval-tools';
export { ToolQuestion as QuestionForm } from './toolkit/approval-tools';
export type { ToolQuestionProps as QuestionFormProps } from './toolkit/approval-tools';
export { BatchApprovalWrapper as BatchApprovalBar } from './toolkit/approval-tools';
export { parseMcpToolName, useMcpToolMeta, getToolIcon } from './toolkit/approval-tools';
export type { ParsedTool } from './toolkit/approval-tools';
@@ -1,179 +0,0 @@
import React, { useState, useMemo } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import ExtensionIcon from '@mui/icons-material/Extension';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { ApprovalRequest } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { ParsedTool, parseMcpToolName, useMcpToolMeta } from './approvalUtils';
import { getToolIcon } from './ToolPreview';
import { QuestionForm } from './QuestionForm';
import ApprovalBar from './ApprovalBar';
interface ToolGroup {
toolName: string;
parsed: ParsedTool;
requests: ApprovalRequest[];
}
interface BatchApprovalBarProps {
requests: ApprovalRequest[];
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onDeny: (requestId: string, message?: string) => void;
}
export const BatchApprovalBar: React.FC<BatchApprovalBarProps> = ({ requests, onApprove, onDeny }) => {
const c = useClaudeTokens();
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
const questions = requests.filter((r) => r.tool_name === 'AskUserQuestion');
const nonQuestions = requests.filter((r) => r.tool_name !== 'AskUserQuestion');
const groups = useMemo(() => {
const map = new Map<string, ToolGroup>();
for (const req of nonQuestions) {
const existing = map.get(req.tool_name);
if (existing) {
existing.requests.push(req);
} else {
map.set(req.tool_name, {
toolName: req.tool_name,
parsed: parseMcpToolName(req.tool_name),
requests: [req],
});
}
}
return Array.from(map.values());
}, [nonQuestions]);
const handleApproveAll = () => { for (const req of nonQuestions) onApprove(req.id); };
const handleDenyAll = () => { for (const req of nonQuestions) onDeny(req.id); };
const handleApproveGroup = (g: ToolGroup) => { for (const req of g.requests) onApprove(req.id); };
const handleDenyGroup = (g: ToolGroup) => { for (const req of g.requests) onDeny(req.id); };
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{questions.map((req) => (
<QuestionForm key={req.id} request={req} onApprove={onApprove} onDeny={onDeny} />
))}
{nonQuestions.length > 1 && (
<Box sx={{
mx: 2, mb: 0.5, borderRadius: 2.5, border: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.surface, overflow: 'hidden',
}}>
<Box sx={{
display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 1.25,
bgcolor: c.status.warningBg, borderBottom: `1px solid ${c.border.subtle}`,
}}>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: c.status.warning, flex: 1 }}>
{nonQuestions.length} pending approvals
</Typography>
<Button variant="contained" size="small" startIcon={<CheckIcon />} onClick={handleApproveAll}
sx={{ bgcolor: c.status.success, '&:hover': { bgcolor: '#1e4d15' }, fontWeight: 600, fontSize: '0.78rem', textTransform: 'none', borderRadius: 1.5, px: 1.5, minHeight: 30 }}>
Approve All
</Button>
<Button variant="outlined" size="small" startIcon={<CloseIcon />} onClick={handleDenyAll}
sx={{ borderColor: c.status.error, color: c.status.error, '&:hover': { borderColor: '#8f2828', bgcolor: 'rgba(181,51,51,0.04)' }, fontWeight: 600, fontSize: '0.78rem', textTransform: 'none', borderRadius: 1.5, px: 1.5, minHeight: 30 }}>
Deny All
</Button>
</Box>
{groups.map((group) => (
<GroupRow
key={group.toolName}
group={group}
expanded={expandedGroup === group.toolName}
onToggle={() => setExpandedGroup((prev) => prev === group.toolName ? null : group.toolName)}
onApprove={onApprove}
onDeny={onDeny}
onApproveGroup={() => handleApproveGroup(group)}
onDenyGroup={() => handleDenyGroup(group)}
/>
))}
</Box>
)}
{nonQuestions.length === 1 && (
<ApprovalBar request={nonQuestions[0]} onApprove={onApprove} onDeny={onDeny} />
)}
</Box>
);
};
interface GroupRowProps {
group: ToolGroup;
expanded: boolean;
onToggle: () => void;
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onDeny: (requestId: string, message?: string) => void;
onApproveGroup: () => void;
onDenyGroup: () => void;
}
const GroupRow: React.FC<GroupRowProps> = ({ group, expanded, onToggle, onApprove, onDeny, onApproveGroup, onDenyGroup }) => {
const c = useClaudeTokens();
const meta = useMcpToolMeta(group.parsed);
const accentColor = meta.integration?.color || c.status.warning;
return (
<Box sx={{ borderBottom: `1px solid ${c.border.subtle}`, '&:last-child': { borderBottom: 'none' } }}>
<Box
onClick={onToggle}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 1,
cursor: 'pointer', '&:hover': { bgcolor: c.bg.secondary }, transition: 'background-color 0.1s',
}}
>
<Box sx={{
width: 26, height: 26, borderRadius: 1, bgcolor: `${accentColor}14`,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
{group.parsed.isMcp
? (meta.integration?.icon || <ExtensionIcon sx={{ fontSize: 15, color: accentColor }} />)
: getToolIcon(group.toolName)}
</Box>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 600, color: c.text.primary, flex: 1 }}>
{group.parsed.isMcp ? group.parsed.displayName : group.toolName}
</Typography>
<Chip label={`${group.requests.length}`} size="small"
sx={{ height: 20, minWidth: 24, fontSize: '0.72rem', fontWeight: 700, bgcolor: `${accentColor}18`, color: accentColor, border: 'none' }} />
{group.requests.length > 1 && (
<>
<Button variant="text" size="small"
onClick={(e) => { e.stopPropagation(); onApproveGroup(); }}
sx={{ color: c.status.success, fontWeight: 600, fontSize: '0.72rem', textTransform: 'none', minWidth: 0, px: 1, minHeight: 24 }}>
Approve {group.requests.length}
</Button>
<Button variant="text" size="small"
onClick={(e) => { e.stopPropagation(); onDenyGroup(); }}
sx={{ color: c.status.error, fontWeight: 600, fontSize: '0.72rem', textTransform: 'none', minWidth: 0, px: 1, minHeight: 24 }}>
Deny {group.requests.length}
</Button>
</>
)}
<IconButton size="small" sx={{ color: c.text.ghost, p: 0.25 }}>
{expanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Box>
<Collapse in={expanded}>
<Box sx={{ px: 1, pb: 1, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{group.requests.map((req) => (
<ApprovalBar key={req.id} request={req} onApprove={onApprove} onDeny={onDeny} />
))}
</Box>
</Collapse>
</Box>
);
};
@@ -1,238 +0,0 @@
import React, { useCallback, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Chip from '@mui/material/Chip';
import SendIcon from '@mui/icons-material/Send';
import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer';
import { ApprovalRequest } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
function getOptionKey(opt: any): string {
return opt.id || opt.value || opt.label || opt.text || String(opt);
}
function getOptionLabel(opt: any): string {
return opt.label || opt.value || opt.text || String(opt);
}
type Answers = Record<number, string | string[]>;
export interface QuestionFormProps {
request: ApprovalRequest;
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onDeny: (requestId: string, message?: string) => void;
compact?: boolean;
}
const OTHER_KEY = '__other__';
export const QuestionForm: React.FC<QuestionFormProps> = ({ request, onApprove, onDeny, compact }) => {
const c = useClaudeTokens();
const questions: any[] = request.tool_input.questions || [];
const [answers, setAnswers] = useState<Answers>(() => {
const init: Answers = {};
questions.forEach((q: any, i: number) => {
init[i] = q.multiSelect ? [] : '';
});
return init;
});
const [otherActive, setOtherActive] = useState<Record<number, boolean>>({});
const [otherText, setOtherText] = useState<Record<number, string>>({});
const toggleOption = useCallback((qIdx: number, key: string, multi: boolean) => {
setAnswers((prev) => {
const copy = { ...prev };
if (multi) {
const arr = Array.isArray(copy[qIdx]) ? [...(copy[qIdx] as string[])] : [];
const idx = arr.indexOf(key);
if (idx >= 0) arr.splice(idx, 1);
else arr.push(key);
copy[qIdx] = arr;
} else {
copy[qIdx] = copy[qIdx] === key ? '' : key;
}
return copy;
});
if (key !== OTHER_KEY) {
if (!multi) {
setOtherActive((prev) => ({ ...prev, [qIdx]: false }));
setOtherText((prev) => ({ ...prev, [qIdx]: '' }));
}
}
}, []);
const toggleOther = useCallback((qIdx: number, multi: boolean) => {
setOtherActive((prev) => {
const wasActive = !!prev[qIdx];
if (wasActive) {
setOtherText((p) => ({ ...p, [qIdx]: '' }));
}
if (!multi && !wasActive) {
setAnswers((p) => ({ ...p, [qIdx]: '' }));
}
return { ...prev, [qIdx]: !wasActive };
});
}, []);
const setTextAnswer = useCallback((qIdx: number, text: string) => {
setAnswers((prev) => ({ ...prev, [qIdx]: text }));
}, []);
const handleSubmit = () => {
const answersDict: Record<string, string> = {};
questions.forEach((q: any, i: number) => {
const questionText = q.question || q.prompt || q.text || '';
const hasOptions = Array.isArray(q.options) && q.options.length > 0;
let answer = answers[i];
if (hasOptions && otherActive[i] && otherText[i]) {
if (q.multiSelect) {
const arr = Array.isArray(answer) ? [...answer] : [];
arr.push(otherText[i]);
answer = arr;
} else {
answer = otherText[i];
}
}
if (Array.isArray(answer)) {
answersDict[questionText] = answer.join(', ');
} else {
answersDict[questionText] = answer || '';
}
});
onApprove(request.id, { ...request.tool_input, questions, answers: answersDict });
};
const isSelected = (qIdx: number, key: string): boolean => {
const val = answers[qIdx];
if (Array.isArray(val)) return val.includes(key);
return val === key;
};
return (
<Box sx={{
bgcolor: c.bg.secondary, border: `1px solid ${c.accent.primary}33`,
borderRadius: compact ? 2 : 2.5, p: compact ? 1.5 : 2,
mx: compact ? 0 : 2, mb: compact ? 0 : 1,
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.5 }}>
<Box sx={{ color: c.accent.primary, display: 'flex', alignItems: 'center' }}>
<QuestionAnswerIcon sx={{ fontSize: '1rem' }} />
</Box>
<Typography sx={{ color: c.accent.primary, fontWeight: 700, fontSize: '0.85rem' }}>
Agent has a question
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 2 }}>
{questions.map((q: any, i: number) => {
const hasOptions = Array.isArray(q.options) && q.options.length > 0;
const multi = !!q.multiSelect;
const isOtherActive = !!otherActive[i];
return (
<Box key={i}>
{q.header && (
<Typography sx={{ color: c.text.muted, fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.25 }}>
{q.header}
</Typography>
)}
<Typography sx={{ color: c.text.primary, fontSize: '0.85rem', fontWeight: 500, mb: 0.75 }}>
{q.question || q.prompt || q.text || '(question)'}
</Typography>
{hasOptions ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{q.options.map((opt: any) => {
const key = getOptionKey(opt);
const selected = isSelected(i, key);
return (
<Chip
key={key}
label={getOptionLabel(opt)}
size="small"
onClick={() => toggleOption(i, key, multi)}
sx={{
fontSize: '0.78rem', fontWeight: selected ? 600 : 400, cursor: 'pointer',
color: selected ? c.accent.primary : c.text.secondary,
bgcolor: selected ? `${c.accent.primary}18` : 'transparent',
borderColor: selected ? c.accent.primary : c.border.medium,
borderWidth: 1, borderStyle: 'solid', transition: 'all 0.15s ease',
'&:hover': {
bgcolor: selected ? `${c.accent.primary}24` : `${c.text.secondary}0a`,
borderColor: selected ? c.accent.primary : c.text.secondary,
},
}}
/>
);
})}
<Chip
label="Other…"
size="small"
onClick={() => toggleOther(i, multi)}
sx={{
fontSize: '0.78rem', fontWeight: isOtherActive ? 600 : 400,
fontStyle: 'italic', cursor: 'pointer',
color: isOtherActive ? c.accent.primary : c.text.muted,
bgcolor: isOtherActive ? `${c.accent.primary}18` : 'transparent',
borderColor: isOtherActive ? c.accent.primary : c.border.subtle,
borderWidth: 1, borderStyle: 'dashed', transition: 'all 0.15s ease',
'&:hover': {
bgcolor: isOtherActive ? `${c.accent.primary}24` : `${c.text.secondary}0a`,
borderColor: isOtherActive ? c.accent.primary : c.border.medium,
},
}}
/>
</Box>
{isOtherActive && (
<TextField
placeholder="Type your own answer..."
value={otherText[i] || ''}
onChange={(e) => setOtherText((prev) => ({ ...prev, [i]: e.target.value }))}
fullWidth size="small" autoFocus
sx={{
mt: 0.25,
'& .MuiOutlinedInput-root': {
color: c.text.primary, fontSize: '0.82rem',
'& fieldset': { borderColor: c.border.medium },
'&:hover fieldset': { borderColor: c.border.strong },
'&.Mui-focused fieldset': { borderColor: c.accent.primary },
},
}}
/>
)}
</Box>
) : (
<TextField
placeholder="Type your answer..."
value={answers[i] || ''}
onChange={(e) => setTextAnswer(i, e.target.value)}
fullWidth size="small" multiline maxRows={4}
sx={{
'& .MuiOutlinedInput-root': {
color: c.text.primary, fontSize: '0.82rem',
'& fieldset': { borderColor: c.border.medium },
'&:hover fieldset': { borderColor: c.border.strong },
'&.Mui-focused fieldset': { borderColor: c.accent.primary },
},
}}
/>
)}
</Box>
);
})}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="contained" startIcon={<SendIcon />} onClick={handleSubmit}
sx={{ bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.hover || c.accent.primary, filter: 'brightness(0.9)' }, fontWeight: 600, fontSize: '0.8rem' }}>
Submit
</Button>
<Button variant="outlined" onClick={() => onDeny(request.id)}
sx={{ borderColor: c.border.strong, color: c.text.secondary, '&:hover': { borderColor: c.text.secondary, bgcolor: `${c.text.secondary}08` }, fontWeight: 600, fontSize: '0.8rem' }}>
Dismiss
</Button>
</Box>
</Box>
);
};
@@ -1,140 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import TerminalIcon from '@mui/icons-material/Terminal';
import DescriptionIcon from '@mui/icons-material/Description';
import EditIcon from '@mui/icons-material/Edit';
import SearchIcon from '@mui/icons-material/Search';
import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer';
import BuildIcon from '@mui/icons-material/Build';
import { ApprovalRequest } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export function getToolIcon(toolName: string) {
switch (toolName) {
case 'Bash': return <TerminalIcon sx={{ fontSize: '1rem' }} />;
case 'Read': return <DescriptionIcon sx={{ fontSize: '1rem' }} />;
case 'Write': case 'Edit': return <EditIcon sx={{ fontSize: '1rem' }} />;
case 'Grep': case 'Glob': return <SearchIcon sx={{ fontSize: '1rem' }} />;
case 'AskUserQuestion': return <QuestionAnswerIcon sx={{ fontSize: '1rem' }} />;
default: return <BuildIcon sx={{ fontSize: '1rem' }} />;
}
}
interface ToolPreviewProps {
request: ApprovalRequest;
tokens: ReturnType<typeof useClaudeTokens>;
}
export const CodeBlock: React.FC<{ tokens: ReturnType<typeof useClaudeTokens>; children: React.ReactNode }> = ({ tokens: c, children }) => (
<Box
component="pre"
sx={{
bgcolor: c.bg.secondary,
borderRadius: 1.5,
p: 1.5,
m: 0,
maxHeight: 150,
overflow: 'auto',
border: `1px solid ${c.border.subtle}`,
color: c.text.secondary,
fontSize: '0.75rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: c.font.mono,
'&::-webkit-scrollbar': { width: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium,
borderRadius: 3,
'&:hover': { background: c.border.strong },
},
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}
>
{children}
</Box>
);
const ToolPreview: React.FC<ToolPreviewProps> = ({ request, tokens: c }) => {
const { tool_name, tool_input } = request;
switch (tool_name) {
case 'Bash': {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{tool_input.description && (
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem' }}>
{tool_input.description}
</Typography>
)}
<CodeBlock tokens={c}>{tool_input.command || '(empty command)'}</CodeBlock>
</Box>
);
}
case 'Read':
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DescriptionIcon sx={{ fontSize: '0.9rem', color: c.text.muted }} />
<Typography sx={{ color: c.text.secondary, fontSize: '0.8rem', fontFamily: c.font.mono }}>
{tool_input.file_path || tool_input.path || JSON.stringify(tool_input)}
</Typography>
</Box>
);
case 'Write':
case 'Edit': {
const path = tool_input.file_path || tool_input.path || '';
const content = tool_input.content || tool_input.new_content || tool_input.old_string;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<EditIcon sx={{ fontSize: '0.9rem', color: c.text.muted }} />
<Typography sx={{ color: c.text.secondary, fontSize: '0.8rem', fontFamily: c.font.mono }}>
{path}
</Typography>
</Box>
{content && <CodeBlock tokens={c}>{typeof content === 'string' ? content : JSON.stringify(content, null, 2)}</CodeBlock>}
</Box>
);
}
case 'Grep':
case 'Glob': {
const pattern = tool_input.pattern || tool_input.glob_pattern || tool_input.query || '';
const path = tool_input.path || tool_input.directory || '';
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={pattern}
size="small"
sx={{ fontFamily: c.font.mono, fontSize: '0.75rem', bgcolor: c.bg.secondary, color: c.text.secondary, border: `1px solid ${c.border.subtle}` }}
/>
{path && (
<Typography sx={{ color: c.text.muted, fontSize: '0.75rem', fontFamily: c.font.mono }}>
in {path}
</Typography>
)}
</Box>
</Box>
);
}
case 'AskUserQuestion':
return null;
default: {
const preview = tool_input.command || tool_input.file_path || tool_input.path || tool_input.query || null;
if (preview) {
return <CodeBlock tokens={c}>{preview}</CodeBlock>;
}
return <CodeBlock tokens={c}>{JSON.stringify(tool_input, null, 2)}</CodeBlock>;
}
}
};
export default ToolPreview;
@@ -0,0 +1,211 @@
import React, { useMemo, useState, useCallback } from 'react';
import { OptionList } from '@/components/tool-ui/option-list';
import type { OptionListSelection } from '@/components/tool-ui/option-list';
import { QuestionFlow } from '@/components/tool-ui/question-flow';
import type { ApprovalRequest } from '@/shared/state/agentsSlice';
function optionKey(opt: any): string {
return opt.id || opt.value || opt.label || opt.text || String(opt);
}
function optionLabel(opt: any): string {
return opt.label || opt.value || opt.text || String(opt);
}
// ---------------------------------------------------------------------------
// FreeTextQuestion (fallback for questions without options)
// ---------------------------------------------------------------------------
const FreeTextQuestion: React.FC<{
id: string;
question: string;
header?: string;
onSubmit: (answer: string) => void;
onDismiss: () => void;
}> = ({ id, question, header, onSubmit, onDismiss }) => {
const [text, setText] = useState('');
return (
<div
className="flex w-full max-w-md flex-col gap-3 text-foreground"
data-slot="free-text-question"
data-tool-ui-id={id}
>
<div className="bg-card flex flex-col gap-3 rounded-2xl border p-5 shadow-xs">
{header && (
<span className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
{header}
</span>
)}
<h2 className="text-base font-semibold leading-tight">{question}</h2>
<textarea
className="border-input bg-background text-foreground placeholder:text-muted-foreground w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows={3}
placeholder="Type your answer..."
value={text}
onChange={(e) => setText(e.target.value)}
/>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent transition-colors"
onClick={onDismiss}
>
Dismiss
</button>
<button
type="button"
className="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
onClick={() => onSubmit(text)}
disabled={!text.trim()}
>
Submit
</button>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// ToolQuestion
// ---------------------------------------------------------------------------
export interface ToolQuestionProps {
request: ApprovalRequest;
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onDeny: (requestId: string, message?: string) => void;
compact?: boolean;
}
export const ToolQuestion: React.FC<ToolQuestionProps> = ({ request, onApprove, onDeny }) => {
const rawQuestions: any[] = request.tool_input.questions || [];
const questions = useMemo(() => {
if (rawQuestions.length > 0) return rawQuestions;
if (request.tool_input.question) {
return [{
question: request.tool_input.question,
options: request.tool_input.options,
multiSelect: request.tool_input.allow_multiple ?? request.tool_input.multiSelect,
header: request.tool_input.header,
}];
}
return [];
}, [rawQuestions, request.tool_input]);
const buildAnswersPayload = useCallback(
(answersDict: Record<string, string>) => {
return { ...request.tool_input, questions, answers: answersDict };
},
[request.tool_input, questions],
);
// Multi-step questions with options -> QuestionFlow upfront mode
const stepsWithOptions = useMemo(() => {
if (questions.length <= 1) return null;
const steps = questions
.map((q: any, i: number) => {
const opts = Array.isArray(q.options) ? q.options : [];
if (opts.length === 0) return null;
return {
id: `q-${i}`,
title: q.question || q.prompt || q.text || `Question ${i + 1}`,
description: q.header,
options: opts.map((opt: any) => ({
id: optionKey(opt),
label: optionLabel(opt),
description: opt.description,
})),
selectionMode: (q.multiSelect || q.allow_multiple ? 'multi' : 'single') as 'multi' | 'single',
};
})
.filter(Boolean) as Array<{
id: string;
title: string;
description?: string;
options: Array<{ id: string; label: string; description?: string }>;
selectionMode: 'multi' | 'single';
}>;
return steps.length > 1 ? steps : null;
}, [questions]);
const handleFlowComplete = useCallback(
(answers: Record<string, string[]>) => {
const answersDict: Record<string, string> = {};
if (stepsWithOptions) {
stepsWithOptions.forEach((step, i) => {
const q = questions[i];
const questionText = q?.question || q?.prompt || q?.text || '';
const selection = answers[step.id] || [];
answersDict[questionText] = selection.join(', ');
});
}
onApprove(request.id, buildAnswersPayload(answersDict));
},
[stepsWithOptions, questions, request.id, onApprove, buildAnswersPayload],
);
if (stepsWithOptions) {
return (
<QuestionFlow
id={request.id}
steps={stepsWithOptions}
onComplete={handleFlowComplete}
/>
);
}
// Single question
const q = questions[0] || request.tool_input;
const questionText: string = q.question || q.prompt || q.text || '(question)';
const options: any[] = Array.isArray(q.options) ? q.options : [];
const isMulti: boolean = !!(q.multiSelect || q.allow_multiple);
if (options.length > 0) {
const mappedOptions = options.map((opt: any) => ({
id: optionKey(opt),
label: optionLabel(opt),
description: opt.description,
}));
return (
<div className="flex flex-col gap-2">
<div className="text-foreground px-1">
<h2 className="text-base font-semibold leading-tight">{questionText}</h2>
</div>
<OptionList
id={request.id}
options={mappedOptions}
selectionMode={isMulti ? 'multi' : 'single'}
actions={[
{ id: 'cancel', label: 'Skip' },
{ id: 'confirm', label: 'Submit' },
]}
onAction={(actionId: string, selection: OptionListSelection) => {
if (actionId === 'confirm') {
const answer = Array.isArray(selection) ? selection : selection ? [selection] : [];
onApprove(request.id, { answer });
} else {
onDeny(request.id);
}
}}
/>
</div>
);
}
// Free-text fallback
return (
<FreeTextQuestion
id={request.id}
question={questionText}
header={q.header}
onSubmit={(answer) => {
const answersDict: Record<string, string> = { [questionText]: answer };
onApprove(request.id, buildAnswersPayload(answersDict));
}}
onDismiss={() => onDeny(request.id)}
/>
);
};
@@ -1,3 +1,182 @@
import React, { useMemo, useCallback } from 'react';
import type { Toolkit } from '@assistant-ui/react';
import { ApprovalCard } from '@/components/tool-ui/approval-card';
import type { ApprovalRequest } from '@/shared/state/agentsSlice';
import { useAppSelector } from '@/shared/hooks';
import type { ToolDefinition } from '@/shared/state/toolsSlice';
import {
parseMcpToolName, sanitizeServerName, getMcpInputSummary,
getToolIconName, buildMetadata, isDangerous,
INTEGRATION_META,
type ParsedTool, type McpToolMeta,
} from './approval-utils';
import { ToolQuestion } from './approval-question';
// Re-exports so external consumers can import everything from this file
export {
parseMcpToolName, sanitizeServerName, getMcpInputSummary,
getToolIcon, getToolIconName, buildMetadata, isDangerous,
INTEGRATION_META,
} from './approval-utils';
export type {
IntegrationMeta, ParsedTool, McpToolMeta,
} from './approval-utils';
export { ToolQuestion } from './approval-question';
export type { ToolQuestionProps } from './approval-question';
// ---------------------------------------------------------------------------
// useMcpToolMeta (React hook — lives here alongside other component code)
// ---------------------------------------------------------------------------
export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta {
const toolItems = useAppSelector((s) => s.tools.items);
return useMemo(() => {
if (!parsed.isMcp) {
return { integration: null, description: '', serverLabel: '' };
}
const toolDef: ToolDefinition | undefined = Object.values(toolItems).find(
(t) => t.mcp_config && Object.keys(t.mcp_config).length > 0
&& sanitizeServerName(t.name) === parsed.serverSlug,
);
if (!toolDef) {
return { integration: null, description: '', serverLabel: parsed.serverSlug };
}
const description = toolDef.tool_permissions?._tool_descriptions?.[parsed.actionName] || '';
const integration = INTEGRATION_META[toolDef.name] || null;
const serverLabel = toolDef.name;
return { integration, description, serverLabel };
}, [parsed, toolItems]);
}
// ---------------------------------------------------------------------------
// ToolApproval
// ---------------------------------------------------------------------------
export interface ToolApprovalProps {
request: ApprovalRequest;
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onDeny: (requestId: string, message?: string) => void;
}
export const ToolApproval: React.FC<ToolApprovalProps> = ({ request, onApprove, onDeny }) => {
const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]);
const meta = useMcpToolMeta(parsed);
const summary = parsed.isMcp
? getMcpInputSummary(parsed.actionName, request.tool_input)
: '';
return (
<ApprovalCard
id={request.id}
title={parsed.isMcp ? parsed.displayName : `Run ${request.tool_name}`}
description={meta.description || summary || undefined}
icon={parsed.isMcp ? 'puzzle' : getToolIconName(request.tool_name)}
metadata={buildMetadata(request.tool_input)}
variant={isDangerous(request.tool_name, request.tool_input) ? 'destructive' : 'default'}
confirmLabel="Approve"
cancelLabel="Deny"
onConfirm={() => onApprove(request.id)}
onCancel={() => onDeny(request.id)}
/>
);
};
// ---------------------------------------------------------------------------
// BatchApprovalWrapper
// ---------------------------------------------------------------------------
export interface BatchApprovalWrapperProps {
requests: ApprovalRequest[];
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onDeny: (requestId: string, message?: string) => void;
}
export const BatchApprovalWrapper: React.FC<BatchApprovalWrapperProps> = ({
requests, onApprove, onDeny,
}) => {
const questionReqs = useMemo(
() => requests.filter((r) => r.tool_name === 'AskUserQuestion'),
[requests],
);
const approvalReqs = useMemo(
() => requests.filter((r) => r.tool_name !== 'AskUserQuestion'),
[requests],
);
const handleApproveAll = useCallback(() => {
for (const req of approvalReqs) onApprove(req.id);
}, [approvalReqs, onApprove]);
const handleDenyAll = useCallback(() => {
for (const req of approvalReqs) onDeny(req.id);
}, [approvalReqs, onDeny]);
return (
<div className="flex flex-col gap-2 px-2 pb-1">
{questionReqs.map((req) => (
<ToolQuestion key={req.id} request={req} onApprove={onApprove} onDeny={onDeny} />
))}
{approvalReqs.length > 1 && (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between rounded-xl bg-muted/50 px-4 py-2">
<span className="text-sm font-semibold text-muted-foreground">
{approvalReqs.length} pending approvals
</span>
<div className="flex gap-2">
<button
type="button"
className="inline-flex items-center rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground hover:bg-primary/90 transition-colors"
onClick={handleApproveAll}
>
Approve All
</button>
<button
type="button"
className="inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-semibold text-destructive hover:bg-destructive/10 transition-colors"
onClick={handleDenyAll}
>
Deny All
</button>
</div>
</div>
{approvalReqs.map((req) => (
<ToolApproval key={req.id} request={req} onApprove={onApprove} onDeny={onDeny} />
))}
</div>
)}
{approvalReqs.length === 1 && (
<ApprovalRouter request={approvalReqs[0]} onApprove={onApprove} onDeny={onDeny} />
)}
</div>
);
};
// ---------------------------------------------------------------------------
// ApprovalRouter (replaces old ApprovalBar default export)
// ---------------------------------------------------------------------------
interface ApprovalRouterProps {
request: ApprovalRequest;
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onDeny: (requestId: string, message?: string) => void;
}
export const ApprovalRouter: React.FC<ApprovalRouterProps> = (props) => {
if (props.request.tool_name === 'AskUserQuestion') {
return <ToolQuestion {...props} />;
}
return <ToolApproval {...props} />;
};
// ---------------------------------------------------------------------------
// Toolkit export (empty — approvals are standalone, not thread tool renderers)
// ---------------------------------------------------------------------------
export const approvalToolkit: Partial<Toolkit> = {};
@@ -1,13 +1,38 @@
import React, { useMemo } from 'react';
import { useAppSelector } from '@/shared/hooks';
import { ToolDefinition } from '@/shared/state/toolsSlice';
import React from 'react';
import type { ReactNode } from 'react';
import type { MetadataItem } from '@/components/tool-ui/approval-card';
import {
Terminal, FileText, FilePen, Search,
MessageCircleQuestion, Wrench,
} from 'lucide-react';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface IntegrationMeta {
label: string;
color: string;
icon: React.ReactNode;
icon: ReactNode;
}
export interface ParsedTool {
isMcp: boolean;
serverSlug: string;
actionName: string;
displayName: string;
}
export interface McpToolMeta {
integration: IntegrationMeta | null;
description: string;
serverLabel: string;
}
// ---------------------------------------------------------------------------
// Integration metadata (ported from approvalUtils.tsx)
// ---------------------------------------------------------------------------
const GoogleIcon = (
<svg viewBox="0 0 24 24" width="16" height="16">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
@@ -30,12 +55,9 @@ export const INTEGRATION_META: Record<string, IntegrationMeta> = {
'Reddit': { label: 'Reddit', color: '#FF4500', icon: RedditIcon },
};
export interface ParsedTool {
isMcp: boolean;
serverSlug: string;
actionName: string;
displayName: string;
}
// ---------------------------------------------------------------------------
// Parse / sanitize
// ---------------------------------------------------------------------------
export function parseMcpToolName(rawName: string): ParsedTool {
const m = rawName.match(/^mcp__([^_]+(?:-[^_]+)*)__(.+)$/);
@@ -54,35 +76,9 @@ export function sanitizeServerName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
export interface McpToolMeta {
integration: IntegrationMeta | null;
description: string;
serverLabel: string;
}
export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta {
const toolItems = useAppSelector((s) => s.tools.items);
return useMemo(() => {
if (!parsed.isMcp) {
return { integration: null, description: '', serverLabel: '' };
}
const toolDef: ToolDefinition | undefined = Object.values(toolItems).find(
(t) => t.mcp_config && Object.keys(t.mcp_config).length > 0 && sanitizeServerName(t.name) === parsed.serverSlug
);
if (!toolDef) {
return { integration: null, description: '', serverLabel: parsed.serverSlug };
}
const description = toolDef.tool_permissions?._tool_descriptions?.[parsed.actionName] || '';
const integration = INTEGRATION_META[toolDef.name] || null;
const serverLabel = toolDef.name;
return { integration, description, serverLabel };
}, [parsed, toolItems]);
}
// ---------------------------------------------------------------------------
// MCP input summary
// ---------------------------------------------------------------------------
export function getMcpInputSummary(actionName: string, toolInput: Record<string, any>): string {
const lower = actionName.toLowerCase();
@@ -131,9 +127,66 @@ export function getMcpInputSummary(actionName: string, toolInput: Record<string,
if (stringVals.length >= 2) break;
}
if (stringVals.length > 0) {
const joined = stringVals.join(' -- ');
const joined = stringVals.join(' ');
return joined.length > 100 ? joined.slice(0, 97) + '...' : joined;
}
return '';
}
// ---------------------------------------------------------------------------
// Icon helpers
// ---------------------------------------------------------------------------
const TOOL_ICON_MAP: Record<string, string> = {
Bash: 'terminal',
Read: 'file-text',
Write: 'file-pen',
Edit: 'file-pen',
Grep: 'search',
Glob: 'search',
AskUserQuestion: 'message-circle-question',
};
export function getToolIconName(toolName: string): string {
return TOOL_ICON_MAP[toolName] ?? 'wrench';
}
/** Backward-compatible JSX icon for external consumers (DynamicIsland, etc.) */
export function getToolIcon(toolName: string): ReactNode {
const size = 16;
switch (toolName) {
case 'Bash': return <Terminal size={size} />;
case 'Read': return <FileText size={size} />;
case 'Write': case 'Edit': return <FilePen size={size} />;
case 'Grep': case 'Glob': return <Search size={size} />;
case 'AskUserQuestion': return <MessageCircleQuestion size={size} />;
default: return <Wrench size={size} />;
}
}
// ---------------------------------------------------------------------------
// Metadata / danger helpers
// ---------------------------------------------------------------------------
export function buildMetadata(toolInput: Record<string, any>): MetadataItem[] {
return Object.entries(toolInput)
.filter(([, v]) => v != null)
.slice(0, 5)
.map(([key, value]) => ({
key,
value: typeof value === 'string'
? value.slice(0, 200)
: JSON.stringify(value).slice(0, 200),
}));
}
const DANGEROUS_PATTERNS = /\b(rm\s|rmdir|del\s|delete|drop\s|truncate|format)\b/i;
export function isDangerous(toolName: string, toolInput: Record<string, any>): boolean {
if (toolName === 'Bash') {
const cmd = toolInput.command || '';
return DANGEROUS_PATTERNS.test(cmd);
}
return false;
}
File diff suppressed because one or more lines are too long