refactor(design): change the design of tools and introduce todo list and task list.

This commit is contained in:
simosmik
2026-02-09 15:14:11 +00:00
committed by Haileyesus
parent 56a132d34e
commit 905ae38bf5
14 changed files with 602 additions and 347 deletions

View File

@@ -10,12 +10,12 @@ const TodoList = ({ todos, isResult = false }) => {
const getStatusIcon = (status) => { const getStatusIcon = (status) => {
switch (status) { switch (status) {
case 'completed': case 'completed':
return <CheckCircle2 className="w-4 h-4 text-green-500 dark:text-green-400" />; return <CheckCircle2 className="w-3.5 h-3.5 text-green-500 dark:text-green-400" />;
case 'in_progress': case 'in_progress':
return <Clock className="w-4 h-4 text-blue-500 dark:text-blue-400" />; return <Clock className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" />;
case 'pending': case 'pending':
default: default:
return <Circle className="w-4 h-4 text-gray-400 dark:text-gray-500" />; return <Circle className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />;
} }
}; };
@@ -44,38 +44,38 @@ const TodoList = ({ todos, isResult = false }) => {
}; };
return ( return (
<div className="space-y-3"> <div className="space-y-1.5">
{isResult && ( {isResult && (
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> <div className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
Todo List ({todos.length} {todos.length === 1 ? 'item' : 'items'}) Todo List ({todos.length} {todos.length === 1 ? 'item' : 'items'})
</div> </div>
)} )}
{todos.map((todo, index) => ( {todos.map((todo, index) => (
<div <div
key={todo.id || `todo-${index}`} key={todo.id || `todo-${index}`}
className="flex items-start gap-3 p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:shadow-md dark:shadow-gray-900/50 transition-shadow" className="flex items-start gap-2 p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded transition-colors"
> >
<div className="flex-shrink-0 mt-0.5"> <div className="flex-shrink-0 mt-0.5">
{getStatusIcon(todo.status)} {getStatusIcon(todo.status)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2"> <div className="flex items-start justify-between gap-2 mb-0.5">
<p className={`text-sm font-medium ${todo.status === 'completed' ? 'line-through text-gray-500 dark:text-gray-400' : 'text-gray-900 dark:text-gray-100'}`}> <p className={`text-xs font-medium ${todo.status === 'completed' ? 'line-through text-gray-500 dark:text-gray-400' : 'text-gray-900 dark:text-gray-100'}`}>
{todo.content} {todo.content}
</p> </p>
<div className="flex gap-1 flex-shrink-0"> <div className="flex gap-1 flex-shrink-0">
<Badge <Badge
variant="outline" variant="outline"
className={`text-xs px-2 py-0.5 ${getPriorityColor(todo.priority)}`} className={`text-[10px] px-1.5 py-px ${getPriorityColor(todo.priority)}`}
> >
{todo.priority} {todo.priority}
</Badge> </Badge>
<Badge <Badge
variant="outline" variant="outline"
className={`text-xs px-2 py-0.5 ${getStatusColor(todo.status)}`} className={`text-[10px] px-1.5 py-px ${getStatusColor(todo.status)}`}
> >
{todo.status.replace('_', ' ')} {todo.status.replace('_', ' ')}
</Badge> </Badge>
@@ -88,4 +88,4 @@ const TodoList = ({ todos, isResult = false }) => {
); );
}; };
export default TodoList; export default TodoList;

View File

@@ -11,7 +11,7 @@ import { Markdown } from '../markdown/Markdown';
import { formatUsageLimitText } from '../utils/chatFormatting'; import { formatUsageLimitText } from '../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../utils/chatPermissions'; import { getClaudePermissionSuggestion } from '../utils/chatPermissions';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../tools'; import { ToolRenderer, shouldHideToolResult, FileListContent, TaskListContent } from '../tools';
type DiffLine = { type DiffLine = {
type: string; type: string;
@@ -181,38 +181,29 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
return ( return (
<div <div
id={`tool-result-${message.toolId}`} id={`tool-result-${message.toolId}`}
className={`relative mt-4 p-4 rounded-lg border backdrop-blur-sm scroll-mt-4 ${ className={`relative mt-2 p-3 rounded border scroll-mt-4 ${
message.toolResult.isError message.toolResult.isError
? 'bg-gradient-to-br from-red-50 to-rose-50 dark:from-red-950/20 dark:to-rose-950/20 border-red-200/60 dark:border-red-800/60' ? 'bg-red-50/50 dark:bg-red-950/10 border-red-200/60 dark:border-red-800/40'
: 'bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 border-green-200/60 dark:border-green-800/60' : 'bg-green-50/50 dark:bg-green-950/10 border-green-200/60 dark:border-green-800/40'
}`}> }`}>
{/* Decorative gradient overlay */} <div className="relative flex items-center gap-1.5 mb-2">
<div className={`absolute inset-0 rounded-lg opacity-50 ${ <svg className={`w-4 h-4 ${
message.toolResult.isError
? 'bg-gradient-to-br from-red-500/5 to-rose-500/5 dark:from-red-400/5 dark:to-rose-400/5'
: 'bg-gradient-to-br from-green-500/5 to-emerald-500/5 dark:from-green-400/5 dark:to-emerald-400/5'
}`}></div>
<div className="relative flex items-center gap-2.5 mb-3">
<div className={`w-6 h-6 rounded-lg flex items-center justify-center shadow-md ${
message.toolResult.isError message.toolResult.isError
? 'bg-gradient-to-br from-red-500 to-rose-600 dark:from-red-400 dark:to-rose-500 shadow-red-500/20' ? 'text-red-500 dark:text-red-400'
: 'bg-gradient-to-br from-green-500 to-emerald-600 dark:from-green-400 dark:to-emerald-500 shadow-green-500/20' : 'text-green-500 dark:text-green-400'
}`}> }`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {message.toolResult.isError ? (
{message.toolResult.isError ? ( <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" /> ) : (
) : ( <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" /> )}
)} </svg>
</svg> <span className={`text-xs font-medium ${
</div>
<span className={`text-sm font-semibold ${
message.toolResult.isError message.toolResult.isError
? 'text-red-800 dark:text-red-200' ? 'text-red-700 dark:text-red-300'
: 'text-green-800 dark:text-green-200' : 'text-green-700 dark:text-green-300'
}`}> }`}>
{message.toolResult.isError ? 'Tool Error' : 'Tool Result'} {message.toolResult.isError ? 'Error' : 'Result'}
</span> </span>
</div> </div>
@@ -291,57 +282,26 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
} }
} }
// Special handling for Grep/Glob results with structured data // Grep/Glob results - compact comma-separated file list
if ((message.toolName === 'Grep' || message.toolName === 'Glob') && message.toolResult?.toolUseResult) { if ((message.toolName === 'Grep' || message.toolName === 'Glob') && message.toolResult?.toolUseResult) {
const toolData = message.toolResult.toolUseResult; const toolData = message.toolResult.toolUseResult;
// Handle files_with_matches mode or any tool result with filenames array
if (toolData.filenames && Array.isArray(toolData.filenames) && toolData.filenames.length > 0) { if (toolData.filenames && Array.isArray(toolData.filenames) && toolData.filenames.length > 0) {
const count = toolData.numFiles || toolData.filenames.length;
return ( return (
<div> <FileListContent
<div className="flex items-center gap-2 mb-3"> files={toolData.filenames}
<span className="font-medium"> onFileClick={onFileOpen}
Found {toolData.numFiles || toolData.filenames.length} {(toolData.numFiles === 1 || toolData.filenames.length === 1) ? 'file' : 'files'} title={`Found ${count} ${count === 1 ? 'file' : 'files'}`}
</span> />
</div>
<div className="space-y-1 max-h-96 overflow-y-auto">
{toolData.filenames.map((filePath, index) => {
const fileName = filePath.split('/').pop();
const dirPath = filePath.substring(0, filePath.lastIndexOf('/'));
return (
<div
key={index}
onClick={() => {
if (onFileOpen) {
onFileOpen(filePath);
}
}}
className="group flex items-center gap-2 px-2 py-1.5 rounded hover:bg-green-100/50 dark:hover:bg-green-800/20 cursor-pointer transition-colors"
>
<svg className="w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div className="flex-1 min-w-0">
<div className="font-mono text-sm font-medium text-green-800 dark:text-green-200 truncate group-hover:text-green-900 dark:group-hover:text-green-100">
{fileName}
</div>
<div className="font-mono text-xs text-green-600/70 dark:text-green-400/70 truncate">
{dirPath}
</div>
</div>
<svg className="w-4 h-4 text-green-600 dark:text-green-400 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
);
})}
</div>
</div>
); );
} }
} }
// Task tool results - proper task list rendering
if (message.toolName === 'TaskList' || message.toolName === 'TaskGet') {
return <TaskListContent content={content} />;
}
// Special handling for interactive prompts // Special handling for interactive prompts
if (content.includes('Do you want to proceed?') && message.toolName === 'Bash') { if (content.includes('Do you want to proceed?') && message.toolName === 'Bash') {
const lines = content.split('\n'); const lines = content.split('\n');
@@ -797,9 +757,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div> </div>
)} )}
<div className={`text-xs text-gray-500 dark:text-gray-400 mt-1 ${isGrouped ? 'opacity-0 group-hover:opacity-100' : ''}`}> {!isGrouped && (
{new Date(message.timestamp).toLocaleTimeString()} <div className="text-[11px] text-gray-400 dark:text-gray-500 mt-1">
</div> {new Date(message.timestamp).toLocaleTimeString()}
</div>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { getToolConfig } from './configs/toolConfigs'; import { getToolConfig } from './configs/toolConfigs';
import { OneLineDisplay, CollapsibleDisplay, FilePathButton, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TextContent } from './components'; import { OneLineDisplay, CollapsibleDisplay, FilePathButton, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent } from './components';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
type DiffLine = { type DiffLine = {
@@ -24,6 +24,16 @@ interface ToolRendererProps {
rawToolInput?: string; rawToolInput?: string;
} }
function getToolCategory(toolName: string): string {
if (['Edit', 'Write', 'ApplyPatch'].includes(toolName)) return 'edit';
if (['Grep', 'Glob'].includes(toolName)) return 'search';
if (toolName === 'Bash') return 'bash';
if (['TodoWrite', 'TodoRead'].includes(toolName)) return 'todo';
if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task';
if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan';
return 'default';
}
/** /**
* Main tool renderer router * Main tool renderer router
* Routes to OneLineDisplay or CollapsibleDisplay based on tool config * Routes to OneLineDisplay or CollapsibleDisplay based on tool config
@@ -43,7 +53,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = ({
rawToolInput rawToolInput
}) => { }) => {
const config = getToolConfig(toolName); const config = getToolConfig(toolName);
const displayConfig = mode === 'input' ? config.input : config.result; const displayConfig: any = mode === 'input' ? config.input : config.result;
if (!displayConfig) return null; if (!displayConfig) return null;
@@ -87,7 +97,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = ({
if (displayConfig.type === 'collapsible') { if (displayConfig.type === 'collapsible') {
const title = typeof displayConfig.title === 'function' const title = typeof displayConfig.title === 'function'
? displayConfig.title(parsedData) ? displayConfig.title(parsedData)
: displayConfig.title || 'View details'; : displayConfig.title || 'Details';
const defaultOpen = displayConfig.defaultOpen !== undefined const defaultOpen = displayConfig.defaultOpen !== undefined
? displayConfig.defaultOpen ? displayConfig.defaultOpen
@@ -143,6 +153,14 @@ export const ToolRenderer: React.FC<ToolRendererProps> = ({
); );
break; break;
case 'task':
contentComponent = (
<TaskListContent
content={contentProps.content || ''}
/>
);
break;
case 'text': case 'text':
contentComponent = ( contentComponent = (
<TextContent <TextContent
@@ -155,15 +173,18 @@ export const ToolRenderer: React.FC<ToolRendererProps> = ({
case 'success-message': case 'success-message':
const message = displayConfig.getMessage?.(parsedData) || 'Success'; const message = displayConfig.getMessage?.(parsedData) || 'Success';
contentComponent = ( contentComponent = (
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<span className="font-medium">{message}</span> <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{message}
</div> </div>
); );
break; break;
default: default:
contentComponent = ( contentComponent = (
<div className="text-gray-500">Unknown content type: {displayConfig.contentType}</div> <div className="text-gray-500 text-xs">Unknown content type: {displayConfig.contentType}</div>
); );
} }
@@ -223,11 +244,16 @@ export const ToolRenderer: React.FC<ToolRendererProps> = ({
actionButton = ( actionButton = (
<FilePathButton <FilePathButton
filePath={contentProps.filePath} filePath={contentProps.filePath}
onClick={handleFileClick} onClick={(e?: any) => handleFileClick(e)}
/> />
); );
} }
// For edit tools, make the title (filename) clickable to open the file
const handleTitleClick = (toolName === 'Edit' || toolName === 'Write' || toolName === 'ApplyPatch') && contentProps.filePath && onFileOpen
? () => onFileOpen(contentProps.filePath)
: undefined;
return ( return (
<CollapsibleDisplay <CollapsibleDisplay
toolName={toolName} toolName={toolName}
@@ -235,17 +261,20 @@ export const ToolRenderer: React.FC<ToolRendererProps> = ({
title={title} title={title}
defaultOpen={defaultOpen} defaultOpen={defaultOpen}
action={actionButton} action={actionButton}
onTitleClick={handleTitleClick}
contentType={displayConfig.contentType || 'text'} contentType={displayConfig.contentType || 'text'}
contentProps={{ contentProps={{
DiffViewer: contentComponent, DiffViewer: contentComponent,
MarkdownComponent: contentComponent, MarkdownComponent: contentComponent,
FileListComponent: contentComponent, FileListComponent: contentComponent,
TodoListComponent: contentComponent, TodoListComponent: contentComponent,
TaskComponent: contentComponent,
TextComponent: contentComponent TextComponent: contentComponent
}} }}
showRawParameters={mode === 'input' && showRawParameters} showRawParameters={mode === 'input' && showRawParameters}
rawContent={rawToolInput} rawContent={rawToolInput}
onShowSettings={onShowSettings} onShowSettings={onShowSettings}
toolCategory={getToolCategory(toolName)}
/> />
); );
} }

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { CollapsibleSection } from './CollapsibleSection'; import { CollapsibleSection } from './CollapsibleSection';
type ContentType = 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text'; type ContentType = 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task' | 'success-message';
interface CollapsibleDisplayProps { interface CollapsibleDisplayProps {
toolName: string; toolName: string;
@@ -9,111 +9,85 @@ interface CollapsibleDisplayProps {
title: string; title: string;
defaultOpen?: boolean; defaultOpen?: boolean;
action?: React.ReactNode; action?: React.ReactNode;
onTitleClick?: () => void;
contentType: ContentType; contentType: ContentType;
contentProps: any; contentProps: any;
showRawParameters?: boolean; showRawParameters?: boolean;
rawContent?: string; rawContent?: string;
className?: string; className?: string;
onShowSettings?: () => void; onShowSettings?: () => void;
toolCategory?: string;
} }
const borderColorMap: Record<string, string> = {
edit: 'border-l-amber-500 dark:border-l-amber-400',
search: 'border-l-gray-400 dark:border-l-gray-500',
bash: 'border-l-green-500 dark:border-l-green-400',
todo: 'border-l-violet-500 dark:border-l-violet-400',
task: 'border-l-violet-500 dark:border-l-violet-400',
plan: 'border-l-indigo-500 dark:border-l-indigo-400',
default: 'border-l-gray-300 dark:border-l-gray-600',
};
export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
toolName, toolName,
toolId,
title, title,
defaultOpen = false, defaultOpen = false,
action, action,
onTitleClick,
contentType, contentType,
contentProps, contentProps,
showRawParameters = false, showRawParameters = false,
rawContent, rawContent,
className = '', className = '',
onShowSettings toolCategory
}) => { }) => {
const renderContent = () => { const renderContent = () => {
switch (contentType) { switch (contentType) {
case 'diff': case 'diff':
return contentProps.DiffViewer; return contentProps.DiffViewer;
case 'markdown': case 'markdown':
return contentProps.MarkdownComponent; return contentProps.MarkdownComponent;
case 'file-list': case 'file-list':
return contentProps.FileListComponent; return contentProps.FileListComponent;
case 'todo-list': case 'todo-list':
return contentProps.TodoListComponent; return contentProps.TodoListComponent;
case 'task':
return contentProps.TaskComponent;
case 'text': case 'text':
return contentProps.TextComponent; return contentProps.TextComponent;
default: default:
return <div className="text-gray-500">Unknown content type: {contentType}</div>; return <div className="text-xs text-gray-500">Unknown content type: {contentType}</div>;
} }
}; };
const borderColor = borderColorMap[toolCategory || 'default'];
return ( return (
<div className="group relative bg-gradient-to-br from-blue-50/50 to-indigo-50/50 dark:from-blue-950/20 dark:to-indigo-950/20 border border-blue-100/30 dark:border-blue-800/30 rounded-lg p-3 mb-2"> <div className={`border-l-2 ${borderColor} pl-3 py-0.5 my-1 ${className}`}>
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/3 to-indigo-500/3 dark:from-blue-400/3 dark:to-indigo-400/3 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<div className="relative flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="relative w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 dark:from-blue-400 dark:to-indigo-500 rounded-lg flex items-center justify-center shadow-lg shadow-blue-500/20 dark:shadow-blue-400/20">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<div className="absolute inset-0 rounded-lg bg-blue-500 dark:bg-blue-400 animate-pulse opacity-20"></div>
</div>
<div className="flex flex-col">
<span className="font-semibold text-gray-900 dark:text-white text-sm">
{toolName}
</span>
{toolId && (
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{toolId}
</span>
)}
</div>
</div>
{onShowSettings && (
<button
onClick={(e) => {
e.stopPropagation();
onShowSettings();
}}
className="p-2 rounded-lg hover:bg-white/60 dark:hover:bg-gray-800/60 transition-all duration-200 group/btn backdrop-blur-sm"
title="Settings"
>
<svg className="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover/btn:text-blue-600 dark:group-hover/btn:text-blue-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
)}
</div>
<CollapsibleSection <CollapsibleSection
title={title} title={title}
toolName={toolName}
open={defaultOpen} open={defaultOpen}
action={action} action={action}
className={className} onTitleClick={onTitleClick}
> >
{renderContent()} {renderContent()}
{showRawParameters && rawContent && ( {showRawParameters && rawContent && (
<details className="relative mt-3 pl-6 group/raw" open={defaultOpen}> <details className="relative mt-2 group/raw">
<summary className="flex items-center gap-2 text-xs font-medium text-gray-600 dark:text-gray-400 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50"> <summary className="flex items-center gap-1.5 text-[11px] text-gray-400 dark:text-gray-500 cursor-pointer hover:text-gray-600 dark:hover:text-gray-300 py-0.5">
<svg <svg
className="w-3 h-3 transition-transform duration-200 group-open/raw:rotate-180" className="w-2.5 h-2.5 transition-transform duration-150 group-open/raw:rotate-90"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
View raw parameters raw params
</summary> </summary>
<pre className="mt-2 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/60 dark:border-gray-700/60 p-3 rounded-lg whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono"> <pre className="mt-1 text-[11px] bg-gray-50 dark:bg-gray-900/50 border border-gray-200/40 dark:border-gray-700/40 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-gray-600 dark:text-gray-400 font-mono">
{rawContent} {rawContent}
</pre> </pre>
</details> </details>

View File

@@ -2,40 +2,58 @@ import React from 'react';
interface CollapsibleSectionProps { interface CollapsibleSectionProps {
title: string; title: string;
toolName?: string;
open?: boolean; open?: boolean;
action?: React.ReactNode; action?: React.ReactNode;
onTitleClick?: () => void;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
} }
/** /**
* Reusable collapsible section with consistent styling * Reusable collapsible section with consistent styling
* Replaces repeated details/summary patterns throughout MessageComponent
*/ */
export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title, title,
toolName,
open = false, open = false,
action, action,
onTitleClick,
children, children,
className = '' className = ''
}) => { }) => {
return ( return (
<details className={`relative mt-3 group/details ${className}`} open={open}> <details className={`relative group/details ${className}`} open={open}>
<summary className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2.5 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50"> <summary className="flex items-center gap-1.5 text-xs cursor-pointer py-0.5 select-none">
<svg <svg
className="w-4 h-4 transition-transform duration-200 group-open/details:rotate-180" className="w-3 h-3 text-gray-400 dark:text-gray-500 transition-transform duration-150 group-open/details:rotate-90 flex-shrink-0"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
<span className="flex items-center gap-2 flex-1"> {toolName && (
{title} <span className="font-medium text-gray-500 dark:text-gray-400 flex-shrink-0">{toolName}</span>
</span> )}
{action} {toolName && (
<span className="text-gray-300 dark:text-gray-600 text-[10px] flex-shrink-0">/</span>
)}
{onTitleClick ? (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onTitleClick(); }}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-mono hover:underline truncate flex-1 text-left transition-colors"
>
{title}
</button>
) : (
<span className="text-gray-600 dark:text-gray-400 truncate flex-1">
{title}
</span>
)}
{action && <span className="flex-shrink-0 ml-1">{action}</span>}
</summary> </summary>
<div className="mt-3 pl-6"> <div className="mt-1.5 pl-[18px]">
{children} {children}
</div> </div>
</details> </details>

View File

@@ -12,7 +12,7 @@ interface FileListContentProps {
} }
/** /**
* Renders a list of files with click handlers * Renders a compact comma-separated list of clickable file names
* Used by: Grep/Glob results * Used by: Grep/Glob results
*/ */
export const FileListContent: React.FC<FileListContentProps> = ({ export const FileListContent: React.FC<FileListContentProps> = ({
@@ -20,54 +20,34 @@ export const FileListContent: React.FC<FileListContentProps> = ({
onFileClick, onFileClick,
title title
}) => { }) => {
const fileCount = files.length;
return ( return (
<div> <div>
{title && ( {title && (
<div className="flex items-center gap-2 mb-3"> <div className="text-[11px] text-gray-500 dark:text-gray-400 mb-1">
<span className="font-medium"> {title}
{title || `Found ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`}
</span>
</div> </div>
)} )}
<div className="space-y-1 max-h-96 overflow-y-auto"> <div className="flex flex-wrap gap-x-1 gap-y-0.5 max-h-48 overflow-y-auto">
{files.map((file, index) => { {files.map((file, index) => {
const filePath = typeof file === 'string' ? file : file.path; const filePath = typeof file === 'string' ? file : file.path;
const fileName = filePath.split('/').pop() || filePath; const fileName = filePath.split('/').pop() || filePath;
const dirPath = filePath.substring(0, filePath.lastIndexOf('/'));
const handleClick = typeof file === 'string' const handleClick = typeof file === 'string'
? () => onFileClick?.(file) ? () => onFileClick?.(file)
: file.onClick; : file.onClick;
return ( return (
<div <span key={index} className="inline-flex items-center">
key={index} <button
onClick={handleClick} onClick={handleClick}
className="group flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 cursor-pointer transition-colors" className="text-[11px] font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline transition-colors"
> title={filePath}
{/* File icon */} >
<svg className="w-4 h-4 text-gray-400 dark:text-gray-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {fileName}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> </button>
</svg> {index < files.length - 1 && (
<span className="text-gray-300 dark:text-gray-600 text-[10px] ml-1">,</span>
{/* File path */} )}
<div className="flex-1 min-w-0"> </span>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{fileName}
</div>
{dirPath && (
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{dirPath}
</div>
)}
</div>
{/* Chevron on hover */}
<svg className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
); );
})} })}
</div> </div>

View File

@@ -12,7 +12,7 @@ interface MarkdownContentProps {
*/ */
export const MarkdownContent: React.FC<MarkdownContentProps> = ({ export const MarkdownContent: React.FC<MarkdownContentProps> = ({
content, content,
className = 'mt-3 prose prose-sm max-w-none dark:prose-invert' className = 'mt-1 prose prose-sm max-w-none dark:prose-invert'
}) => { }) => {
return ( return (
<Markdown className={className}> <Markdown className={className}>

View File

@@ -0,0 +1,125 @@
import React from 'react';
interface TaskItem {
id: string;
subject: string;
status: 'pending' | 'in_progress' | 'completed';
owner?: string;
blockedBy?: string[];
}
interface TaskListContentProps {
content: string;
}
function parseTaskContent(content: string): TaskItem[] {
const tasks: TaskItem[] = [];
const lines = content.split('\n');
for (const line of lines) {
// Match patterns like: #15. [in_progress] Subject here
// or: - #15 [in_progress] Subject (owner: agent)
// or: #15. Subject here (status: in_progress)
const match = line.match(/#(\d+)\.?\s*(?:\[(\w+)\]\s*)?(.+?)(?:\s*\((?:owner:\s*\w+)?\))?$/);
if (match) {
const [, id, status, subject] = match;
const blockedMatch = line.match(/blockedBy:\s*\[([^\]]*)\]/);
tasks.push({
id,
subject: subject.trim(),
status: (status as TaskItem['status']) || 'pending',
blockedBy: blockedMatch ? blockedMatch[1].split(',').map(s => s.trim()).filter(Boolean) : undefined
});
}
}
return tasks;
}
const statusConfig = {
completed: {
icon: (
<svg className="w-3.5 h-3.5 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
textClass: 'line-through text-gray-400 dark:text-gray-500',
badgeClass: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800'
},
in_progress: {
icon: (
<svg className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
textClass: 'text-gray-900 dark:text-gray-100',
badgeClass: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800'
},
pending: {
icon: (
<svg className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" strokeWidth={2} />
</svg>
),
textClass: 'text-gray-700 dark:text-gray-300',
badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700'
}
};
/**
* Renders task list results with proper status icons and compact layout
* Parses text content from TaskList/TaskGet results
*/
export const TaskListContent: React.FC<TaskListContentProps> = ({ content }) => {
const tasks = parseTaskContent(content);
// If we couldn't parse any tasks, fall back to text display
if (tasks.length === 0) {
return (
<pre className="text-[11px] font-mono text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
{content}
</pre>
);
}
const completed = tasks.filter(t => t.status === 'completed').length;
const total = tasks.length;
return (
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-gray-500 dark:text-gray-400">
{completed}/{total} completed
</span>
<div className="flex-1 h-1 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 dark:bg-green-400 rounded-full transition-all"
style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }}
/>
</div>
</div>
<div className="space-y-px">
{tasks.map((task) => {
const config = statusConfig[task.status] || statusConfig.pending;
return (
<div
key={task.id}
className="flex items-center gap-1.5 py-0.5 group"
>
<span className="flex-shrink-0">{config.icon}</span>
<span className="text-[11px] font-mono text-gray-400 dark:text-gray-500 flex-shrink-0">
#{task.id}
</span>
<span className={`text-xs truncate flex-1 ${config.textClass}`}>
{task.subject}
</span>
<span className={`text-[10px] px-1 py-px rounded border flex-shrink-0 ${config.badgeClass}`}>
{task.status.replace('_', ' ')}
</span>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -25,7 +25,7 @@ export const TextContent: React.FC<TextContentProps> = ({
} }
return ( return (
<pre className={`mt-2 text-xs bg-gray-900 dark:bg-gray-950 text-gray-100 p-4 rounded-lg overflow-x-auto font-mono ${className}`}> <pre className={`mt-1 text-xs bg-gray-900 dark:bg-gray-950 text-gray-100 p-2.5 rounded overflow-x-auto font-mono ${className}`}>
{formattedJson} {formattedJson}
</pre> </pre>
); );
@@ -33,7 +33,7 @@ export const TextContent: React.FC<TextContentProps> = ({
if (format === 'code') { if (format === 'code') {
return ( return (
<pre className={`mt-2 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/60 dark:border-gray-700/60 p-3 rounded-lg whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono ${className}`}> <pre className={`mt-1 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/50 dark:border-gray-700/50 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono ${className}`}>
{content} {content}
</pre> </pre>
); );
@@ -41,7 +41,7 @@ export const TextContent: React.FC<TextContentProps> = ({
// Plain text // Plain text
return ( return (
<div className={`mt-2 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap ${className}`}> <div className={`mt-1 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap ${className}`}>
{content} {content}
</div> </div>
); );

View File

@@ -1,4 +1,5 @@
export { MarkdownContent } from './MarkdownContent'; export { MarkdownContent } from './MarkdownContent';
export { FileListContent } from './FileListContent'; export { FileListContent } from './FileListContent';
export { TodoListContent } from './TodoListContent'; export { TodoListContent } from './TodoListContent';
export { TaskListContent } from './TaskListContent';
export { TextContent } from './TextContent'; export { TextContent } from './TextContent';

View File

@@ -17,8 +17,7 @@ interface DiffViewerProps {
} }
/** /**
* Reusable diff viewer component with consistent styling * Compact diff viewer — VS Code-style
* Replaces duplicated diff display logic in Edit, Write, and result sections
*/ */
export const DiffViewer: React.FC<DiffViewerProps> = ({ export const DiffViewer: React.FC<DiffViewerProps> = ({
oldContent, oldContent,
@@ -30,48 +29,48 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
badgeColor = 'gray' badgeColor = 'gray'
}) => { }) => {
const badgeClasses = badgeColor === 'green' const badgeClasses = badgeColor === 'green'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' ? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-500 dark:text-gray-400'; : 'bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400';
return ( return (
<div className="bg-white dark:bg-gray-900/50 border border-gray-200/60 dark:border-gray-700/60 rounded-lg overflow-hidden shadow-sm"> <div className="border border-gray-200/60 dark:border-gray-700/50 rounded overflow-hidden">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800/80 dark:to-gray-800/40 border-b border-gray-200/60 dark:border-gray-700/60 backdrop-blur-sm"> <div className="flex items-center justify-between px-2.5 py-1 bg-gray-50/80 dark:bg-gray-800/40 border-b border-gray-200/60 dark:border-gray-700/50">
{onFileClick ? ( {onFileClick ? (
<button <button
onClick={onFileClick} onClick={onFileClick}
className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate cursor-pointer font-medium transition-colors" className="text-[11px] font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate cursor-pointer transition-colors"
> >
{filePath} {filePath}
</button> </button>
) : ( ) : (
<span className="text-xs font-mono text-gray-700 dark:text-gray-300 truncate"> <span className="text-[11px] font-mono text-gray-600 dark:text-gray-400 truncate">
{filePath} {filePath}
</span> </span>
)} )}
<span className={`text-xs font-medium px-2 py-0.5 rounded ${badgeClasses}`}> <span className={`text-[10px] font-medium px-1.5 py-px rounded ${badgeClasses} flex-shrink-0 ml-2`}>
{badge} {badge}
</span> </span>
</div> </div>
{/* Diff content */} {/* Diff lines */}
<div className="text-xs font-mono"> <div className="text-[11px] font-mono leading-[18px]">
{createDiff(oldContent, newContent).map((diffLine, i) => ( {createDiff(oldContent, newContent).map((diffLine, i) => (
<div key={i} className="flex"> <div key={i} className="flex">
<span <span
className={`w-8 text-center border-r ${ className={`w-6 text-center select-none flex-shrink-0 ${
diffLine.type === 'removed' diffLine.type === 'removed'
? 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border-red-200 dark:border-red-800' ? 'bg-red-50 dark:bg-red-950/30 text-red-400 dark:text-red-500'
: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800' : 'bg-green-50 dark:bg-green-950/30 text-green-400 dark:text-green-500'
}`} }`}
> >
{diffLine.type === 'removed' ? '-' : '+'} {diffLine.type === 'removed' ? '-' : '+'}
</span> </span>
<span <span
className={`px-2 py-0.5 flex-1 whitespace-pre-wrap ${ className={`px-2 flex-1 whitespace-pre-wrap ${
diffLine.type === 'removed' diffLine.type === 'removed'
? 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200' ? 'bg-red-50/50 dark:bg-red-950/20 text-red-800 dark:text-red-200'
: 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200' : 'bg-green-50/50 dark:bg-green-950/20 text-green-800 dark:text-green-200'
}`} }`}
> >
{diffLine.content} {diffLine.content}

View File

@@ -9,8 +9,7 @@ interface FilePathButtonProps {
} }
/** /**
* Reusable clickable file path component with consistent styling * Clickable file path — inline link style
* Used across Edit, Write, and Read tool displays
*/ */
export const FilePathButton: React.FC<FilePathButtonProps> = ({ export const FilePathButton: React.FC<FilePathButtonProps> = ({
filePath, filePath,
@@ -26,7 +25,7 @@ export const FilePathButton: React.FC<FilePathButtonProps> = ({
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono transition-colors ${className}`} className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-mono text-xs hover:underline transition-colors ${className}`}
> >
{displayText} {displayText}
</button> </button>
@@ -36,7 +35,8 @@ export const FilePathButton: React.FC<FilePathButtonProps> = ({
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className={`px-2.5 py-1 rounded-md bg-white/60 dark:bg-gray-800/60 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 font-mono text-xs font-medium transition-all duration-200 shadow-sm ${className}`} className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-mono text-xs hover:underline transition-colors ${className}`}
title={filePath}
> >
{displayText} {displayText}
</button> </button>

View File

@@ -3,6 +3,7 @@ import React, { useState } from 'react';
type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none'; type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none';
interface OneLineDisplayProps { interface OneLineDisplayProps {
toolName: string; toolName: string;
icon?: string; icon?: string;
label?: string; label?: string;
@@ -41,15 +42,16 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
colorScheme = { colorScheme = {
primary: 'text-gray-700 dark:text-gray-300', primary: 'text-gray-700 dark:text-gray-300',
secondary: 'text-gray-500 dark:text-gray-400', secondary: 'text-gray-500 dark:text-gray-400',
background: 'bg-gray-50/50 dark:bg-gray-800/30', background: '',
border: 'border-blue-400 dark:border-blue-500', border: 'border-gray-300 dark:border-gray-600',
icon: 'text-blue-500 dark:text-blue-400' icon: 'text-gray-500 dark:text-gray-400'
}, },
resultId, resultId,
toolResult, toolResult,
toolId toolId
}) => { }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const isTerminal = style === 'terminal';
const handleAction = () => { const handleAction = () => {
if (action === 'copy' && value) { if (action === 'copy' && value) {
@@ -61,109 +63,118 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
} }
}; };
const renderActionButton = () => { const renderCopyButton = () => (
if (action === 'none') return null; <button
onClick={handleAction}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-all ml-1 flex-shrink-0"
title="Copy to clipboard"
aria-label="Copy to clipboard"
>
{copied ? (
<svg className="w-3 h-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
if (action === 'copy') { // Terminal style: dark pill only around the command
return ( if (isTerminal) {
<button return (
onClick={handleAction} <div className="group flex items-start gap-2 my-1">
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors ml-1" <div className="flex items-center gap-1.5 flex-shrink-0 pt-0.5">
title="Copy to clipboard" <svg className="w-3 h-3 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
aria-label="Copy to clipboard" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
>
{copied ? (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
if (action === 'open-file') {
return (
<button
onClick={handleAction}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono transition-colors"
>
{value.split('/').pop()}
</button>
);
}
if (action === 'jump-to-results' && resultId) {
return (
<a
href={`#${resultId}`}
className="flex-shrink-0 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors flex items-center gap-1"
>
<span>Search results</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</a>
);
}
return null;
};
const isTerminal = style === 'terminal';
return (
<div className={`group relative ${colorScheme.background} border-l-2 ${colorScheme.border} pl-3 py-1 my-0.5`}>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400 flex-1 min-w-0">
{icon === 'terminal' ? (
<svg className={`w-3.5 h-3.5 ${colorScheme.icon} flex-shrink-0`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
) : icon ? (
<span className={`${colorScheme.icon} flex-shrink-0`}>
{icon}
</span>
) : label ? (
<span className="font-medium flex-shrink-0">{label}</span>
) : (
<span className="font-medium flex-shrink-0">{toolName}</span>
)}
<span className="text-gray-400 dark:text-gray-500 flex-shrink-0"></span>
{action === 'open-file' ? (
renderActionButton()
) : (
<span className={`font-mono ${wrapText ? 'whitespace-pre-wrap break-all' : 'truncate'} flex-1 min-w-0 ${colorScheme.primary}`}>
{isTerminal && <span className="text-green-500 dark:text-green-400 mr-1">$</span>}
{value}
</span>
)}
{secondary && (
<span className={`text-xs ${colorScheme.secondary} italic ml-2`}>
({secondary})
</span>
)}
{action === 'copy' && renderActionButton()}
</div> </div>
<div className="flex-1 min-w-0 flex items-start gap-2">
<div className="bg-gray-900 dark:bg-black rounded px-2.5 py-1 flex-1 min-w-0">
<code className={`text-xs text-green-400 font-mono ${wrapText ? 'whitespace-pre-wrap break-all' : 'block truncate'}`}>
<span className="text-green-600 dark:text-green-500 select-none">$ </span>{value}
</code>
</div>
{action === 'copy' && renderCopyButton()}
</div>
{secondary && (
<span className="text-[11px] text-gray-400 dark:text-gray-500 italic flex-shrink-0 pt-1">
{secondary}
</span>
)}
</div>
);
}
{action === 'jump-to-results' && toolResult && ( // File open style - show filename only, full path on hover
if (action === 'open-file') {
const displayName = value.split('/').pop() || value;
return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} pl-3 py-0.5 my-0.5`}>
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">{label || toolName}</span>
<span className="text-gray-300 dark:text-gray-600 text-[10px]">/</span>
<button
onClick={handleAction}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-mono hover:underline transition-colors truncate"
title={value}
>
{displayName}
</button>
</div>
);
}
// Search / jump-to-results style
if (action === 'jump-to-results') {
return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} pl-3 py-0.5 my-0.5`}>
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">{label || toolName}</span>
<span className="text-gray-300 dark:text-gray-600 text-[10px]">/</span>
<span className={`text-xs font-mono truncate flex-1 min-w-0 ${colorScheme.primary}`}>
{value}
</span>
{secondary && (
<span className="text-[11px] text-gray-400 dark:text-gray-500 italic flex-shrink-0">
{secondary}
</span>
)}
{toolResult && (
<a <a
href={`#tool-result-${toolId}`} href={`#tool-result-${toolId}`}
className="flex-shrink-0 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors flex items-center gap-1" className="flex-shrink-0 text-[11px] text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors flex items-center gap-0.5"
> >
<span>Search results</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</a> </a>
)} )}
</div> </div>
);
}
// Default one-line style
return (
<div className={`group flex items-center gap-1.5 ${colorScheme.background || ''} border-l-2 ${colorScheme.border} pl-3 py-0.5 my-0.5`}>
{icon && icon !== 'terminal' && (
<span className={`${colorScheme.icon} flex-shrink-0 text-xs`}>{icon}</span>
)}
{!icon && (label || toolName) && (
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">{label || toolName}</span>
)}
{(icon || label || toolName) && (
<span className="text-gray-300 dark:text-gray-600 text-[10px]">/</span>
)}
<span className={`text-xs font-mono ${wrapText ? 'whitespace-pre-wrap break-all' : 'truncate'} flex-1 min-w-0 ${colorScheme.primary}`}>
{value}
</span>
{secondary && (
<span className={`text-[11px] ${colorScheme.secondary} italic flex-shrink-0`}>
{secondary}
</span>
)}
{action === 'copy' && renderCopyButton()}
</div> </div>
); );
}; };

View File

@@ -12,14 +12,19 @@ export interface ToolDisplayConfig {
getValue?: (input: any) => string; getValue?: (input: any) => string;
getSecondary?: (input: any) => string | undefined; getSecondary?: (input: any) => string | undefined;
action?: 'copy' | 'open-file' | 'jump-to-results' | 'none'; action?: 'copy' | 'open-file' | 'jump-to-results' | 'none';
style?: string;
wrapText?: boolean;
colorScheme?: { colorScheme?: {
primary?: string; primary?: string;
secondary?: string; secondary?: string;
background?: string;
border?: string;
icon?: string;
}; };
// Collapsible config // Collapsible config
title?: string | ((input: any) => string); title?: string | ((input: any) => string);
defaultOpen?: boolean; defaultOpen?: boolean;
contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text'; contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task';
getContentProps?: (input: any, helpers?: any) => any; getContentProps?: (input: any, helpers?: any) => any;
actionButton?: 'file-button' | 'none'; actionButton?: 'file-button' | 'none';
}; };
@@ -27,8 +32,10 @@ export interface ToolDisplayConfig {
hidden?: boolean; hidden?: boolean;
hideOnSuccess?: boolean; hideOnSuccess?: boolean;
type?: 'one-line' | 'collapsible' | 'special'; type?: 'one-line' | 'collapsible' | 'special';
title?: string | ((result: any) => string);
defaultOpen?: boolean;
// Special result handlers // Special result handlers
contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message'; contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task';
getMessage?: (result: any) => string; getMessage?: (result: any) => string;
getContentProps?: (result: any) => any; getContentProps?: (result: any) => any;
}; };
@@ -51,14 +58,14 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
colorScheme: { colorScheme: {
primary: 'text-green-400 font-mono', primary: 'text-green-400 font-mono',
secondary: 'text-gray-400', secondary: 'text-gray-400',
background: 'bg-gray-900 dark:bg-black', background: '',
border: 'border-green-500 dark:border-green-400', border: 'border-green-500 dark:border-green-400',
icon: 'text-green-500 dark:text-green-400' icon: 'text-green-500 dark:text-green-400'
} }
}, },
result: { result: {
hideOnSuccess: true, hideOnSuccess: true,
type: 'special' // Interactive prompts, cat -n output, etc. type: 'special'
} }
}, },
@@ -70,29 +77,35 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
input: { input: {
type: 'one-line', type: 'one-line',
label: 'Read', label: 'Read',
getValue: (input) => input.file_path, getValue: (input) => input.file_path || '',
action: 'open-file', action: 'open-file',
colorScheme: { colorScheme: {
primary: 'text-gray-700 dark:text-gray-300' primary: 'text-gray-700 dark:text-gray-300',
background: '',
border: 'border-gray-300 dark:border-gray-600',
icon: 'text-gray-500 dark:text-gray-400'
} }
}, },
result: { result: {
hidden: true // Read results not displayed hidden: true
} }
}, },
Edit: { Edit: {
input: { input: {
type: 'collapsible', type: 'collapsible',
title: 'View edit diff for', title: (input) => {
const filename = input.file_path?.split('/').pop() || input.file_path || 'file';
return `${filename}`;
},
defaultOpen: false, defaultOpen: false,
contentType: 'diff', contentType: 'diff',
actionButton: 'file-button', actionButton: 'none',
getContentProps: (input) => ({ getContentProps: (input) => ({
oldContent: input.old_string, oldContent: input.old_string,
newContent: input.new_string, newContent: input.new_string,
filePath: input.file_path, filePath: input.file_path,
badge: 'Diff', badge: 'Edit',
badgeColor: 'gray' badgeColor: 'gray'
}) })
}, },
@@ -104,15 +117,18 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
Write: { Write: {
input: { input: {
type: 'collapsible', type: 'collapsible',
title: 'Creating new file', title: (input) => {
const filename = input.file_path?.split('/').pop() || input.file_path || 'file';
return `${filename}`;
},
defaultOpen: false, defaultOpen: false,
contentType: 'diff', contentType: 'diff',
actionButton: 'file-button', actionButton: 'none',
getContentProps: (input) => ({ getContentProps: (input) => ({
oldContent: '', oldContent: '',
newContent: input.content, newContent: input.content,
filePath: input.file_path, filePath: input.file_path,
badge: 'New File', badge: 'New',
badgeColor: 'green' badgeColor: 'green'
}) })
}, },
@@ -124,10 +140,13 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
ApplyPatch: { ApplyPatch: {
input: { input: {
type: 'collapsible', type: 'collapsible',
title: 'View patch diff for', title: (input) => {
const filename = input.file_path?.split('/').pop() || input.file_path || 'file';
return `${filename}`;
},
defaultOpen: false, defaultOpen: false,
contentType: 'diff', contentType: 'diff',
actionButton: 'file-button', actionButton: 'none',
getContentProps: (input) => ({ getContentProps: (input) => ({
oldContent: input.old_string, oldContent: input.old_string,
newContent: input.new_string, newContent: input.new_string,
@@ -154,19 +173,25 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
action: 'jump-to-results', action: 'jump-to-results',
colorScheme: { colorScheme: {
primary: 'text-gray-700 dark:text-gray-300', primary: 'text-gray-700 dark:text-gray-300',
secondary: 'text-gray-500 dark:text-gray-400' secondary: 'text-gray-500 dark:text-gray-400',
background: '',
border: 'border-gray-400 dark:border-gray-500',
icon: 'text-gray-500 dark:text-gray-400'
} }
}, },
result: { result: {
type: 'collapsible', type: 'collapsible',
defaultOpen: false,
title: (result) => {
const toolData = result.toolUseResult || {};
const count = toolData.numFiles || toolData.filenames?.length || 0;
return `Found ${count} ${count === 1 ? 'file' : 'files'}`;
},
contentType: 'file-list', contentType: 'file-list',
getContentProps: (result) => { getContentProps: (result) => {
const toolData = result.toolUseResult || {}; const toolData = result.toolUseResult || {};
return { return {
files: toolData.filenames || [], files: toolData.filenames || []
title: toolData.filenames ?
`Found ${toolData.numFiles || toolData.filenames.length} ${(toolData.numFiles === 1 || toolData.filenames.length === 1) ? 'file' : 'files'}`
: undefined
}; };
} }
} }
@@ -181,19 +206,25 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
action: 'jump-to-results', action: 'jump-to-results',
colorScheme: { colorScheme: {
primary: 'text-gray-700 dark:text-gray-300', primary: 'text-gray-700 dark:text-gray-300',
secondary: 'text-gray-500 dark:text-gray-400' secondary: 'text-gray-500 dark:text-gray-400',
background: '',
border: 'border-gray-400 dark:border-gray-500',
icon: 'text-gray-500 dark:text-gray-400'
} }
}, },
result: { result: {
type: 'collapsible', type: 'collapsible',
defaultOpen: false,
title: (result) => {
const toolData = result.toolUseResult || {};
const count = toolData.numFiles || toolData.filenames?.length || 0;
return `Found ${count} ${count === 1 ? 'file' : 'files'}`;
},
contentType: 'file-list', contentType: 'file-list',
getContentProps: (result) => { getContentProps: (result) => {
const toolData = result.toolUseResult || {}; const toolData = result.toolUseResult || {};
return { return {
files: toolData.filenames || [], files: toolData.filenames || []
title: toolData.filenames ?
`Found ${toolData.numFiles || toolData.filenames.length} ${(toolData.numFiles === 1 || toolData.filenames.length === 1) ? 'file' : 'files'}`
: undefined
}; };
} }
} }
@@ -206,7 +237,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
TodoWrite: { TodoWrite: {
input: { input: {
type: 'collapsible', type: 'collapsible',
title: 'Updating Todo List', title: 'Updating todo list',
defaultOpen: false, defaultOpen: false,
contentType: 'todo-list', contentType: 'todo-list',
getContentProps: (input) => ({ getContentProps: (input) => ({
@@ -216,16 +247,20 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
result: { result: {
type: 'collapsible', type: 'collapsible',
contentType: 'success-message', contentType: 'success-message',
getMessage: () => 'Todo list has been updated successfully' getMessage: () => 'Todo list updated'
} }
}, },
TodoRead: { TodoRead: {
input: { input: {
type: 'one-line', type: 'one-line',
label: 'Read todo list', label: 'TodoRead',
getValue: () => '', getValue: () => 'reading list',
action: 'none' action: 'none',
colorScheme: {
primary: 'text-gray-500 dark:text-gray-400',
border: 'border-violet-400 dark:border-violet-500'
}
}, },
result: { result: {
type: 'collapsible', type: 'collapsible',
@@ -245,6 +280,97 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
} }
}, },
// ============================================================================
// TASK TOOLS (TaskCreate, TaskUpdate, TaskList, TaskGet)
// ============================================================================
TaskCreate: {
input: {
type: 'one-line',
label: 'Task',
getValue: (input) => input.subject || 'Creating task',
getSecondary: (input) => input.status || undefined,
action: 'none',
colorScheme: {
primary: 'text-gray-700 dark:text-gray-300',
border: 'border-violet-400 dark:border-violet-500',
icon: 'text-violet-500 dark:text-violet-400'
}
},
result: {
hideOnSuccess: true
}
},
TaskUpdate: {
input: {
type: 'one-line',
label: 'Task',
getValue: (input) => {
const parts = [];
if (input.taskId) parts.push(`#${input.taskId}`);
if (input.status) parts.push(input.status);
if (input.subject) parts.push(`"${input.subject}"`);
return parts.join(' → ') || 'updating';
},
action: 'none',
colorScheme: {
primary: 'text-gray-700 dark:text-gray-300',
border: 'border-violet-400 dark:border-violet-500',
icon: 'text-violet-500 dark:text-violet-400'
}
},
result: {
hideOnSuccess: true
}
},
TaskList: {
input: {
type: 'one-line',
label: 'Tasks',
getValue: () => 'listing tasks',
action: 'none',
colorScheme: {
primary: 'text-gray-500 dark:text-gray-400',
border: 'border-violet-400 dark:border-violet-500',
icon: 'text-violet-500 dark:text-violet-400'
}
},
result: {
type: 'collapsible',
defaultOpen: true,
title: 'Task list',
contentType: 'task',
getContentProps: (result) => ({
content: String(result.content || '')
})
}
},
TaskGet: {
input: {
type: 'one-line',
label: 'Task',
getValue: (input) => input.taskId ? `#${input.taskId}` : 'fetching',
action: 'none',
colorScheme: {
primary: 'text-gray-700 dark:text-gray-300',
border: 'border-violet-400 dark:border-violet-500',
icon: 'text-violet-500 dark:text-violet-400'
}
},
result: {
type: 'collapsible',
defaultOpen: true,
title: 'Task details',
contentType: 'task',
getContentProps: (result) => ({
content: String(result.content || '')
})
}
},
// ============================================================================ // ============================================================================
// PLAN TOOLS // PLAN TOOLS
// ============================================================================ // ============================================================================
@@ -252,7 +378,37 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
exit_plan_mode: { exit_plan_mode: {
input: { input: {
type: 'collapsible', type: 'collapsible',
title: 'View implementation plan', title: 'Implementation plan',
defaultOpen: true,
contentType: 'markdown',
getContentProps: (input) => ({
content: input.plan?.replace(/\\n/g, '\n') || input.plan
})
},
result: {
type: 'collapsible',
contentType: 'markdown',
getContentProps: (result) => {
try {
let parsed = result.content;
if (typeof parsed === 'string') {
parsed = JSON.parse(parsed);
}
return {
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
};
} catch (e) {
return { content: '' };
}
}
}
},
// Also register as ExitPlanMode (the actual tool name used by Claude)
ExitPlanMode: {
input: {
type: 'collapsible',
title: 'Implementation plan',
defaultOpen: true, defaultOpen: true,
contentType: 'markdown', contentType: 'markdown',
getContentProps: (input) => ({ getContentProps: (input) => ({
@@ -285,7 +441,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
Default: { Default: {
input: { input: {
type: 'collapsible', type: 'collapsible',
title: 'View input parameters', title: 'Parameters',
defaultOpen: false, defaultOpen: false,
contentType: 'text', contentType: 'text',
getContentProps: (input) => ({ getContentProps: (input) => ({