diff --git a/package-lock.json b/package-lock.json index d19c6bb..02a3f4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.7.0", + "version": "0.7.1", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.33", "@hugeicons/core-free-icons": "^3.1.1", diff --git a/package.json b/package.json index 0d853ea..cd350d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.7.0", + "version": "0.7.1", "private": true, "main": "dist-electron/main.js", "scripts": { diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx index f9315d0..2329b5f 100644 --- a/src/components/ai-elements/message.tsx +++ b/src/components/ai-elements/message.tsx @@ -57,7 +57,7 @@ export const MessageContent = ({ className={cn( "is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm", "group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground", - "group-[.is-assistant]:text-foreground", + "group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground", className )} {...props} diff --git a/src/components/ai-elements/tool-actions-group.tsx b/src/components/ai-elements/tool-actions-group.tsx new file mode 100644 index 0000000..00a7a29 --- /dev/null +++ b/src/components/ai-elements/tool-actions-group.tsx @@ -0,0 +1,285 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { IconSvgElement } from "@hugeicons/react"; +import { + File01Icon, + FileEditIcon, + CommandLineIcon, + Search01Icon, + Wrench01Icon, + Loading02Icon, + CheckmarkCircle02Icon, + CancelCircleIcon, +} from "@hugeicons/core-free-icons"; +import { ChevronRightIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ToolAction { + id?: string; + name: string; + input: unknown; + result?: string; + isError?: boolean; +} + +interface ToolActionsGroupProps { + tools: ToolAction[]; + isStreaming?: boolean; + streamingToolOutput?: string; +} + +// --------------------------------------------------------------------------- +// Tool categorisation +// --------------------------------------------------------------------------- + +type ToolCategory = 'read' | 'write' | 'bash' | 'search' | 'other'; + +function getToolCategory(name: string): ToolCategory { + const lower = name.toLowerCase(); + if (lower === 'read' || lower === 'readfile' || lower === 'read_file') return 'read'; + if ( + lower === 'write' || lower === 'edit' || lower === 'writefile' || + lower === 'write_file' || lower === 'create_file' || lower === 'createfile' || + lower === 'notebookedit' || lower === 'notebook_edit' + ) return 'write'; + if ( + lower === 'bash' || lower === 'execute' || lower === 'run' || + lower === 'shell' || lower === 'execute_command' + ) return 'bash'; + if ( + lower === 'search' || lower === 'glob' || lower === 'grep' || + lower === 'find_files' || lower === 'search_files' || + lower === 'websearch' || lower === 'web_search' + ) return 'search'; + return 'other'; +} + +function getToolIcon(category: ToolCategory): IconSvgElement { + switch (category) { + case 'read': return File01Icon; + case 'write': return FileEditIcon; + case 'bash': return CommandLineIcon; + case 'search': return Search01Icon; + case 'other': return Wrench01Icon; + } +} + +// --------------------------------------------------------------------------- +// Summary helpers +// --------------------------------------------------------------------------- + +function extractFilename(path: string): string { + const parts = path.split('/'); + return parts[parts.length - 1] || path; +} + +function getToolSummary(name: string, input: unknown, category: ToolCategory): string { + const inp = input as Record | undefined; + if (!inp) return name; + + switch (category) { + case 'read': + case 'write': { + const path = (inp.file_path || inp.path || inp.filePath || '') as string; + return path ? extractFilename(path) : name; + } + case 'bash': { + const cmd = (inp.command || inp.cmd || '') as string; + if (cmd) return cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd; + return name; + } + case 'search': { + const pattern = (inp.pattern || inp.query || inp.glob || '') as string; + return pattern ? `"${pattern.length > 50 ? pattern.slice(0, 47) + '...' : pattern}"` : name; + } + default: + return name; + } +} + +function getFilePath(input: unknown): string { + const inp = input as Record | undefined; + if (!inp) return ''; + return (inp.file_path || inp.path || inp.filePath || '') as string; +} + +function truncatePath(path: string, maxLen = 50): string { + if (path.length <= maxLen) return path; + return '...' + path.slice(path.length - maxLen + 3); +} + +// --------------------------------------------------------------------------- +// Status indicator — running: gray, completed: green, error: red +// --------------------------------------------------------------------------- + +type ToolStatus = 'running' | 'success' | 'error'; + +function getStatus(tool: ToolAction): ToolStatus { + if (tool.result === undefined) return 'running'; + return tool.isError ? 'error' : 'success'; +} + +function StatusDot({ status }: { status: ToolStatus }) { + switch (status) { + case 'running': + return ( + + ); + case 'success': + return ; + case 'error': + return ; + } +} + +// --------------------------------------------------------------------------- +// Compact row for a single tool action +// --------------------------------------------------------------------------- + +function ToolActionRow({ tool }: { tool: ToolAction }) { + const category = getToolCategory(tool.name); + const icon = getToolIcon(category); + const summary = getToolSummary(tool.name, tool.input, category); + const filePath = getFilePath(tool.input); + const status = getStatus(tool); + + const label = category === 'bash' ? '' : tool.name; + + return ( +
+ + + {label && ( + {label} + )} + + + {summary} + + + {filePath && (category === 'read' || category === 'write') && ( + + {truncatePath(filePath)} + + )} + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Header summary helper — build running task description +// --------------------------------------------------------------------------- + +function getRunningDescription(tools: ToolAction[]): string { + const running = tools.filter((t) => t.result === undefined); + if (running.length === 0) return ''; + const last = running[running.length - 1]; + const category = getToolCategory(last.name); + return getToolSummary(last.name, last.input, category); +} + +// --------------------------------------------------------------------------- +// Main group component +// --------------------------------------------------------------------------- + +export function ToolActionsGroup({ + tools, + isStreaming = false, + streamingToolOutput: _streamingToolOutput, +}: ToolActionsGroupProps) { + const hasRunningTool = tools.some((t) => t.result === undefined); + + const [userToggled, setUserToggled] = useState(false); + const [expanded, setExpanded] = useState(hasRunningTool); + + useEffect(() => { + if (!userToggled) { + setExpanded(hasRunningTool || isStreaming); + } + }, [hasRunningTool, isStreaming, userToggled]); + + if (tools.length === 0) return null; + + const runningCount = tools.filter((t) => t.result === undefined).length; + const doneCount = tools.length - runningCount; + const runningDesc = getRunningDescription(tools); + + const handleToggle = () => { + setUserToggled(true); + setExpanded((prev) => !prev); + }; + + // Build summary text parts + const summaryParts: string[] = []; + if (runningCount > 0) summaryParts.push(`${runningCount} running`); + if (doneCount > 0) summaryParts.push(`${doneCount} completed`); + if (summaryParts.length === 0) summaryParts.push(`${tools.length} actions`); + + return ( +
+ {/* Header — minimal: chevron + count + gray summary */} + + + {/* Expanded list — left vertical line like blockquote */} + + {expanded && ( + + +
+ {tools.map((tool, i) => ( + + ))} +
+
+
+ )} +
+
+ ); +} diff --git a/src/components/chat/MessageItem.tsx b/src/components/chat/MessageItem.tsx index 08de3ea..2fac247 100644 --- a/src/components/chat/MessageItem.tsx +++ b/src/components/chat/MessageItem.tsx @@ -7,15 +7,8 @@ import { MessageContent, MessageResponse, } from '@/components/ai-elements/message'; -import { - Tool, - ToolHeader, - ToolContent, - ToolInput, - ToolOutput, -} from '@/components/ai-elements/tool'; +import { ToolActionsGroup } from '@/components/ai-elements/tool-actions-group'; import { CopyIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; -import type { ToolUIPart } from 'ai'; import { FileAttachmentDisplay } from './FileAttachmentDisplay'; interface MessageItemProps { @@ -149,12 +142,6 @@ function pairTools(tools: ToolBlock[]): Array<{ return paired; } -function getToolState(result?: string, isError?: boolean): ToolUIPart['state'] { - if (result === undefined) return 'input-available'; - if (isError) return 'output-error'; - return 'output-available'; -} - function parseMessageFiles(content: string): { files: FileAttachment[]; text: string } { const match = content.match(/^\n?/); if (!match) return { files: [], text: content }; @@ -261,26 +248,17 @@ export function MessageItem({ message }: MessageItemProps) { )} - {/* Tool calls for assistant messages */} + {/* Tool calls for assistant messages — compact collapsible group */} {!isUser && pairedTools.length > 0 && ( -
- {pairedTools.map((tool, i) => ( - - - - - - - - ))} -
+ ({ + id: `hist-${i}`, + name: tool.name, + input: tool.input, + result: tool.result, + isError: tool.isError, + }))} + /> )} {/* Text content */} diff --git a/src/components/chat/StreamingMessage.tsx b/src/components/chat/StreamingMessage.tsx index ceb8211..b476f26 100644 --- a/src/components/chat/StreamingMessage.tsx +++ b/src/components/chat/StreamingMessage.tsx @@ -6,13 +6,7 @@ import { MessageContent, MessageResponse, } from '@/components/ai-elements/message'; -import { - Tool, - ToolHeader, - ToolContent, - ToolInput, - ToolOutput, -} from '@/components/ai-elements/tool'; +import { ToolActionsGroup } from '@/components/ai-elements/tool-actions-group'; import { Confirmation, ConfirmationTitle, @@ -50,12 +44,6 @@ interface StreamingMessageProps { permissionResolved?: 'allow' | 'deny' | null; } -function getToolState(hasResult: boolean, isError?: boolean): ToolUIPart['state'] { - if (!hasResult) return 'input-available'; - if (isError) return 'output-error'; - return 'output-available'; -} - function ElapsedTimer() { const [elapsed, setElapsed] = useState(0); const startRef = useRef(Date.now()); @@ -149,35 +137,22 @@ export function StreamingMessage({ return ( - {/* Tool calls */} + {/* Tool calls — compact collapsible group */} {toolUses.length > 0 && ( -
- {toolUses.map((tool, index) => { + { const result = toolResults.find((r) => r.tool_use_id === tool.id); - const isRunning = !result; - const isLastRunning = isRunning && index === toolUses.length - 1; - return ( - - - - - {isLastRunning && streamingToolOutput ? ( - - ) : ( - - )} - - - ); + return { + id: tool.id, + name: tool.name, + input: tool.input, + result: result?.content, + isError: result?.is_error, + }; })} -
+ isStreaming={isStreaming} + streamingToolOutput={streamingToolOutput} + /> )} {/* Permission approval confirmation */}