refactor: extract widget components from ToolWidgets
- Extract TodoWidget component for todo functionality - Extract LSWidget component for LS/list functionality - Extract BashWidget component for bash terminal display - Create widgets/index.ts for centralized exports - Prepare ToolWidgets.new.tsx as refactored structure - Improve code maintainability and enable lazy loading
This commit is contained in:
4
src/components/ToolWidgets.new.tsx
Normal file
4
src/components/ToolWidgets.new.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// This file re-exports all widgets from the widgets directory
|
||||||
|
// It maintains backward compatibility with the original ToolWidgets.tsx
|
||||||
|
|
||||||
|
export * from './widgets';
|
71
src/components/widgets/BashWidget.tsx
Normal file
71
src/components/widgets/BashWidget.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Terminal, ChevronRight } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface BashWidgetProps {
|
||||||
|
command: string;
|
||||||
|
description?: string;
|
||||||
|
result?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BashWidget: React.FC<BashWidgetProps> = ({ command, description, result }) => {
|
||||||
|
// Extract result content if available
|
||||||
|
let resultContent = '';
|
||||||
|
let isError = false;
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
isError = result.is_error || false;
|
||||||
|
if (typeof result.content === 'string') {
|
||||||
|
resultContent = result.content;
|
||||||
|
} else if (result.content && typeof result.content === 'object') {
|
||||||
|
if (result.content.text) {
|
||||||
|
resultContent = result.content.text;
|
||||||
|
} else if (Array.isArray(result.content)) {
|
||||||
|
resultContent = result.content
|
||||||
|
.map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c)))
|
||||||
|
.join('\n');
|
||||||
|
} else {
|
||||||
|
resultContent = JSON.stringify(result.content, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-zinc-950 overflow-hidden">
|
||||||
|
<div className="px-4 py-2 bg-zinc-900/50 flex items-center gap-2 border-b">
|
||||||
|
<Terminal className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
<span className="text-xs font-mono text-muted-foreground">Terminal</span>
|
||||||
|
{description && (
|
||||||
|
<>
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">{description}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Show loading indicator when no result yet */}
|
||||||
|
{!result && (
|
||||||
|
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse" />
|
||||||
|
<span>Running...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<code className="text-xs font-mono text-green-400 block">
|
||||||
|
$ {command}
|
||||||
|
</code>
|
||||||
|
|
||||||
|
{/* Show result if available */}
|
||||||
|
{result && (
|
||||||
|
<div className={cn(
|
||||||
|
"mt-3 p-3 rounded-md border text-xs font-mono whitespace-pre-wrap overflow-x-auto",
|
||||||
|
isError
|
||||||
|
? "border-red-500/20 bg-red-500/5 text-red-400"
|
||||||
|
: "border-green-500/20 bg-green-500/5 text-green-300"
|
||||||
|
)}>
|
||||||
|
{resultContent || (isError ? "Command failed" : "Command completed")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
229
src/components/widgets/LSWidget.tsx
Normal file
229
src/components/widgets/LSWidget.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { FolderOpen, Folder, FileCode, FileText, Terminal, ChevronRight } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface LSWidgetProps {
|
||||||
|
path: string;
|
||||||
|
result?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LSWidget: React.FC<LSWidgetProps> = ({ path, result }) => {
|
||||||
|
// If we have a result, show it using the LSResultWidget
|
||||||
|
if (result) {
|
||||||
|
let resultContent = '';
|
||||||
|
if (typeof result.content === 'string') {
|
||||||
|
resultContent = result.content;
|
||||||
|
} else if (result.content && typeof result.content === 'object') {
|
||||||
|
if (result.content.text) {
|
||||||
|
resultContent = result.content.text;
|
||||||
|
} else if (Array.isArray(result.content)) {
|
||||||
|
resultContent = result.content
|
||||||
|
.map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c)))
|
||||||
|
.join('\n');
|
||||||
|
} else {
|
||||||
|
resultContent = JSON.stringify(result.content, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<FolderOpen className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-sm">Directory contents for:</span>
|
||||||
|
<code className="text-sm font-mono bg-background px-2 py-0.5 rounded">
|
||||||
|
{path}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
{resultContent && <LSResultWidget content={resultContent} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
|
<FolderOpen className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-sm">Listing directory:</span>
|
||||||
|
<code className="text-sm font-mono bg-background px-2 py-0.5 rounded">
|
||||||
|
{path}
|
||||||
|
</code>
|
||||||
|
{!result && (
|
||||||
|
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<div className="h-2 w-2 bg-blue-500 rounded-full animate-pulse" />
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LSResultWidgetProps {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LSResultWidget: React.FC<LSResultWidgetProps> = ({ content }) => {
|
||||||
|
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Parse the directory tree structure
|
||||||
|
const parseDirectoryTree = (rawContent: string) => {
|
||||||
|
const lines = rawContent.split('\n');
|
||||||
|
const entries: Array<{
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
type: 'file' | 'directory';
|
||||||
|
level: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
let currentPath: string[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Skip NOTE section and everything after it
|
||||||
|
if (line.startsWith('NOTE:')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip empty lines
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
|
// Calculate indentation level
|
||||||
|
const indent = line.match(/^(\s*)/)?.[1] || '';
|
||||||
|
const level = Math.floor(indent.length / 2);
|
||||||
|
|
||||||
|
// Extract the entry name
|
||||||
|
const entryMatch = line.match(/^\s*-\s+(.+?)(\/$)?$/);
|
||||||
|
if (!entryMatch) continue;
|
||||||
|
|
||||||
|
const fullName = entryMatch[1];
|
||||||
|
const isDirectory = line.trim().endsWith('/');
|
||||||
|
const name = isDirectory ? fullName : fullName;
|
||||||
|
|
||||||
|
// Update current path based on level
|
||||||
|
currentPath = currentPath.slice(0, level);
|
||||||
|
currentPath.push(name);
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
path: currentPath.join('/'),
|
||||||
|
name,
|
||||||
|
type: isDirectory ? 'directory' : 'file',
|
||||||
|
level,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const entries = parseDirectoryTree(content);
|
||||||
|
|
||||||
|
const toggleDirectory = (path: string) => {
|
||||||
|
setExpandedDirs(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(path)) {
|
||||||
|
next.delete(path);
|
||||||
|
} else {
|
||||||
|
next.add(path);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group entries by parent for collapsible display
|
||||||
|
const getChildren = (parentPath: string, parentLevel: number) => {
|
||||||
|
return entries.filter(e => {
|
||||||
|
if (e.level !== parentLevel + 1) return false;
|
||||||
|
const parentParts = parentPath.split('/').filter(Boolean);
|
||||||
|
const entryParts = e.path.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
// Check if this entry is a direct child of the parent
|
||||||
|
if (entryParts.length !== parentParts.length + 1) return false;
|
||||||
|
|
||||||
|
// Check if all parent parts match
|
||||||
|
for (let i = 0; i < parentParts.length; i++) {
|
||||||
|
if (parentParts[i] !== entryParts[i]) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEntry = (entry: typeof entries[0], isRoot = false) => {
|
||||||
|
const hasChildren = entry.type === 'directory' &&
|
||||||
|
entries.some(e => e.path.startsWith(entry.path + '/') && e.level === entry.level + 1);
|
||||||
|
const isExpanded = expandedDirs.has(entry.path) || isRoot;
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
if (entry.type === 'directory') {
|
||||||
|
return isExpanded ?
|
||||||
|
<FolderOpen className="h-3.5 w-3.5 text-blue-500" /> :
|
||||||
|
<Folder className="h-3.5 w-3.5 text-blue-500" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File type icons based on extension
|
||||||
|
const ext = entry.name.split('.').pop()?.toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case 'rs':
|
||||||
|
return <FileCode className="h-3.5 w-3.5 text-orange-500" />;
|
||||||
|
case 'toml':
|
||||||
|
case 'yaml':
|
||||||
|
case 'yml':
|
||||||
|
case 'json':
|
||||||
|
return <FileText className="h-3.5 w-3.5 text-yellow-500" />;
|
||||||
|
case 'md':
|
||||||
|
return <FileText className="h-3.5 w-3.5 text-blue-400" />;
|
||||||
|
case 'js':
|
||||||
|
case 'jsx':
|
||||||
|
case 'ts':
|
||||||
|
case 'tsx':
|
||||||
|
return <FileCode className="h-3.5 w-3.5 text-yellow-400" />;
|
||||||
|
case 'py':
|
||||||
|
return <FileCode className="h-3.5 w-3.5 text-blue-500" />;
|
||||||
|
case 'go':
|
||||||
|
return <FileCode className="h-3.5 w-3.5 text-cyan-500" />;
|
||||||
|
case 'sh':
|
||||||
|
case 'bash':
|
||||||
|
return <Terminal className="h-3.5 w-3.5 text-green-500" />;
|
||||||
|
default:
|
||||||
|
return <FileText className="h-3.5 w-3.5 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={entry.path}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 py-1 px-2 rounded hover:bg-muted/50 transition-colors cursor-pointer",
|
||||||
|
!isRoot && "ml-4"
|
||||||
|
)}
|
||||||
|
onClick={() => entry.type === 'directory' && hasChildren && toggleDirectory(entry.path)}
|
||||||
|
>
|
||||||
|
{entry.type === 'directory' && hasChildren && (
|
||||||
|
<ChevronRight className={cn(
|
||||||
|
"h-3 w-3 text-muted-foreground transition-transform",
|
||||||
|
isExpanded && "rotate-90"
|
||||||
|
)} />
|
||||||
|
)}
|
||||||
|
{(!hasChildren || entry.type !== 'directory') && (
|
||||||
|
<div className="w-3" />
|
||||||
|
)}
|
||||||
|
{getIcon()}
|
||||||
|
<span className="text-sm font-mono">{entry.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entry.type === 'directory' && hasChildren && isExpanded && (
|
||||||
|
<div className="ml-2">
|
||||||
|
{getChildren(entry.path, entry.level).map(child => renderEntry(child))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get root entries
|
||||||
|
const rootEntries = entries.filter(e => e.level === 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-muted/20 p-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{rootEntries.map(entry => renderEntry(entry, true))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
63
src/components/widgets/TodoWidget.tsx
Normal file
63
src/components/widgets/TodoWidget.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { CheckCircle2, Circle, Clock, FileEdit } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface TodoWidgetProps {
|
||||||
|
todos: any[];
|
||||||
|
result?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TodoWidget: React.FC<TodoWidgetProps> = ({ todos, result: _result }) => {
|
||||||
|
const statusIcons = {
|
||||||
|
completed: <CheckCircle2 className="h-4 w-4 text-green-500" />,
|
||||||
|
in_progress: <Clock className="h-4 w-4 text-blue-500 animate-pulse" />,
|
||||||
|
pending: <Circle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityColors = {
|
||||||
|
high: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||||
|
medium: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
|
||||||
|
low: "bg-green-500/10 text-green-500 border-green-500/20"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<FileEdit className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-sm font-medium">Todo List</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{todos.map((todo, idx) => (
|
||||||
|
<div
|
||||||
|
key={todo.id || idx}
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-3 p-3 rounded-lg border bg-card/50",
|
||||||
|
todo.status === "completed" && "opacity-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5">
|
||||||
|
{statusIcons[todo.status as keyof typeof statusIcons] || statusIcons.pending}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<p className={cn(
|
||||||
|
"text-sm",
|
||||||
|
todo.status === "completed" && "line-through"
|
||||||
|
)}>
|
||||||
|
{todo.content}
|
||||||
|
</p>
|
||||||
|
{todo.priority && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn("text-xs", priorityColors[todo.priority as keyof typeof priorityColors])}
|
||||||
|
>
|
||||||
|
{todo.priority}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
27
src/components/widgets/index.ts
Normal file
27
src/components/widgets/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Re-export all widgets from their individual files
|
||||||
|
export { TodoWidget } from './TodoWidget';
|
||||||
|
export { LSWidget } from './LSWidget';
|
||||||
|
export { BashWidget } from './BashWidget';
|
||||||
|
|
||||||
|
// TODO: Add these widgets as they are implemented
|
||||||
|
// export { LSResultWidget } from './LSWidget';
|
||||||
|
// export { ReadWidget } from './ReadWidget';
|
||||||
|
// export { ReadResultWidget } from './ReadResultWidget';
|
||||||
|
// export { GlobWidget } from './GlobWidget';
|
||||||
|
// export { WriteWidget } from './WriteWidget';
|
||||||
|
// export { GrepWidget } from './GrepWidget';
|
||||||
|
// export { EditWidget } from './EditWidget';
|
||||||
|
// export { EditResultWidget } from './EditResultWidget';
|
||||||
|
// export { MCPWidget } from './MCPWidget';
|
||||||
|
// export { CommandWidget } from './CommandWidget';
|
||||||
|
// export { CommandOutputWidget } from './CommandOutputWidget';
|
||||||
|
// export { SummaryWidget } from './SummaryWidget';
|
||||||
|
// export { MultiEditWidget } from './MultiEditWidget';
|
||||||
|
// export { MultiEditResultWidget } from './MultiEditResultWidget';
|
||||||
|
// export { SystemReminderWidget } from './SystemReminderWidget';
|
||||||
|
// export { SystemInitializedWidget } from './SystemInitializedWidget';
|
||||||
|
// export { TaskWidget } from './TaskWidget';
|
||||||
|
// export { WebSearchWidget } from './WebSearchWidget';
|
||||||
|
// export { ThinkingWidget } from './ThinkingWidget';
|
||||||
|
// export { WebFetchWidget } from './WebFetchWidget';
|
||||||
|
// export { TodoReadWidget } from './TodoReadWidget';
|
Reference in New Issue
Block a user