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:
1 parent
6776cc6297
commit
ff1fb38735
149 files changed
+31117
-122
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in new issue
Block a user