- 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>
937 lines
39 KiB
TypeScript
937 lines
39 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
}
|