feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
936
dexto/packages/webui/components/ToolCallTimeline.tsx
Normal file
936
dexto/packages/webui/components/ToolCallTimeline.tsx
Normal file
@@ -0,0 +1,936 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Shield,
|
||||
FileText,
|
||||
FileEdit,
|
||||
FilePlus,
|
||||
Trash2,
|
||||
Terminal,
|
||||
Search,
|
||||
Copy,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { CodePreview } from './CodePreview';
|
||||
import type { ToolDisplayData } from '@dexto/core';
|
||||
|
||||
/**
|
||||
* Sub-agent progress data for spawn_agent tool calls
|
||||
*/
|
||||
export interface SubAgentProgress {
|
||||
task: string;
|
||||
agentId: string;
|
||||
toolsCalled: number;
|
||||
currentTool: string;
|
||||
currentArgs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolCallTimelineProps {
|
||||
toolName: string;
|
||||
toolArgs?: Record<string, unknown>;
|
||||
toolResult?: unknown;
|
||||
success?: boolean;
|
||||
requireApproval?: boolean;
|
||||
approvalStatus?: 'pending' | 'approved' | 'rejected';
|
||||
displayData?: ToolDisplayData;
|
||||
subAgentProgress?: SubAgentProgress;
|
||||
onApprove?: (formData?: Record<string, unknown>, rememberChoice?: boolean) => void;
|
||||
onReject?: () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function stripToolPrefix(toolName: string): { displayName: string; source: string } {
|
||||
if (toolName.startsWith('internal--')) {
|
||||
return { displayName: toolName.replace('internal--', ''), source: '' };
|
||||
}
|
||||
if (toolName.startsWith('custom--')) {
|
||||
return { displayName: toolName.replace('custom--', ''), source: '' };
|
||||
}
|
||||
if (toolName.startsWith('mcp--')) {
|
||||
const parts = toolName.split('--');
|
||||
if (parts.length >= 3) {
|
||||
return { displayName: parts.slice(2).join('--'), source: parts[1] ?? '' };
|
||||
}
|
||||
return { displayName: toolName.replace('mcp--', ''), source: 'mcp' };
|
||||
}
|
||||
if (toolName.startsWith('mcp__')) {
|
||||
const parts = toolName.substring(5).split('__');
|
||||
if (parts.length >= 2) {
|
||||
return { displayName: parts.slice(1).join('__'), source: parts[0] ?? '' };
|
||||
}
|
||||
return { displayName: toolName.substring(5), source: 'mcp' };
|
||||
}
|
||||
if (toolName.startsWith('internal__')) {
|
||||
return { displayName: toolName.substring(10), source: '' };
|
||||
}
|
||||
return { displayName: toolName, source: '' };
|
||||
}
|
||||
|
||||
function getShortPath(path: string): string {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (parts.length <= 2) return path;
|
||||
return `.../${parts.slice(-2).join('/')}`;
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
function getSummary(
|
||||
displayName: string,
|
||||
toolArgs?: Record<string, unknown>
|
||||
): { name: string; detail?: string } {
|
||||
const args = toolArgs || {};
|
||||
const filePath = (args.file_path || args.path || args.file) as string | undefined;
|
||||
const command = args.command as string | undefined;
|
||||
const pattern = (args.pattern || args.query) as string | undefined;
|
||||
|
||||
if (command) {
|
||||
return { name: displayName, detail: truncate(command, 40) };
|
||||
}
|
||||
if (filePath) {
|
||||
return { name: displayName, detail: getShortPath(filePath) };
|
||||
}
|
||||
if (pattern) {
|
||||
return { name: displayName, detail: `"${truncate(pattern, 25)}"` };
|
||||
}
|
||||
return { name: displayName };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export function ToolCallTimeline({
|
||||
toolName,
|
||||
toolArgs,
|
||||
toolResult,
|
||||
success,
|
||||
requireApproval = false,
|
||||
approvalStatus,
|
||||
displayData,
|
||||
subAgentProgress,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: ToolCallTimelineProps) {
|
||||
const hasResult = toolResult !== undefined;
|
||||
const isPendingApproval = requireApproval && approvalStatus === 'pending';
|
||||
const isFailed = success === false;
|
||||
const isRejected = approvalStatus === 'rejected';
|
||||
// Tool is processing only if: no result yet, not pending approval, and not marked as failed
|
||||
// The `success === false` check handles incomplete tool calls from history (never got a result)
|
||||
const isProcessing = !hasResult && !isPendingApproval && !isFailed;
|
||||
const hasSubAgentProgress = !!subAgentProgress;
|
||||
|
||||
// Determine if there's meaningful content to show
|
||||
const hasExpandableContent = Boolean(
|
||||
displayData ||
|
||||
toolArgs?.content ||
|
||||
(toolArgs?.old_string && toolArgs?.new_string) ||
|
||||
(toolArgs?.command && hasResult)
|
||||
);
|
||||
|
||||
// Determine if this tool has rich UI that should be shown by default
|
||||
// Rich UI includes: displayData, file content previews, and diff views
|
||||
// Exclude bash commands as they're more variable in visual value
|
||||
const hasRichUI = Boolean(
|
||||
displayData || toolArgs?.content || (toolArgs?.old_string && toolArgs?.new_string)
|
||||
);
|
||||
|
||||
// Smart default: expand for pending approvals and successful tools with rich UI
|
||||
// Failed, rejected, and no-output should always be collapsed
|
||||
const [expanded, setExpanded] = useState(
|
||||
isPendingApproval || (hasRichUI && !isFailed && !isRejected)
|
||||
);
|
||||
const [detailsExpanded, setDetailsExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Auto-collapse after approval is resolved, but keep open if tool has rich UI and succeeded
|
||||
useEffect(() => {
|
||||
if (requireApproval && approvalStatus && approvalStatus !== 'pending') {
|
||||
// Collapse if rejected or if no rich UI to show
|
||||
if (isRejected || !hasRichUI) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}
|
||||
}, [requireApproval, approvalStatus, hasRichUI, isRejected]);
|
||||
|
||||
const { displayName, source } = stripToolPrefix(toolName);
|
||||
const summary = getSummary(displayName, toolArgs);
|
||||
|
||||
// For sub-agent progress, format the agent name nicely
|
||||
const subAgentLabel = hasSubAgentProgress
|
||||
? subAgentProgress.agentId
|
||||
.replace(/-agent$/, '')
|
||||
.charAt(0)
|
||||
.toUpperCase() + subAgentProgress.agentId.replace(/-agent$/, '').slice(1)
|
||||
: null;
|
||||
|
||||
// Status icon
|
||||
const StatusIcon = isPendingApproval ? (
|
||||
<div className="relative">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-amber-500" />
|
||||
<span className="absolute inset-0 rounded-full bg-amber-500/30 animate-ping" />
|
||||
</div>
|
||||
) : isProcessing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 text-blue-500 animate-spin" />
|
||||
) : isFailed || isRejected ? (
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||
);
|
||||
|
||||
// Header click handler
|
||||
const toggleExpanded = () => {
|
||||
if (hasResult || isPendingApproval || hasExpandableContent) {
|
||||
setExpanded(!expanded);
|
||||
}
|
||||
};
|
||||
|
||||
const canExpand = hasResult || isPendingApproval || hasExpandableContent;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'my-0.5 rounded-md transition-colors inline-block max-w-full',
|
||||
isPendingApproval &&
|
||||
'bg-amber-50/50 dark:bg-amber-950/20 border border-amber-200/50 dark:border-amber-800/30'
|
||||
)}
|
||||
>
|
||||
{/* Collapsed Header - Always Visible */}
|
||||
<button
|
||||
onClick={toggleExpanded}
|
||||
disabled={!canExpand}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 p-1.5 text-left rounded-md',
|
||||
canExpand && 'hover:bg-muted/40 cursor-pointer',
|
||||
!canExpand && 'cursor-default'
|
||||
)}
|
||||
>
|
||||
{/* Status icon */}
|
||||
<div className="flex-shrink-0">{StatusIcon}</div>
|
||||
|
||||
{/* Summary text */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs flex-1 truncate',
|
||||
isPendingApproval && 'text-amber-700 dark:text-amber-300 font-medium',
|
||||
isProcessing && 'text-blue-600 dark:text-blue-400',
|
||||
isFailed && 'text-red-600 dark:text-red-400',
|
||||
isRejected && 'text-red-600 dark:text-red-400',
|
||||
!isPendingApproval &&
|
||||
!isProcessing &&
|
||||
!isFailed &&
|
||||
!isRejected &&
|
||||
'text-foreground/70'
|
||||
)}
|
||||
>
|
||||
{isPendingApproval ? 'Approval required: ' : ''}
|
||||
{isFailed ? 'Failed: ' : ''}
|
||||
{isRejected ? 'Rejected: ' : ''}
|
||||
{hasSubAgentProgress ? (
|
||||
<span className="font-mono">
|
||||
<span className="text-purple-600 dark:text-purple-400 font-medium">
|
||||
{subAgentLabel}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50">(</span>
|
||||
<span className="text-foreground/80">{subAgentProgress.task}</span>
|
||||
<span className="text-muted-foreground/50">)</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono">
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
{summary.name.toLowerCase()}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50">(</span>
|
||||
{summary.detail && (
|
||||
<span className="text-foreground/80">{summary.detail}</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/50">)</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Badges */}
|
||||
{source && (
|
||||
<span className="text-[10px] text-muted-foreground/60 flex-shrink-0">
|
||||
[{source}]
|
||||
</span>
|
||||
)}
|
||||
{requireApproval && approvalStatus === 'approved' && (
|
||||
<span className="text-[10px] text-green-600 dark:text-green-500 flex-shrink-0">
|
||||
approved
|
||||
</span>
|
||||
)}
|
||||
{isProcessing && !hasSubAgentProgress && (
|
||||
<span className="text-[10px] text-muted-foreground/50 flex-shrink-0">
|
||||
running...
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Sub-agent progress indicator */}
|
||||
{hasSubAgentProgress && isProcessing && (
|
||||
<span className="text-[10px] text-muted-foreground flex-shrink-0">
|
||||
{subAgentProgress.toolsCalled} tool
|
||||
{subAgentProgress.toolsCalled !== 1 ? 's' : ''} |{' '}
|
||||
{subAgentProgress.currentTool}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Expand chevron */}
|
||||
{canExpand && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3 w-3 text-muted-foreground/40 flex-shrink-0 transition-transform',
|
||||
expanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{expanded && (
|
||||
<div className="px-1.5 pb-2 pt-1 space-y-2 animate-fade-in">
|
||||
{/* Pending Approval Content */}
|
||||
{isPendingApproval && (
|
||||
<>
|
||||
{renderApprovalPreview()}
|
||||
<div className="flex gap-1.5 flex-wrap pt-1">
|
||||
<Button
|
||||
onClick={() => onApprove?.(undefined, false)}
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700 text-white h-6 text-[11px] px-2.5"
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onApprove?.(undefined, true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-[11px] px-2 text-green-600 border-green-300 hover:bg-green-50 dark:border-green-700 dark:hover:bg-green-950/20"
|
||||
>
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Always
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onReject?.()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[11px] px-2.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error Content */}
|
||||
{isFailed && hasResult && renderErrorContent()}
|
||||
|
||||
{/* Result Content */}
|
||||
{hasResult && !isFailed && !isPendingApproval && renderResultContent()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// Render Functions
|
||||
// =========================================================================
|
||||
|
||||
function renderApprovalPreview() {
|
||||
const command = toolArgs?.command as string | undefined;
|
||||
const filePath = (toolArgs?.file_path || toolArgs?.path) as string | undefined;
|
||||
const content = toolArgs?.content as string | undefined;
|
||||
const oldString = toolArgs?.old_string as string | undefined;
|
||||
const newString = toolArgs?.new_string as string | undefined;
|
||||
|
||||
// Bash command
|
||||
if (command) {
|
||||
return (
|
||||
<div className="ml-5 bg-zinc-900 rounded overflow-hidden">
|
||||
<pre className="px-2 py-1.5 text-[11px] text-zinc-300 font-mono whitespace-pre-wrap">
|
||||
<span className="text-zinc-500">$ </span>
|
||||
{command}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Edit operation - diff view without header (file path is in summary)
|
||||
if (oldString !== undefined && newString !== undefined) {
|
||||
return (
|
||||
<div className="ml-5 bg-muted/30 rounded overflow-hidden border border-border/50 text-[11px] font-mono">
|
||||
{oldString
|
||||
.split('\n')
|
||||
.slice(0, detailsExpanded ? 15 : 3)
|
||||
.map((line, i) => (
|
||||
<div
|
||||
key={`o${i}`}
|
||||
className="px-2 py-0.5 bg-red-100/50 dark:bg-red-900/20 text-red-800 dark:text-red-300"
|
||||
>
|
||||
<span className="text-red-500/50 mr-1">-</span>
|
||||
{line || ' '}
|
||||
</div>
|
||||
))}
|
||||
{newString
|
||||
.split('\n')
|
||||
.slice(0, detailsExpanded ? 15 : 3)
|
||||
.map((line, i) => (
|
||||
<div
|
||||
key={`n${i}`}
|
||||
className="px-2 py-0.5 bg-green-100/50 dark:bg-green-900/20 text-green-800 dark:text-green-300"
|
||||
>
|
||||
<span className="text-green-500/50 mr-1">+</span>
|
||||
{line || ' '}
|
||||
</div>
|
||||
))}
|
||||
{(oldString.split('\n').length > 3 || newString.split('\n').length > 3) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailsExpanded(!detailsExpanded);
|
||||
}}
|
||||
className="w-full py-0.5 text-[10px] text-blue-500 bg-muted/50 border-t border-border/30"
|
||||
>
|
||||
{detailsExpanded ? 'less' : 'more...'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Write/Create file
|
||||
if (content && filePath) {
|
||||
return (
|
||||
<div className="ml-5">
|
||||
<CodePreview
|
||||
content={content}
|
||||
filePath={filePath}
|
||||
maxLines={8}
|
||||
maxHeight={180}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderErrorContent() {
|
||||
let errorMessage = 'Unknown error';
|
||||
if (toolResult && typeof toolResult === 'object') {
|
||||
const result = toolResult as Record<string, unknown>;
|
||||
if (result.content && Array.isArray(result.content)) {
|
||||
const textPart = result.content.find(
|
||||
(p: unknown) =>
|
||||
typeof p === 'object' &&
|
||||
p !== null &&
|
||||
(p as Record<string, unknown>).type === 'text'
|
||||
) as { text?: string } | undefined;
|
||||
if (textPart?.text) errorMessage = textPart.text;
|
||||
} else if (result.error) {
|
||||
errorMessage =
|
||||
typeof result.error === 'string' ? result.error : JSON.stringify(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800/50 rounded px-2 py-1.5">
|
||||
<pre className="text-[11px] text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-all">
|
||||
{truncate(errorMessage, 500)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderResultContent() {
|
||||
// Extract toolArgs for checking rich content availability
|
||||
const command = toolArgs?.command as string | undefined;
|
||||
const filePath = (toolArgs?.file_path || toolArgs?.path) as string | undefined;
|
||||
const content = toolArgs?.content as string | undefined;
|
||||
const oldString = toolArgs?.old_string as string | undefined;
|
||||
const newString = toolArgs?.new_string as string | undefined;
|
||||
|
||||
// Check if we have rich content that should override simple displayData
|
||||
const hasRichContent = !!(
|
||||
content ||
|
||||
(oldString !== undefined && newString !== undefined) ||
|
||||
(command && hasResult)
|
||||
);
|
||||
|
||||
// If we have display metadata from tool, use it (unless we have richer content)
|
||||
if (displayData) {
|
||||
// Skip simple file metadata display if we have rich content to show
|
||||
if (displayData.type === 'file' && hasRichContent) {
|
||||
// Fall through to render rich content below
|
||||
} else {
|
||||
switch (displayData.type) {
|
||||
case 'diff':
|
||||
return renderDiff(displayData);
|
||||
case 'shell':
|
||||
return renderShell(displayData);
|
||||
case 'search':
|
||||
return renderSearch(displayData);
|
||||
case 'file':
|
||||
return renderFile(displayData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render rich content from toolArgs
|
||||
// Bash command with result
|
||||
if (command && hasResult) {
|
||||
return renderBashResult(command);
|
||||
}
|
||||
|
||||
// Edit operation (old_string -> new_string)
|
||||
if (oldString !== undefined && newString !== undefined && filePath) {
|
||||
return renderEditResult(filePath, oldString, newString);
|
||||
}
|
||||
|
||||
// Write/create file
|
||||
if (content && filePath) {
|
||||
return renderWriteResult(filePath, content);
|
||||
}
|
||||
|
||||
// Read file - show content from result
|
||||
if (displayName.toLowerCase().includes('read') && filePath) {
|
||||
return renderReadResult(filePath);
|
||||
}
|
||||
|
||||
// Fallback to generic
|
||||
return renderGenericResult();
|
||||
}
|
||||
|
||||
function renderDiff(data: Extract<ToolDisplayData, { type: 'diff' }>) {
|
||||
const lines = data.unified
|
||||
.split('\n')
|
||||
.filter((l) => !l.startsWith('@@') && !l.startsWith('---') && !l.startsWith('+++'));
|
||||
const displayLines = lines.slice(0, detailsExpanded ? 40 : 8);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<FileEdit className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mono text-foreground/70">
|
||||
{getShortPath(data.filename)}
|
||||
</span>
|
||||
<span className="text-green-600">+{data.additions}</span>
|
||||
<span className="text-red-600">-{data.deletions}</span>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded overflow-hidden border border-border/50">
|
||||
{displayLines.map((line, i) => {
|
||||
const isAdd = line.startsWith('+');
|
||||
const isDel = line.startsWith('-');
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'px-2 py-0.5 text-[11px] font-mono',
|
||||
isAdd &&
|
||||
'bg-green-100/50 dark:bg-green-900/20 text-green-800 dark:text-green-300',
|
||||
isDel &&
|
||||
'bg-red-100/50 dark:bg-red-900/20 text-red-800 dark:text-red-300',
|
||||
!isAdd && !isDel && 'text-foreground/50'
|
||||
)}
|
||||
>
|
||||
<span className="mr-1 opacity-50">
|
||||
{isAdd ? '+' : isDel ? '-' : ' '}
|
||||
</span>
|
||||
{line.slice(1) || ' '}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{lines.length > 8 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailsExpanded(!detailsExpanded);
|
||||
}}
|
||||
className="w-full py-0.5 text-[10px] text-blue-500 bg-muted/50 border-t border-border/30"
|
||||
>
|
||||
{detailsExpanded ? 'less' : `+${lines.length - 8} more...`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderShell(data: Extract<ToolDisplayData, { type: 'shell' }>) {
|
||||
const output = data.stdout || data.stderr || '';
|
||||
const lines = output.split('\n');
|
||||
const displayLines = lines.slice(0, detailsExpanded ? 25 : 5);
|
||||
const isError = data.exitCode !== 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<Terminal className="h-3 w-3 text-muted-foreground" />
|
||||
<code className="font-mono text-foreground/70 truncate flex-1">
|
||||
{truncate(data.command, 50)}
|
||||
</code>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] px-1 py-0.5 rounded',
|
||||
isError
|
||||
? 'bg-red-100 dark:bg-red-900/30 text-red-600'
|
||||
: 'bg-green-100 dark:bg-green-900/30 text-green-600'
|
||||
)}
|
||||
>
|
||||
{isError ? `exit ${data.exitCode}` : 'ok'}
|
||||
</span>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await navigator.clipboard.writeText(data.command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{output && (
|
||||
<div className="bg-zinc-100 dark:bg-zinc-900 rounded overflow-hidden border border-zinc-200 dark:border-zinc-800">
|
||||
<pre className="p-1.5 text-[11px] text-zinc-800 dark:text-zinc-300 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
{displayLines.join('\n')}
|
||||
</pre>
|
||||
{lines.length > 5 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailsExpanded(!detailsExpanded);
|
||||
}}
|
||||
className="w-full py-0.5 text-[10px] text-blue-600 dark:text-blue-400 bg-zinc-200 dark:bg-zinc-800 border-t border-zinc-300 dark:border-zinc-700"
|
||||
>
|
||||
{detailsExpanded ? 'less' : `+${lines.length - 5} more...`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSearch(data: Extract<ToolDisplayData, { type: 'search' }>) {
|
||||
const matches = data.matches.slice(0, detailsExpanded ? 15 : 5);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<Search className="h-3 w-3 text-muted-foreground" />
|
||||
<code className="font-mono text-foreground/70">{data.pattern}</code>
|
||||
<span className="text-muted-foreground">
|
||||
{data.totalMatches} matches{data.truncated && '+'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded overflow-hidden border border-border/50 divide-y divide-border/30">
|
||||
{matches.map((m, i) => (
|
||||
<div key={i} className="px-2 py-1 text-[11px]">
|
||||
<span className="text-blue-600 dark:text-blue-400 font-mono">
|
||||
{getShortPath(m.file)}
|
||||
</span>
|
||||
{m.line > 0 && <span className="text-muted-foreground">:{m.line}</span>}
|
||||
{m.content && (
|
||||
<div className="text-foreground/60 font-mono truncate">
|
||||
{m.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{data.matches.length > 5 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailsExpanded(!detailsExpanded);
|
||||
}}
|
||||
className="w-full py-0.5 text-[10px] text-blue-500 bg-muted/50"
|
||||
>
|
||||
{detailsExpanded ? 'less' : `+${data.matches.length - 5} more...`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFile(data: Extract<ToolDisplayData, { type: 'file' }>) {
|
||||
const OpIcon = { read: FileText, write: FileEdit, create: FilePlus, delete: Trash2 }[
|
||||
data.operation
|
||||
];
|
||||
const opColors = {
|
||||
read: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
|
||||
write: 'bg-amber-100 dark:bg-amber-900/30 text-amber-600',
|
||||
create: 'bg-green-100 dark:bg-green-900/30 text-green-600',
|
||||
delete: 'bg-red-100 dark:bg-red-900/30 text-red-600',
|
||||
}[data.operation];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<OpIcon className="h-3 w-3 text-muted-foreground" />
|
||||
<span className={cn('px-1 py-0.5 rounded text-[10px]', opColors)}>
|
||||
{data.operation}
|
||||
</span>
|
||||
<span className="font-mono text-foreground/70">{getShortPath(data.path)}</span>
|
||||
{data.lineCount !== undefined && (
|
||||
<span className="text-muted-foreground">{data.lineCount} lines</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Render functions for generating preview from toolArgs (no displayData)
|
||||
// =========================================================================
|
||||
|
||||
function renderBashResult(_command: string) {
|
||||
// Extract and parse bash result from tool result
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let exitCode: number | undefined;
|
||||
let duration: number | undefined;
|
||||
|
||||
if (toolResult && typeof toolResult === 'object') {
|
||||
const result = toolResult as Record<string, unknown>;
|
||||
if (result.content && Array.isArray(result.content)) {
|
||||
const textContent = result.content
|
||||
.filter(
|
||||
(p: unknown) =>
|
||||
typeof p === 'object' &&
|
||||
p !== null &&
|
||||
(p as Record<string, unknown>).type === 'text'
|
||||
)
|
||||
.map((p: unknown) => (p as { text?: string }).text || '')
|
||||
.join('\n');
|
||||
|
||||
// Try to parse as JSON bash result
|
||||
try {
|
||||
const parsed = JSON.parse(textContent);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
stdout = parsed.stdout || '';
|
||||
stderr = parsed.stderr || '';
|
||||
exitCode =
|
||||
typeof parsed.exit_code === 'number' ? parsed.exit_code : undefined;
|
||||
duration =
|
||||
typeof parsed.duration === 'number' ? parsed.duration : undefined;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, treat as plain output
|
||||
stdout = textContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const output = stdout || stderr;
|
||||
if (!output && exitCode === undefined) return null;
|
||||
|
||||
const lines = output.split('\n').filter((l) => l.trim());
|
||||
const displayLines = lines.slice(0, detailsExpanded ? 25 : 5);
|
||||
const isError = exitCode !== undefined && exitCode !== 0;
|
||||
|
||||
return (
|
||||
<div className="ml-5 space-y-1">
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
{exitCode !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded font-medium',
|
||||
isError
|
||||
? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
|
||||
: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'
|
||||
)}
|
||||
>
|
||||
{isError ? `exit ${exitCode}` : 'success'}
|
||||
</span>
|
||||
)}
|
||||
{duration !== undefined && (
|
||||
<span className="text-muted-foreground">{duration}ms</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
{output && (
|
||||
<div className="bg-zinc-100 dark:bg-zinc-900 rounded overflow-hidden border border-zinc-200 dark:border-zinc-800">
|
||||
<pre className="p-1.5 text-[11px] text-zinc-800 dark:text-zinc-300 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
{displayLines.join('\n')}
|
||||
</pre>
|
||||
{lines.length > 5 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailsExpanded(!detailsExpanded);
|
||||
}}
|
||||
className="w-full py-0.5 text-[10px] text-blue-600 dark:text-blue-400 bg-zinc-200 dark:bg-zinc-800 border-t border-zinc-300 dark:border-zinc-700"
|
||||
>
|
||||
{detailsExpanded ? 'less' : `+${lines.length - 5} more...`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stderr if present and different from stdout */}
|
||||
{stderr && stderr !== stdout && (
|
||||
<div className="bg-red-50 dark:bg-red-950/30 rounded overflow-hidden border border-red-200 dark:border-red-900/30">
|
||||
<div className="px-2 py-0.5 text-[10px] text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/20 border-b border-red-200 dark:border-red-900/30">
|
||||
stderr
|
||||
</div>
|
||||
<pre className="p-1.5 text-[11px] text-red-800 dark:text-red-300 font-mono whitespace-pre-wrap max-h-24 overflow-y-auto">
|
||||
{stderr}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderEditResult(_filePath: string, oldString: string, newString: string) {
|
||||
return (
|
||||
<div className="ml-5 bg-muted/30 rounded overflow-hidden border border-border/50 text-[11px] font-mono">
|
||||
{oldString
|
||||
.split('\n')
|
||||
.slice(0, detailsExpanded ? 15 : 3)
|
||||
.map((line, i) => (
|
||||
<div
|
||||
key={`o${i}`}
|
||||
className="px-2 py-0.5 bg-red-100/50 dark:bg-red-900/20 text-red-800 dark:text-red-300"
|
||||
>
|
||||
<span className="text-red-500/50 mr-1">-</span>
|
||||
{line || ' '}
|
||||
</div>
|
||||
))}
|
||||
{newString
|
||||
.split('\n')
|
||||
.slice(0, detailsExpanded ? 15 : 3)
|
||||
.map((line, i) => (
|
||||
<div
|
||||
key={`n${i}`}
|
||||
className="px-2 py-0.5 bg-green-100/50 dark:bg-green-900/20 text-green-800 dark:text-green-300"
|
||||
>
|
||||
<span className="text-green-500/50 mr-1">+</span>
|
||||
{line || ' '}
|
||||
</div>
|
||||
))}
|
||||
{(oldString.split('\n').length > 3 || newString.split('\n').length > 3) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailsExpanded(!detailsExpanded);
|
||||
}}
|
||||
className="w-full py-0.5 text-[10px] text-blue-500 bg-muted/50 border-t border-border/30"
|
||||
>
|
||||
{detailsExpanded ? 'less' : 'more...'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWriteResult(filePath: string, content: string) {
|
||||
return (
|
||||
<div className="ml-5">
|
||||
<CodePreview
|
||||
content={content}
|
||||
filePath={filePath}
|
||||
maxLines={8}
|
||||
maxHeight={180}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderReadResult(filePath: string) {
|
||||
// Extract content from tool result
|
||||
let content = '';
|
||||
if (toolResult && typeof toolResult === 'object') {
|
||||
const result = toolResult as Record<string, unknown>;
|
||||
if (result.content && Array.isArray(result.content)) {
|
||||
content = result.content
|
||||
.filter(
|
||||
(p: unknown) =>
|
||||
typeof p === 'object' &&
|
||||
p !== null &&
|
||||
(p as Record<string, unknown>).type === 'text'
|
||||
)
|
||||
.map((p: unknown) => (p as { text?: string }).text || '')
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<div className="ml-5">
|
||||
<CodePreview
|
||||
content={content}
|
||||
filePath={filePath}
|
||||
maxLines={8}
|
||||
maxHeight={180}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderGenericResult() {
|
||||
// Extract text from result
|
||||
let resultText = '';
|
||||
if (toolResult && typeof toolResult === 'object') {
|
||||
const result = toolResult as Record<string, unknown>;
|
||||
if (result.content && Array.isArray(result.content)) {
|
||||
resultText = result.content
|
||||
.filter(
|
||||
(p: unknown) =>
|
||||
typeof p === 'object' &&
|
||||
p !== null &&
|
||||
(p as Record<string, unknown>).type === 'text'
|
||||
)
|
||||
.map((p: unknown) => (p as { text?: string }).text || '')
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (!resultText) return null;
|
||||
|
||||
const lines = resultText.split('\n');
|
||||
const displayLines = lines.slice(0, detailsExpanded ? 20 : 5);
|
||||
|
||||
return (
|
||||
<div className="bg-muted/30 rounded overflow-hidden border border-border/50">
|
||||
<pre className="p-1.5 text-[11px] text-foreground/70 font-mono whitespace-pre-wrap max-h-40 overflow-y-auto">
|
||||
{displayLines.join('\n')}
|
||||
</pre>
|
||||
{lines.length > 5 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailsExpanded(!detailsExpanded);
|
||||
}}
|
||||
className="w-full py-0.5 text-[10px] text-blue-500 bg-muted/50 border-t border-border/30"
|
||||
>
|
||||
{detailsExpanded ? 'less' : `+${lines.length - 5} more...`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user