feat: CodePilot - desktop GUI for Claude Code

Full-featured desktop application built with Next.js + Electron:
- Streaming chat with Claude Code via Agent SDK
- Session management with SQLite persistence
- Project file tree and file preview
- MCP server and custom skills management
- Permission controls with Code/Plan/Ask modes
- Dark/light theme, token tracking
- macOS DMG packaging with electron-builder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-06 21:04:05 +08:00
1 parent 6776cc6297
commit ff1fb38735
149 files changed
+31117 -122

No files matched your search

+426
View File
@@ -0,0 +1,426 @@
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import type { Message, SSEEvent, TokenUsage, PermissionRequestEvent } from '@/types';
import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
import { usePanel } from '@/hooks/usePanel';
interface ToolUseInfo {
id: string;
name: string;
input: unknown;
}
interface ToolResultInfo {
tool_use_id: string;
content: string;
}
interface ChatViewProps {
sessionId: string;
initialMessages?: Message[];
modelName?: string;
initialMode?: string;
}
export function ChatView({ sessionId, initialMessages = [], modelName, initialMode }: ChatViewProps) {
const { setStreamingSessionId, workingDirectory, setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel();
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [streamingContent, setStreamingContent] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [toolUses, setToolUses] = useState<ToolUseInfo[]>([]);
const [toolResults, setToolResults] = useState<ToolResultInfo[]>([]);
const [statusText, setStatusText] = useState<string | undefined>();
const [mode, setMode] = useState(initialMode || 'code');
const [currentModel, setCurrentModel] = useState(modelName || 'sonnet');
const [pendingPermission, setPendingPermission] = useState<PermissionRequestEvent | null>(null);
const [permissionResolved, setPermissionResolved] = useState<'allow' | 'deny' | null>(null);
const [streamingToolOutput, setStreamingToolOutput] = useState('');
const handleModeChange = useCallback((newMode: string) => {
setMode(newMode);
// Persist mode to database and notify chat list
if (sessionId) {
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: newMode }),
}).then(() => {
window.dispatchEvent(new CustomEvent('session-updated'));
}).catch(() => { /* silent */ });
}
}, [sessionId]);
const abortControllerRef = useRef<AbortController | null>(null);
const handleWorkingDirectoryChange = useCallback((dir: string) => {
setWorkingDirectory(dir);
setPanelOpen(true);
// Persist to database
if (sessionId) {
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ working_directory: dir }),
}).catch(() => { /* silent */ });
}
}, [sessionId, setWorkingDirectory, setPanelOpen]);
const initializedRef = useRef(false);
useEffect(() => {
if (initialMessages.length > 0 && !initializedRef.current) {
initializedRef.current = true;
setMessages(initialMessages);
}
}, [initialMessages]);
// Sync mode when session data loads
useEffect(() => {
if (initialMode) {
setMode(initialMode);
}
}, [initialMode]);
const stopStreaming = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, []);
const handlePermissionResponse = useCallback(async (decision: 'allow' | 'allow_session' | 'deny') => {
if (!pendingPermission) return;
const body: { permissionRequestId: string; decision: { behavior: 'allow'; updatedPermissions?: unknown[] } | { behavior: 'deny'; message?: string } } = {
permissionRequestId: pendingPermission.permissionRequestId,
decision: decision === 'deny'
? { behavior: 'deny', message: 'User denied permission' }
: {
behavior: 'allow',
...(decision === 'allow_session' && pendingPermission.suggestions
? { updatedPermissions: pendingPermission.suggestions }
: {}),
},
};
setPermissionResolved(decision === 'deny' ? 'deny' : 'allow');
setPendingApprovalSessionId('');
try {
await fetch('/api/chat/permission', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} catch {
// Best effort - the stream will handle timeout
}
// Clear permission state after a short delay so user sees the feedback
setTimeout(() => {
setPendingPermission(null);
setPermissionResolved(null);
}, 1000);
}, [pendingPermission, setPendingApprovalSessionId]);
const sendMessage = useCallback(
async (content: string) => {
if (isStreaming) return;
// Optimistic: add user message to UI immediately
const userMessage: Message = {
id: 'temp-' + Date.now(),
session_id: sessionId,
role: 'user',
content,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, userMessage]);
setIsStreaming(true);
setStreamingSessionId(sessionId);
setStreamingContent('');
setToolUses([]);
setToolResults([]);
setStatusText(undefined);
const controller = new AbortController();
abortControllerRef.current = controller;
let accumulated = '';
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, content, mode, model: currentModel }),
signal: controller.signal,
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || 'Failed to send message');
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No response stream');
const decoder = new TextDecoder();
let tokenUsage: TokenUsage | null = null;
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// Keep the last potentially incomplete line in the buffer
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const event: SSEEvent = JSON.parse(line.slice(6));
switch (event.type) {
case 'text': {
accumulated += event.data;
setStreamingContent(accumulated);
break;
}
case 'tool_use': {
try {
const toolData = JSON.parse(event.data);
// Clear streaming output for new tool
setStreamingToolOutput('');
setToolUses((prev) => {
// Avoid duplicates
if (prev.some((t) => t.id === toolData.id)) return prev;
return [...prev, {
id: toolData.id,
name: toolData.name,
input: toolData.input,
}];
});
} catch {
// skip malformed tool_use data
}
break;
}
case 'tool_result': {
try {
const resultData = JSON.parse(event.data);
setStreamingToolOutput('');
setToolResults((prev) => [...prev, {
tool_use_id: resultData.tool_use_id,
content: resultData.content,
}]);
} catch {
// skip malformed tool_result data
}
break;
}
case 'tool_output': {
// Check if this is a progress heartbeat or real stderr output
try {
const parsed = JSON.parse(event.data);
if (parsed._progress) {
// SDK tool_progress event — update status with elapsed time
setStatusText(`Running ${parsed.tool_name}... (${Math.round(parsed.elapsed_time_seconds)}s)`);
break;
}
} catch {
// Not JSON — it's raw stderr output, fall through
}
// Real-time stderr output from tool execution
setStreamingToolOutput((prev) => {
const next = prev + (prev ? '\n' : '') + event.data;
return next.length > 5000 ? next.slice(-5000) : next;
});
break;
}
case 'status': {
try {
const statusData = JSON.parse(event.data);
if (statusData.session_id) {
// Init event — show briefly then clear so tool status can take over
setStatusText(`Connected (${statusData.model || 'claude'})`);
setTimeout(() => setStatusText(undefined), 2000);
} else if (statusData.notification) {
// Notification from SDK hooks — show as progress
setStatusText(statusData.message || statusData.title || undefined);
} else {
setStatusText(typeof event.data === 'string' ? event.data : undefined);
}
} catch {
setStatusText(event.data || undefined);
}
break;
}
case 'result': {
try {
const resultData = JSON.parse(event.data);
if (resultData.usage) {
tokenUsage = resultData.usage;
}
} catch {
// skip
}
setStatusText(undefined);
break;
}
case 'permission_request': {
try {
const permData: PermissionRequestEvent = JSON.parse(event.data);
setPendingPermission(permData);
setPermissionResolved(null);
setPendingApprovalSessionId(sessionId);
} catch {
// skip malformed permission_request data
}
break;
}
case 'error': {
accumulated += '\n\n**Error:** ' + event.data;
setStreamingContent(accumulated);
break;
}
case 'done': {
// Stream complete
break;
}
}
} catch {
// skip malformed SSE lines
}
}
}
// Add the assistant message to the list
if (accumulated.trim()) {
const assistantMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: accumulated.trim(),
created_at: new Date().toISOString(),
token_usage: tokenUsage ? JSON.stringify(tokenUsage) : null,
};
setMessages((prev) => [...prev, assistantMessage]);
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
// User stopped generation - still add partial content
if (accumulated.trim()) {
const partialMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: accumulated.trim() + '\n\n*(generation stopped)*',
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, partialMessage]);
}
} else {
const errMsg = error instanceof Error ? error.message : 'Unknown error';
const errorMessage: Message = {
id: 'temp-error-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: `**Error:** ${errMsg}`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, errorMessage]);
}
} finally {
setIsStreaming(false);
setStreamingSessionId('');
setStreamingContent('');
setToolUses([]);
setToolResults([]);
setStreamingToolOutput('');
setStatusText(undefined);
setPendingPermission(null);
setPermissionResolved(null);
setPendingApprovalSessionId('');
abortControllerRef.current = null;
}
},
[sessionId, isStreaming, setStreamingSessionId, setPendingApprovalSessionId, mode, currentModel]
);
const handleCommand = useCallback((command: string) => {
switch (command) {
case '/help': {
const helpMessage: Message = {
id: 'cmd-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: `## Available Commands\n\n- **/help** - Show this help message\n- **/clear** - Clear conversation history\n- **/compact** - Compress conversation context\n- **/cost** - Show token usage statistics\n- **/doctor** - Check system health\n- **/init** - Initialize CLAUDE.md\n- **/review** - Start code review\n- **/terminal-setup** - Configure terminal\n\n**Tips:**\n- Type \`@\` to mention files\n- Use Shift+Enter for new line\n- Select a project folder to enable file operations`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages(prev => [...prev, helpMessage]);
break;
}
case '/clear':
setMessages([]);
break;
case '/cost': {
const costMessage: Message = {
id: 'cmd-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: `## Token Usage\n\nToken usage tracking is available after sending messages. Check the token count displayed at the bottom of each assistant response.`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages(prev => [...prev, costMessage]);
break;
}
default:
sendMessage(command);
}
}, [sessionId, sendMessage]);
return (
<div className="flex h-full min-h-0 flex-col">
<MessageList
messages={messages}
streamingContent={streamingContent}
isStreaming={isStreaming}
toolUses={toolUses}
toolResults={toolResults}
streamingToolOutput={streamingToolOutput}
statusText={statusText}
pendingPermission={pendingPermission}
onPermissionResponse={handlePermissionResponse}
permissionResolved={permissionResolved}
/>
<MessageInput
onSend={sendMessage}
onCommand={handleCommand}
onStop={stopStreaming}
disabled={false}
isStreaming={isStreaming}
sessionId={sessionId}
modelName={currentModel}
onModelChange={setCurrentModel}
workingDirectory={workingDirectory}
onWorkingDirectoryChange={handleWorkingDirectoryChange}
mode={mode}
onModeChange={handleModeChange}
/>
</div>
);
}
+263
View File
@@ -0,0 +1,263 @@
'use client';
import { useState, useMemo, useRef } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark, vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import {
Copy01Icon,
Tick01Icon,
ArrowDown01Icon,
ArrowUp01Icon,
SourceCodeIcon,
CommandLineIcon,
CodeIcon,
File01Icon,
HashtagIcon,
} from "@hugeicons/core-free-icons";
import { cn } from '@/lib/utils';
const COLLAPSE_THRESHOLD = 20;
const VISIBLE_LINES = 10;
const TERMINAL_LANGUAGES = new Set(['bash', 'sh', 'shell', 'terminal', 'zsh', 'console']);
function getLanguageIcon(language: string): IconSvgElement {
const lower = language.toLowerCase();
if (TERMINAL_LANGUAGES.has(lower)) return CommandLineIcon;
if (['typescript', 'tsx', 'javascript', 'jsx'].includes(lower)) return CodeIcon;
if (['json', 'yaml', 'yml', 'toml', 'xml'].includes(lower)) return CodeIcon;
if (['python', 'ruby', 'go', 'rust', 'java', 'c', 'cpp'].includes(lower)) return HashtagIcon;
if (['css', 'scss', 'html'].includes(lower)) return File01Icon;
return SourceCodeIcon;
}
interface CodeBlockProps {
code: string;
language?: string;
filename?: string;
showLineNumbers?: boolean;
maxCollapsedLines?: number;
}
export function CodeBlock({
code,
language = 'text',
filename,
showLineNumbers = true,
maxCollapsedLines = VISIBLE_LINES,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const [copiedMarkdown, setCopiedMarkdown] = useState(false);
const [expanded, setExpanded] = useState(false);
const codeContainerRef = useRef<HTMLDivElement>(null);
const [animatingHeight, setAnimatingHeight] = useState<string | undefined>(undefined);
const lines = useMemo(() => code.split('\n'), [code]);
const totalLines = lines.length;
const isCollapsible = totalLines > COLLAPSE_THRESHOLD;
const isTerminal = TERMINAL_LANGUAGES.has(language.toLowerCase());
const displayCode = useMemo(() => {
if (!isCollapsible || expanded) return code;
return lines.slice(0, maxCollapsedLines).join('\n');
}, [code, lines, isCollapsible, expanded, maxCollapsedLines]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard not available
}
};
const handleCopyMarkdown = async () => {
try {
const markdown = `\`\`\`${language}\n${code}\n\`\`\``;
await navigator.clipboard.writeText(markdown);
setCopiedMarkdown(true);
setTimeout(() => setCopiedMarkdown(false), 2000);
} catch {
// clipboard not available
}
};
const handleToggleExpand = () => {
const container = codeContainerRef.current;
if (!container) {
setExpanded(!expanded);
return;
}
const currentHeight = container.scrollHeight;
if (!expanded) {
// Expanding: set current height, then switch to auto after transition
setAnimatingHeight(`${currentHeight}px`);
setExpanded(true);
requestAnimationFrame(() => {
// Measure the full height after content change
const fullHeight = container.scrollHeight;
setAnimatingHeight(`${fullHeight}px`);
setTimeout(() => setAnimatingHeight(undefined), 300);
});
} else {
// Collapsing: set current height, then reduce
setAnimatingHeight(`${currentHeight}px`);
requestAnimationFrame(() => {
const collapsedH = maxCollapsedLines * 1.5 + 1.5;
setAnimatingHeight(`${collapsedH}rem`);
setTimeout(() => {
setExpanded(false);
setAnimatingHeight(undefined);
}, 300);
});
}
};
const languageIconData = getLanguageIcon(language);
const theme = isTerminal ? vscDarkPlus : oneDark;
return (
<div className="relative group not-prose my-3 rounded-lg overflow-hidden border border-zinc-700/50">
{/* Header bar */}
<div className={cn(
"flex items-center justify-between px-4 py-1.5 text-xs",
isTerminal
? "bg-zinc-950 text-zinc-400"
: "bg-zinc-800 dark:bg-zinc-900 text-zinc-400"
)}>
<div className="flex items-center gap-2 min-w-0">
<HugeiconsIcon icon={languageIconData} className={cn(
"h-3.5 w-3.5 shrink-0",
isTerminal ? "text-green-400" : "text-zinc-400",
)} />
{filename && (
<span className="truncate text-zinc-300 font-medium">{filename}</span>
)}
{filename && <span className="text-zinc-600">|</span>}
<span className={cn(
"bg-zinc-700/50 rounded px-1.5 py-0.5",
isTerminal && "text-green-400",
)}>{language.toUpperCase()}</span>
</div>
<div className="flex items-center gap-1 ml-2 shrink-0">
<button
onClick={handleCopy}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50 transition-colors"
title="Copy code"
>
{copied ? (
<>
<HugeiconsIcon icon={Tick01Icon} className="h-3 w-3" />
<span>Copied</span>
</>
) : (
<>
<HugeiconsIcon icon={Copy01Icon} className="h-3 w-3" />
<span>Copy</span>
</>
)}
</button>
<button
onClick={handleCopyMarkdown}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50 transition-colors"
title="Copy as Markdown"
>
{copiedMarkdown ? (
<>
<HugeiconsIcon icon={Tick01Icon} className="h-3 w-3" />
<span>Copied</span>
</>
) : (
<>
<HugeiconsIcon icon={SourceCodeIcon} className="h-3 w-3" />
<span>Markdown</span>
</>
)}
</button>
</div>
</div>
{/* Code area */}
<div
ref={codeContainerRef}
className="relative transition-[max-height] duration-300 ease-in-out overflow-hidden"
style={{
maxHeight: animatingHeight ?? (!isCollapsible || expanded ? undefined : `${maxCollapsedLines * 1.5 + 1.5}rem`),
}}
>
<SyntaxHighlighter
style={theme}
language={language}
PreTag="div"
showLineNumbers={showLineNumbers && !isTerminal}
lineNumberStyle={{
minWidth: '2.5em',
paddingRight: '1em',
color: '#3a3a48',
userSelect: 'none',
}}
customStyle={{
margin: 0,
borderRadius: 0,
fontSize: '0.8125rem',
lineHeight: '1.5',
padding: isTerminal ? '0.75rem 1rem' : '0.75rem 0.5rem',
background: isTerminal ? '#0a0a0a' : undefined,
overflow: 'auto',
}}
wrapLines
>
{expanded ? code : displayCode}
</SyntaxHighlighter>
{/* Gradient overlay for collapsed state */}
{isCollapsible && !expanded && (
<div className={cn(
"absolute bottom-0 left-0 right-0 h-16 pointer-events-none",
isTerminal
? "bg-gradient-to-t from-[#0a0a0a] to-transparent"
: "bg-gradient-to-t from-[#282c34] to-transparent"
)} />
)}
</div>
{/* Expand/Collapse button */}
{isCollapsible && (
<button
onClick={handleToggleExpand}
className={cn(
"flex w-full items-center justify-center gap-1.5 py-1.5 text-xs transition-colors",
isTerminal
? "bg-zinc-950 text-zinc-400 hover:text-zinc-200"
: "bg-zinc-800 dark:bg-zinc-900 text-zinc-400 hover:text-zinc-200"
)}
>
{expanded ? (
<>
<HugeiconsIcon icon={ArrowUp01Icon} className="h-3 w-3" />
<span>Collapse</span>
</>
) : (
<>
<HugeiconsIcon icon={ArrowDown01Icon} className="h-3 w-3" />
<span>Expand all {totalLines} lines</span>
</>
)}
</button>
)}
</div>
);
}
// Inline code component for reuse
export function InlineCode({ children }: { children: React.ReactNode }) {
return (
<code className="rounded bg-muted px-1.5 py-0.5 text-sm font-mono">
{children}
</code>
);
}
+167
View File
@@ -0,0 +1,167 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { HugeiconsIcon } from "@hugeicons/react";
import { Folder01Icon, FolderOpenIcon, ArrowRight01Icon, ArrowUp01Icon } from "@hugeicons/core-free-icons";
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
interface FolderEntry {
name: string;
path: string;
}
interface BrowseResponse {
current: string;
parent: string | null;
directories: FolderEntry[];
}
interface FolderPickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (path: string) => void;
initialPath?: string;
}
export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: FolderPickerProps) {
const [currentDir, setCurrentDir] = useState('');
const [parentDir, setParentDir] = useState<string | null>(null);
const [directories, setDirectories] = useState<FolderEntry[]>([]);
const [loading, setLoading] = useState(false);
const [pathInput, setPathInput] = useState('');
const browse = useCallback(async (dir?: string) => {
setLoading(true);
try {
const url = dir
? `/api/files/browse?dir=${encodeURIComponent(dir)}`
: '/api/files/browse';
const res = await fetch(url);
if (res.ok) {
const data: BrowseResponse = await res.json();
setCurrentDir(data.current);
setParentDir(data.parent);
setDirectories(data.directories);
setPathInput(data.current);
}
} catch {
// silently fail
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
browse(initialPath || undefined);
}
}, [open, initialPath, browse]);
const handleNavigate = (dir: string) => {
browse(dir);
};
const handleGoUp = () => {
if (parentDir) browse(parentDir);
};
const handlePathSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (pathInput.trim()) {
browse(pathInput.trim());
}
};
const handleSelect = () => {
onSelect(currentDir);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Select Project Folder</DialogTitle>
</DialogHeader>
{/* Path input */}
<form onSubmit={handlePathSubmit} className="flex gap-2">
<Input
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
placeholder="/path/to/project"
className="flex-1 font-mono text-sm"
/>
<Button type="submit" variant="outline" size="sm">
Go
</Button>
</form>
{/* Directory browser */}
<div className="rounded-md border border-border">
{/* Current path + go up */}
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-3 py-2">
<Button
variant="ghost"
size="icon-sm"
onClick={handleGoUp}
disabled={!parentDir}
className="shrink-0"
>
<HugeiconsIcon icon={ArrowUp01Icon} className="h-4 w-4" />
</Button>
<span className="truncate text-xs font-mono text-muted-foreground">
{currentDir}
</span>
</div>
{/* Folder list */}
<ScrollArea className="h-64">
{loading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
</div>
) : directories.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No subdirectories
</div>
) : (
<div className="p-1">
{directories.map((dir) => (
<button
key={dir.path}
className="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
onClick={() => handleNavigate(dir.path)}
>
<HugeiconsIcon icon={Folder01Icon} className="h-4 w-4 shrink-0 text-blue-500" />
<span className="truncate">{dir.name}</span>
<HugeiconsIcon icon={ArrowRight01Icon} className="ml-auto h-3 w-3 shrink-0 text-muted-foreground" />
</button>
))}
</div>
)}
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSelect} className="gap-2">
<HugeiconsIcon icon={FolderOpenIcon} className="h-4 w-4" />
Select This Folder
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+513
View File
@@ -0,0 +1,513 @@
'use client';
import { useRef, useState, useCallback, useEffect, type KeyboardEvent, type FormEvent } from 'react';
import { HugeiconsIcon } from "@hugeicons/react";
import {
AtIcon,
DivideSignIcon,
FolderOpenIcon,
Wrench01Icon,
ClipboardIcon,
HelpCircleIcon,
ArrowDown01Icon,
CommandLineIcon,
} from "@hugeicons/core-free-icons";
import { cn } from '@/lib/utils';
import { FolderPicker } from './FolderPicker';
import {
PromptInput,
PromptInputTextarea,
PromptInputFooter,
PromptInputTools,
PromptInputButton,
PromptInputSubmit,
} from '@/components/ai-elements/prompt-input';
import type { ChatStatus } from 'ai';
interface MessageInputProps {
onSend: (content: string) => void;
onCommand?: (command: string) => void;
onStop?: () => void;
disabled?: boolean;
isStreaming?: boolean;
sessionId?: string;
modelName?: string;
onModelChange?: (model: string) => void;
workingDirectory?: string;
onWorkingDirectoryChange?: (dir: string) => void;
mode?: string;
onModeChange?: (mode: string) => void;
}
interface PopoverItem {
label: string;
value: string;
description?: string;
builtIn?: boolean;
}
type PopoverMode = 'file' | 'skill' | null;
const BUILT_IN_COMMANDS: PopoverItem[] = [
{ label: 'help', value: '/help', description: 'Show help information', builtIn: true },
{ label: 'clear', value: '/clear', description: 'Clear conversation', builtIn: true },
{ label: 'compact', value: '/compact', description: 'Compress conversation context', builtIn: true },
{ label: 'cost', value: '/cost', description: 'Show token usage', builtIn: true },
{ label: 'doctor', value: '/doctor', description: 'Check system health', builtIn: true },
{ label: 'init', value: '/init', description: 'Initialize CLAUDE.md', builtIn: true },
{ label: 'review', value: '/review', description: 'Code review', builtIn: true },
{ label: 'terminal-setup', value: '/terminal-setup', description: 'Terminal configuration', builtIn: true },
];
interface ModeOption {
value: string;
label: string;
icon: typeof Wrench01Icon;
description: string;
}
const MODE_OPTIONS: ModeOption[] = [
{ value: 'code', label: 'Code', icon: Wrench01Icon, description: 'Read, write files & run commands' },
{ value: 'plan', label: 'Plan', icon: ClipboardIcon, description: 'Analyze & plan without executing' },
{ value: 'ask', label: 'Ask', icon: HelpCircleIcon, description: 'Answer questions only' },
];
const MODEL_OPTIONS = [
{ value: 'sonnet', label: 'Sonnet 4.5' },
{ value: 'opus', label: 'Opus 4.6' },
{ value: 'haiku', label: 'Haiku 4.5' },
];
export function MessageInput({
onSend,
onCommand,
onStop,
disabled,
isStreaming,
sessionId,
modelName,
onModelChange,
workingDirectory,
onWorkingDirectoryChange,
mode = 'code',
onModeChange,
}: MessageInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const modeMenuRef = useRef<HTMLDivElement>(null);
const modelMenuRef = useRef<HTMLDivElement>(null);
const [popoverMode, setPopoverMode] = useState<PopoverMode>(null);
const [popoverItems, setPopoverItems] = useState<PopoverItem[]>([]);
const [popoverFilter, setPopoverFilter] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const [triggerPos, setTriggerPos] = useState<number | null>(null);
const [folderPickerOpen, setFolderPickerOpen] = useState(false);
const [modeMenuOpen, setModeMenuOpen] = useState(false);
const [modelMenuOpen, setModelMenuOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
// Fetch files for @ mention
const fetchFiles = useCallback(async (filter: string) => {
try {
const params = new URLSearchParams();
if (sessionId) params.set('session_id', sessionId);
if (filter) params.set('q', filter);
const res = await fetch(`/api/files?${params.toString()}`);
if (!res.ok) return [];
const data = await res.json();
const tree = data.tree || [];
const items: PopoverItem[] = [];
function flattenTree(nodes: Array<{ name: string; path: string; type: string; children?: unknown[] }>) {
for (const node of nodes) {
items.push({ label: node.name, value: node.path });
if (node.children) flattenTree(node.children as typeof nodes);
}
}
flattenTree(tree);
return items.slice(0, 20);
} catch {
return [];
}
}, [sessionId]);
// Fetch skills for / command (built-in + API)
const fetchSkills = useCallback(async (filter: string) => {
const builtIn = BUILT_IN_COMMANDS.filter((cmd) =>
cmd.label.toLowerCase().includes(filter.toLowerCase())
);
let apiSkills: PopoverItem[] = [];
try {
const res = await fetch('/api/skills');
if (res.ok) {
const data = await res.json();
const skills = data.skills || [];
apiSkills = skills
.filter((s: { name: string; enabled: boolean }) => s.enabled && s.name.toLowerCase().includes(filter.toLowerCase()))
.map((s: { name: string; description: string }) => ({
label: s.name,
value: `/${s.name}`,
description: s.description,
builtIn: false,
}));
}
} catch {
// API not available - just use built-in commands
}
return [...builtIn, ...apiSkills].slice(0, 20);
}, []);
// Close popover
const closePopover = useCallback(() => {
setPopoverMode(null);
setPopoverItems([]);
setPopoverFilter('');
setSelectedIndex(0);
setTriggerPos(null);
}, []);
// Insert selected item
const insertItem = useCallback((item: PopoverItem) => {
if (triggerPos === null) return;
// Built-in commands: execute immediately
if (item.builtIn && onCommand) {
setInputValue('');
closePopover();
onCommand(item.value);
return;
}
const currentVal = inputValue;
const before = currentVal.slice(0, triggerPos);
const cursorEnd = triggerPos + (popoverMode === 'file' ? popoverFilter.length + 1 : popoverFilter.length + 1);
const after = currentVal.slice(cursorEnd);
const insertText = popoverMode === 'file' ? `@${item.value} ` : `${item.value} `;
setInputValue(before + insertText + after);
closePopover();
// Refocus textarea
setTimeout(() => textareaRef.current?.focus(), 0);
}, [triggerPos, popoverMode, closePopover, onCommand, inputValue, popoverFilter]);
// Handle input changes to detect @ and /
const handleInputChange = useCallback(async (val: string) => {
setInputValue(val);
const textarea = textareaRef.current;
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const beforeCursor = val.slice(0, cursorPos);
// Check for @ trigger
const atMatch = beforeCursor.match(/@([^\s@]*)$/);
if (atMatch) {
const filter = atMatch[1];
setPopoverMode('file');
setPopoverFilter(filter);
setTriggerPos(cursorPos - atMatch[0].length);
setSelectedIndex(0);
const items = await fetchFiles(filter);
setPopoverItems(items);
return;
}
// Check for / trigger (only at start of line or after space)
const slashMatch = beforeCursor.match(/(^|\s)\/([^\s]*)$/);
if (slashMatch) {
const filter = slashMatch[2];
setPopoverMode('skill');
setPopoverFilter(filter);
setTriggerPos(cursorPos - slashMatch[2].length - 1);
setSelectedIndex(0);
const items = await fetchSkills(filter);
setPopoverItems(items);
return;
}
if (popoverMode) {
closePopover();
}
}, [fetchFiles, fetchSkills, popoverMode, closePopover]);
const handleSubmit = useCallback((_msg: { text: string; files: unknown[] }, e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const content = inputValue.trim();
if (!content || disabled) return;
closePopover();
// Check if it's a built-in command
const builtInCmd = BUILT_IN_COMMANDS.find(cmd => cmd.value === content);
if (builtInCmd && onCommand) {
onCommand(content);
} else {
onSend(content);
}
setInputValue('');
}, [inputValue, onSend, onCommand, disabled, closePopover]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
// Popover navigation
if (popoverMode && popoverItems.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % filteredItems.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + filteredItems.length) % filteredItems.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
if (filteredItems[selectedIndex]) {
insertItem(filteredItems[selectedIndex]);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
closePopover();
return;
}
}
},
[popoverMode, popoverItems, selectedIndex, insertItem, closePopover]
);
// Click outside to close popover
useEffect(() => {
if (!popoverMode) return;
const handler = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
closePopover();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [popoverMode, closePopover]);
// Click outside to close mode menu
useEffect(() => {
if (!modeMenuOpen) return;
const handler = (e: MouseEvent) => {
if (modeMenuRef.current && !modeMenuRef.current.contains(e.target as Node)) {
setModeMenuOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [modeMenuOpen]);
// Click outside to close model menu
useEffect(() => {
if (!modelMenuOpen) return;
const handler = (e: MouseEvent) => {
if (modelMenuRef.current && !modelMenuRef.current.contains(e.target as Node)) {
setModelMenuOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [modelMenuOpen]);
const filteredItems = popoverItems.filter((item) =>
item.label.toLowerCase().includes(popoverFilter.toLowerCase())
);
const currentModelValue = modelName || 'sonnet';
const currentModelOption = MODEL_OPTIONS.find((m) => m.value === currentModelValue) || MODEL_OPTIONS[0];
const currentMode = MODE_OPTIONS.find((m) => m.value === mode) || MODE_OPTIONS[0];
const folderShortName = workingDirectory
? workingDirectory.split('/').filter(Boolean).pop() || workingDirectory
: '';
// Map isStreaming to ChatStatus for PromptInputSubmit
const chatStatus: ChatStatus = isStreaming ? 'streaming' : 'ready';
return (
<div className="bg-background/80 backdrop-blur-lg px-4 py-3">
<div className="mx-auto">
<div className="relative">
{/* Popover */}
{popoverMode && filteredItems.length > 0 && (
<div
ref={popoverRef}
className="absolute bottom-full left-0 right-0 mb-2 rounded-xl border bg-popover shadow-lg overflow-hidden z-50"
>
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b">
{popoverMode === 'file' ? 'Files' : 'Commands'}
</div>
<div className="max-h-48 overflow-y-auto py-1">
{filteredItems.map((item, i) => (
<button
key={item.value}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors",
i === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)}
onClick={() => insertItem(item)}
onMouseEnter={() => setSelectedIndex(i)}
>
{popoverMode === 'file' ? (
<HugeiconsIcon icon={AtIcon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : item.builtIn ? (
<HugeiconsIcon icon={CommandLineIcon} className="h-3.5 w-3.5 shrink-0 text-blue-400" />
) : (
<HugeiconsIcon icon={DivideSignIcon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="font-mono text-xs truncate">{item.label}</span>
{item.builtIn && (
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-500/10 text-blue-400 font-medium shrink-0">
Built-in
</span>
)}
{item.description && (
<span className="ml-auto text-xs text-muted-foreground truncate max-w-[200px]">
{item.description}
</span>
)}
</button>
))}
</div>
</div>
)}
{/* PromptInput replaces the old input area */}
<PromptInput
onSubmit={handleSubmit}
>
<PromptInputTextarea
ref={textareaRef}
placeholder="Message Claude..."
value={inputValue}
onChange={(e) => handleInputChange(e.currentTarget.value)}
onKeyDown={handleKeyDown}
disabled={disabled || isStreaming}
className="min-h-10"
/>
<PromptInputFooter>
<PromptInputTools>
{/* Folder picker button */}
<PromptInputButton
onClick={() => setFolderPickerOpen(true)}
tooltip={workingDirectory || 'Select project folder'}
>
<HugeiconsIcon icon={FolderOpenIcon} className="h-3.5 w-3.5" />
<span className="max-w-[120px] truncate text-xs">
{folderShortName || 'Folder'}
</span>
</PromptInputButton>
{/* Mode selector */}
<div className="relative" ref={modeMenuRef}>
<PromptInputButton
onClick={() => setModeMenuOpen((prev) => !prev)}
>
<HugeiconsIcon icon={currentMode.icon} className="h-3.5 w-3.5" />
<span className="text-xs">{currentMode.label}</span>
<HugeiconsIcon icon={ArrowDown01Icon} className="h-2.5 w-2.5" />
</PromptInputButton>
{/* Mode dropdown */}
{modeMenuOpen && (
<div className="absolute bottom-full left-0 mb-1.5 w-56 rounded-lg border bg-popover shadow-lg overflow-hidden z-50">
<div className="py-1">
{MODE_OPTIONS.map((opt) => {
const isActive = opt.value === mode;
return (
<button
key={opt.value}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)}
onClick={() => {
onModeChange?.(opt.value);
setModeMenuOpen(false);
}}
>
<HugeiconsIcon icon={opt.icon} className="h-4 w-4 shrink-0" />
<div className="flex flex-col min-w-0">
<span className="font-medium text-xs">{opt.label}</span>
<span className="text-[10px] text-muted-foreground truncate">
{opt.description}
</span>
</div>
</button>
);
})}
</div>
</div>
)}
</div>
</PromptInputTools>
<div className="flex items-center gap-1.5">
{/* Model selector */}
<div className="relative" ref={modelMenuRef}>
<PromptInputButton
onClick={() => setModelMenuOpen((prev) => !prev)}
>
<span className="text-xs font-mono">{currentModelOption.label}</span>
<HugeiconsIcon icon={ArrowDown01Icon} className="h-2.5 w-2.5" />
</PromptInputButton>
{modelMenuOpen && (
<div className="absolute bottom-full right-0 mb-1.5 w-48 rounded-lg border bg-popover shadow-lg overflow-hidden z-50">
<div className="py-1">
{MODEL_OPTIONS.map((opt) => {
const isActive = opt.value === currentModelValue;
return (
<button
key={opt.value}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)}
onClick={() => {
onModelChange?.(opt.value);
setModelMenuOpen(false);
}}
>
<span className="font-mono text-xs">{opt.label}</span>
</button>
);
})}
</div>
</div>
)}
</div>
<PromptInputSubmit
status={chatStatus}
onStop={onStop}
disabled={disabled || (!isStreaming && !inputValue.trim())}
/>
</div>
</PromptInputFooter>
</PromptInput>
</div>
</div>
{/* FolderPicker dialog */}
<FolderPicker
open={folderPickerOpen}
onOpenChange={setFolderPickerOpen}
onSelect={(dir) => {
onWorkingDirectoryChange?.(dir);
}}
initialPath={workingDirectory || undefined}
/>
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
'use client';
import type { Message, TokenUsage } from '@/types';
import {
Message as AIMessage,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
Tool,
ToolHeader,
ToolContent,
ToolInput,
ToolOutput,
} from '@/components/ai-elements/tool';
import type { ToolUIPart } from 'ai';
interface MessageItemProps {
message: Message;
}
interface ToolBlock {
type: 'tool_use' | 'tool_result';
id?: string;
name?: string;
input?: unknown;
content?: string;
is_error?: boolean;
}
function parseToolBlocks(content: string): { text: string; tools: ToolBlock[] } {
const tools: ToolBlock[] = [];
let text = content;
const toolUseRegex = /<!--tool_use:([\s\S]*?)-->/g;
let match;
while ((match = toolUseRegex.exec(content)) !== null) {
try {
const parsed = JSON.parse(match[1]);
tools.push({ type: 'tool_use', ...parsed });
} catch {
// skip malformed
}
text = text.replace(match[0], '');
}
const toolResultRegex = /<!--tool_result:([\s\S]*?)-->/g;
while ((match = toolResultRegex.exec(content)) !== null) {
try {
const parsed = JSON.parse(match[1]);
tools.push({ type: 'tool_result', ...parsed });
} catch {
// skip malformed
}
text = text.replace(match[0], '');
}
return { text: text.trim(), tools };
}
function pairTools(tools: ToolBlock[]): Array<{
name: string;
input: unknown;
result?: string;
isError?: boolean;
}> {
const paired: Array<{
name: string;
input: unknown;
result?: string;
isError?: boolean;
}> = [];
const resultMap = new Map<string, ToolBlock>();
for (const t of tools) {
if (t.type === 'tool_result' && t.id) {
resultMap.set(t.id, t);
}
}
for (const t of tools) {
if (t.type === 'tool_use' && t.name) {
const result = t.id ? resultMap.get(t.id) : undefined;
paired.push({
name: t.name,
input: t.input,
result: result?.content,
isError: result?.is_error,
});
}
}
for (const t of tools) {
if (t.type === 'tool_result' && !tools.some(u => u.type === 'tool_use' && u.id === t.id)) {
paired.push({
name: 'tool_result',
input: {},
result: t.content,
isError: t.is_error,
});
}
}
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 TokenUsageDisplay({ usage }: { usage: TokenUsage }) {
const totalTokens = usage.input_tokens + usage.output_tokens;
const costStr = usage.cost_usd !== undefined && usage.cost_usd !== null
? ` · $${usage.cost_usd.toFixed(4)}`
: '';
return (
<span className="group/tokens relative cursor-default text-xs text-muted-foreground/50">
<span>{totalTokens.toLocaleString()} tokens{costStr}</span>
<span className="pointer-events-none absolute bottom-full left-0 mb-1.5 whitespace-nowrap rounded-md bg-popover px-2.5 py-1.5 text-[11px] text-popover-foreground shadow-md border border-border/50 opacity-0 group-hover/tokens:opacity-100 transition-opacity duration-150 z-50">
In: {usage.input_tokens.toLocaleString()} · Out: {usage.output_tokens.toLocaleString()}
{usage.cache_read_input_tokens ? ` · Cache: ${usage.cache_read_input_tokens.toLocaleString()}` : ''}
{costStr}
</span>
</span>
);
}
export function MessageItem({ message }: MessageItemProps) {
const isUser = message.role === 'user';
const { text, tools } = parseToolBlocks(message.content);
const pairedTools = pairTools(tools);
let tokenUsage: TokenUsage | null = null;
if (message.token_usage) {
try {
tokenUsage = JSON.parse(message.token_usage);
} catch {
// skip
}
}
const timestamp = new Date(message.created_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
return (
<AIMessage from={isUser ? 'user' : 'assistant'}>
<MessageContent>
{/* Tool calls for assistant messages */}
{!isUser && pairedTools.length > 0 && (
<div className="space-y-2 w-full">
{pairedTools.map((tool, i) => (
<Tool key={`tool-${i}`}>
<ToolHeader
type="tool-invocation"
title={tool.name}
state={getToolState(tool.result, tool.isError)}
/>
<ToolContent>
<ToolInput input={tool.input} />
<ToolOutput
output={tool.result}
errorText={tool.isError ? tool.result : undefined}
/>
</ToolContent>
</Tool>
))}
</div>
)}
{/* Text content */}
{text && (
isUser ? (
<div className="text-sm whitespace-pre-wrap break-words">{text}</div>
) : (
<MessageResponse>{text}</MessageResponse>
)
)}
</MessageContent>
{/* Footer with timestamp and token usage - outside MessageContent to avoid overflow-hidden clipping */}
{!isUser && (
<div className="flex items-center gap-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<span className="text-xs text-muted-foreground/50">{timestamp}</span>
{tokenUsage && <TokenUsageDisplay usage={tokenUsage} />}
</div>
)}
</AIMessage>
);
}
+92
View File
@@ -0,0 +1,92 @@
'use client';
import type { Message, PermissionRequestEvent } from '@/types';
import {
Conversation,
ConversationContent,
ConversationScrollButton,
ConversationEmptyState,
} from '@/components/ai-elements/conversation';
import { MessageItem } from './MessageItem';
import { StreamingMessage } from './StreamingMessage';
import { HugeiconsIcon } from "@hugeicons/react";
import { BotIcon } from "@hugeicons/core-free-icons";
interface ToolUseInfo {
id: string;
name: string;
input: unknown;
}
interface ToolResultInfo {
tool_use_id: string;
content: string;
is_error?: boolean;
}
interface MessageListProps {
messages: Message[];
streamingContent: string;
isStreaming: boolean;
toolUses?: ToolUseInfo[];
toolResults?: ToolResultInfo[];
streamingToolOutput?: string;
statusText?: string;
pendingPermission?: PermissionRequestEvent | null;
onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny') => void;
permissionResolved?: 'allow' | 'deny' | null;
}
export function MessageList({
messages,
streamingContent,
isStreaming,
toolUses = [],
toolResults = [],
streamingToolOutput,
statusText,
pendingPermission,
onPermissionResponse,
permissionResolved,
}: MessageListProps) {
if (messages.length === 0 && !isStreaming) {
return (
<div className="flex flex-1 items-center justify-center">
<ConversationEmptyState
title="Claude Chat"
description="Start a conversation with Claude. Ask questions, get help with code, or explore ideas."
icon={
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-violet-500/20 to-purple-600/20">
<HugeiconsIcon icon={BotIcon} className="h-8 w-8 text-violet-500" />
</div>
}
/>
</div>
);
}
return (
<Conversation>
<ConversationContent className="mx-auto max-w-3xl px-4 py-6 gap-6">
{messages.map((message) => (
<MessageItem key={message.id} message={message} />
))}
{isStreaming && (
<StreamingMessage
content={streamingContent}
isStreaming={isStreaming}
toolUses={toolUses}
toolResults={toolResults}
streamingToolOutput={streamingToolOutput}
statusText={statusText}
pendingPermission={pendingPermission}
onPermissionResponse={onPermissionResponse}
permissionResolved={permissionResolved}
/>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
);
}
+258
View File
@@ -0,0 +1,258 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import {
Message as AIMessage,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
Tool,
ToolHeader,
ToolContent,
ToolInput,
ToolOutput,
} from '@/components/ai-elements/tool';
import {
Confirmation,
ConfirmationTitle,
ConfirmationRequest,
ConfirmationAccepted,
ConfirmationRejected,
ConfirmationActions,
ConfirmationAction,
} from '@/components/ai-elements/confirmation';
import { Shimmer } from '@/components/ai-elements/shimmer';
import type { ToolUIPart } from 'ai';
import type { PermissionRequestEvent } from '@/types';
interface ToolUseInfo {
id: string;
name: string;
input: unknown;
}
interface ToolResultInfo {
tool_use_id: string;
content: string;
is_error?: boolean;
}
interface StreamingMessageProps {
content: string;
isStreaming: boolean;
toolUses?: ToolUseInfo[];
toolResults?: ToolResultInfo[];
streamingToolOutput?: string;
statusText?: string;
pendingPermission?: PermissionRequestEvent | null;
onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny') => void;
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());
useEffect(() => {
const interval = setInterval(() => {
setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
}, 1000);
return () => clearInterval(interval);
}, []);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
return (
<span className="tabular-nums">
{mins > 0 ? `${mins}m ${secs}s` : `${secs}s`}
</span>
);
}
function StreamingStatusBar({ statusText }: { statusText?: string }) {
const displayText = statusText || 'Thinking';
return (
<div className="flex items-center gap-3 py-2 px-1 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Shimmer duration={1.5}>{displayText}</Shimmer>
</div>
<span className="text-muted-foreground/50">|</span>
<ElapsedTimer />
</div>
);
}
export function StreamingMessage({
content,
isStreaming,
toolUses = [],
toolResults = [],
streamingToolOutput,
statusText,
pendingPermission,
onPermissionResponse,
permissionResolved,
}: StreamingMessageProps) {
const runningTools = toolUses.filter(
(tool) => !toolResults.some((r) => r.tool_use_id === tool.id)
);
// Determine confirmation state for the AI Elements component
const getConfirmationState = (): ToolUIPart['state'] => {
if (permissionResolved) return 'approval-responded';
if (pendingPermission) return 'approval-requested';
return 'input-available';
};
const getApproval = () => {
if (!pendingPermission && !permissionResolved) return undefined;
if (permissionResolved === 'allow') {
return { id: pendingPermission?.permissionRequestId || '', approved: true as const };
}
if (permissionResolved === 'deny') {
return { id: pendingPermission?.permissionRequestId || '', approved: false as const };
}
// Pending - no decision yet
return { id: pendingPermission?.permissionRequestId || '' };
};
const formatToolInput = (input: Record<string, unknown>): string => {
if (input.command) return String(input.command);
if (input.file_path) return String(input.file_path);
if (input.path) return String(input.path);
return JSON.stringify(input, null, 2);
};
// Extract a human-readable summary of the running command
const getRunningCommandSummary = (): string | undefined => {
if (runningTools.length === 0) return undefined;
const tool = runningTools[runningTools.length - 1];
const input = tool.input as Record<string, unknown>;
if (tool.name === 'Bash' && input.command) {
const cmd = String(input.command);
return cmd.length > 80 ? cmd.slice(0, 80) + '...' : cmd;
}
if (input.file_path) return `${tool.name}: ${String(input.file_path)}`;
if (input.path) return `${tool.name}: ${String(input.path)}`;
return `Running ${tool.name}...`;
};
return (
<AIMessage from="assistant">
<MessageContent>
{/* Tool calls */}
{toolUses.length > 0 && (
<div className="space-y-2 w-full">
{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 (
<Tool key={tool.id} defaultOpen={isRunning || !!result}>
<ToolHeader
type="tool-invocation"
title={tool.name}
state={getToolState(!!result, result?.is_error)}
/>
<ToolContent>
<ToolInput input={tool.input} />
{isLastRunning && streamingToolOutput ? (
<ToolOutput output={streamingToolOutput} errorText={undefined} />
) : (
<ToolOutput
output={result?.content}
errorText={result?.is_error ? result.content : undefined}
/>
)}
</ToolContent>
</Tool>
);
})}
</div>
)}
{/* Permission approval confirmation */}
{(pendingPermission || permissionResolved) && (
<Confirmation
approval={getApproval()}
state={getConfirmationState()}
>
<ConfirmationTitle>
<span className="font-medium">{pendingPermission?.toolName}</span>
{pendingPermission?.decisionReason && (
<span className="text-muted-foreground ml-2">
— {pendingPermission.decisionReason}
</span>
)}
</ConfirmationTitle>
{pendingPermission && (
<div className="mt-1 rounded bg-muted/50 px-3 py-2 font-mono text-xs">
{formatToolInput(pendingPermission.toolInput)}
</div>
)}
<ConfirmationRequest>
<ConfirmationActions>
<ConfirmationAction
variant="outline"
onClick={() => onPermissionResponse?.('deny')}
>
Deny
</ConfirmationAction>
<ConfirmationAction
variant="outline"
onClick={() => onPermissionResponse?.('allow')}
>
Allow Once
</ConfirmationAction>
{pendingPermission?.suggestions && pendingPermission.suggestions.length > 0 && (
<ConfirmationAction
variant="default"
onClick={() => onPermissionResponse?.('allow_session')}
>
Allow for Session
</ConfirmationAction>
)}
</ConfirmationActions>
</ConfirmationRequest>
<ConfirmationAccepted>
<p className="text-xs text-green-600 dark:text-green-400">Allowed</p>
</ConfirmationAccepted>
<ConfirmationRejected>
<p className="text-xs text-red-600 dark:text-red-400">Denied</p>
</ConfirmationRejected>
</Confirmation>
)}
{/* Streaming text content rendered via Streamdown */}
{content && (
<MessageResponse>{content}</MessageResponse>
)}
{/* Loading indicator when no content yet */}
{isStreaming && !content && toolUses.length === 0 && !pendingPermission && (
<div className="py-2">
<Shimmer>Thinking...</Shimmer>
</div>
)}
{/* Status bar during streaming */}
{isStreaming && !pendingPermission && <StreamingStatusBar statusText={
statusText || getRunningCommandSummary()
} />}
</MessageContent>
</AIMessage>
);
}
+358
View File
@@ -0,0 +1,358 @@
'use client';
import { useState } from 'react';
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import {
File01Icon,
FileEditIcon,
CommandLineIcon,
Search01Icon,
Wrench01Icon,
ArrowDown01Icon,
ArrowRight01Icon,
Loading02Icon,
CheckmarkCircle02Icon,
CancelCircleIcon,
} from "@hugeicons/core-free-icons";
import { cn } from '@/lib/utils';
import { CodeBlock } from './CodeBlock';
type ToolStatus = 'running' | 'success' | 'error';
interface ToolCallBlockProps {
name: string;
input: unknown;
result?: string;
isError?: boolean;
status?: ToolStatus;
duration?: number;
}
// Classify tools by name
function getToolCategory(name: string): 'read' | 'write' | 'bash' | 'search' | 'other' {
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: ReturnType<typeof getToolCategory>): IconSvgElement {
switch (category) {
case 'read': return File01Icon;
case 'write': return FileEditIcon;
case 'bash': return CommandLineIcon;
case 'search': return Search01Icon;
case 'other': return Wrench01Icon;
}
}
function getToolSummary(name: string, input: unknown, category: ReturnType<typeof getToolCategory>): string {
const inp = input as Record<string, unknown> | undefined;
if (!inp) return name;
switch (category) {
case 'read': {
const path = (inp.file_path || inp.path || inp.filePath || '') as string;
return path ? extractFilename(path) : name;
}
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) {
const truncated = cmd.length > 80 ? cmd.slice(0, 77) + '...' : cmd;
return truncated;
}
return name;
}
case 'search': {
const pattern = (inp.pattern || inp.query || inp.glob || '') as string;
return pattern ? `"${pattern}"` : name;
}
default:
return name;
}
}
function extractFilename(path: string): string {
const parts = path.split('/');
return parts[parts.length - 1] || path;
}
function getFilePath(input: unknown): string {
const inp = input as Record<string, unknown> | undefined;
if (!inp) return '';
return (inp.file_path || inp.path || inp.filePath || '') as string;
}
function StatusIndicator({ status }: { status: ToolStatus }) {
switch (status) {
case 'running':
return (
<span className="relative flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-30" />
<HugeiconsIcon icon={Loading02Icon} className="relative h-3.5 w-3.5 animate-spin text-blue-500" />
</span>
);
case 'success':
return <HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-3.5 w-3.5 text-green-500" />;
case 'error':
return <HugeiconsIcon icon={CancelCircleIcon} className="h-3.5 w-3.5 text-red-500" />;
}
}
// Detect simple diff in Write/Edit tools (old_string/new_string)
function renderDiff(input: unknown): React.ReactNode | null {
const inp = input as Record<string, unknown> | undefined;
if (!inp) return null;
const oldStr = (inp.old_string ?? inp.oldString ?? '') as string;
const newStr = (inp.new_string ?? inp.newString ?? '') as string;
if (!oldStr && !newStr) return null;
const oldLines = oldStr ? oldStr.split('\n') : [];
const newLines = newStr ? newStr.split('\n') : [];
return (
<div className="my-2 rounded-md border border-zinc-700/50 overflow-hidden text-xs font-mono">
{oldLines.length > 0 && oldLines.map((line, i) => (
<div key={`old-${i}`} className="flex bg-red-950/30 text-red-300">
<span className="select-none w-8 text-right pr-2 text-red-400/60 shrink-0">-</span>
<span className="px-2 whitespace-pre-wrap break-all">{line}</span>
</div>
))}
{newLines.length > 0 && newLines.map((line, i) => (
<div key={`new-${i}`} className="flex bg-green-950/30 text-green-300">
<span className="select-none w-8 text-right pr-2 text-green-400/60 shrink-0">+</span>
<span className="px-2 whitespace-pre-wrap break-all">{line}</span>
</div>
))}
</div>
);
}
export function ToolCallBlock({
name,
input,
result,
isError,
status = result !== undefined ? (isError ? 'error' : 'success') : 'running',
duration,
}: ToolCallBlockProps) {
const [expanded, setExpanded] = useState(false);
const category = getToolCategory(name);
const toolIconData = getToolIcon(category);
const summary = getToolSummary(name, input, category);
const filePath = getFilePath(input);
const renderContent = () => {
switch (category) {
case 'read': {
return (
<div className="space-y-2">
{filePath && (
<div className="text-xs text-muted-foreground font-mono px-1">{filePath}</div>
)}
{result && (
<CodeBlock
code={result.slice(0, 5000)}
language={guessLanguageFromPath(filePath)}
showLineNumbers={true}
/>
)}
{!result && status === 'running' && (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<HugeiconsIcon icon={Loading02Icon} className="h-3 w-3 animate-spin" />
Reading file...
</div>
)}
</div>
);
}
case 'write': {
const diff = renderDiff(input);
const inp = input as Record<string, unknown> | undefined;
const content = (inp?.content || inp?.new_source || inp?.new_string || '') as string;
return (
<div className="space-y-2">
{filePath && (
<div className="text-xs text-muted-foreground font-mono px-1">{filePath}</div>
)}
{diff}
{!diff && content && (
<CodeBlock
code={content.slice(0, 5000)}
language={guessLanguageFromPath(filePath)}
showLineNumbers={true}
/>
)}
{result && (
<div className="text-xs text-muted-foreground px-1 mt-1">{result.slice(0, 500)}</div>
)}
</div>
);
}
case 'bash': {
const inp = input as Record<string, unknown> | undefined;
const command = (inp?.command || inp?.cmd || '') as string;
return (
<div className="space-y-2">
{command && (
<div className="rounded-md bg-black p-3 font-mono text-xs text-zinc-100 overflow-x-auto">
<span className="text-green-400 select-none">$ </span>
<span className="whitespace-pre-wrap break-all">{command}</span>
</div>
)}
{result && (
<div className="rounded-md bg-zinc-950 p-3 font-mono text-xs text-zinc-300 max-h-60 overflow-auto whitespace-pre-wrap break-all">
{result.slice(0, 5000)}
</div>
)}
{!result && status === 'running' && (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<HugeiconsIcon icon={Loading02Icon} className="h-3 w-3 animate-spin" />
Executing...
</div>
)}
</div>
);
}
case 'search': {
const inp = input as Record<string, unknown> | undefined;
const pattern = (inp?.pattern || inp?.query || inp?.glob || '') as string;
return (
<div className="space-y-2">
{pattern && (
<div className="text-xs font-mono text-muted-foreground px-1">
Pattern: <span className="text-foreground">{pattern}</span>
</div>
)}
{result && (
<div className="rounded-md bg-muted/50 p-2 font-mono text-xs max-h-60 overflow-auto">
{result.split('\n').slice(0, 50).map((line, i) => (
<div key={i} className="py-0.5 text-muted-foreground hover:text-foreground transition-colors">
{line}
</div>
))}
{result.split('\n').length > 50 && (
<div className="pt-1 text-zinc-500">... and {result.split('\n').length - 50} more lines</div>
)}
</div>
)}
</div>
);
}
default: {
return (
<div className="space-y-2">
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Input</div>
<pre className="overflow-x-auto whitespace-pre-wrap text-xs font-mono text-muted-foreground bg-muted/50 rounded p-2">
{JSON.stringify(input, null, 2)}
</pre>
</div>
{result && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Output</div>
<pre className="overflow-x-auto whitespace-pre-wrap text-xs font-mono text-muted-foreground bg-muted/50 rounded p-2 max-h-60 overflow-auto">
{result.slice(0, 3000)}
</pre>
</div>
)}
</div>
);
}
}
};
const statusBorderColor = {
running: 'border-blue-500/70',
success: 'border-green-500/50',
error: 'border-red-500/60',
}[status];
const statusBgColor = {
running: 'bg-blue-500/[0.03] dark:bg-blue-500/[0.05]',
success: 'bg-transparent',
error: 'bg-red-500/[0.03] dark:bg-red-500/[0.05]',
}[status];
return (
<div className={cn("my-0.5 border-l-2 rounded-r-md overflow-hidden transition-colors duration-300", statusBorderColor, statusBgColor)}>
<button
onClick={() => setExpanded(!expanded)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1 text-left text-sm hover:bg-muted/30 transition-colors",
expanded && "border-b border-border/30"
)}
>
{expanded ? (
<HugeiconsIcon icon={ArrowDown01Icon} className="h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<HugeiconsIcon icon={ArrowRight01Icon} className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
<HugeiconsIcon icon={toolIconData} className={cn(
"h-3.5 w-3.5 shrink-0",
category === 'read' && "text-blue-500",
category === 'write' && "text-amber-500",
category === 'bash' && "text-green-500",
category === 'search' && "text-purple-500",
category === 'other' && "text-zinc-500",
)} />
<span className="font-mono text-xs truncate flex-1 text-foreground/80">{summary}</span>
<div className="flex items-center gap-2 shrink-0 ml-2">
{duration !== undefined && (
<span className="text-xs text-muted-foreground">{formatDuration(duration)}</span>
)}
<StatusIndicator status={status} />
</div>
</button>
<div className={cn(
"grid transition-[grid-template-rows] duration-200 ease-in-out",
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}>
<div className="overflow-hidden">
<div className="px-3 py-2">
{renderContent()}
</div>
</div>
</div>
</div>
);
}
function guessLanguageFromPath(path: string): string {
if (!path) return 'text';
const ext = path.split('.').pop()?.toLowerCase() || '';
const map: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
py: 'python', rb: 'ruby', go: 'go', rs: 'rust',
java: 'java', kt: 'kotlin', swift: 'swift',
css: 'css', scss: 'scss', html: 'html',
json: 'json', yaml: 'yaml', yml: 'yaml',
md: 'markdown', sql: 'sql', sh: 'bash',
toml: 'toml', xml: 'xml', c: 'c', cpp: 'cpp', h: 'c',
};
return map[ext] || 'text';
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}