'use client'; import { useRef, useState, useCallback, useEffect, type KeyboardEvent, type FormEvent } from 'react'; import { HugeiconsIcon } from "@hugeicons/react"; import { AtIcon, FolderOpenIcon, Wrench01Icon, ClipboardIcon, HelpCircleIcon, ArrowDown01Icon, ArrowUp02Icon, CommandLineIcon, Attachment01Icon, Cancel01Icon, Delete02Icon, Coins01Icon, FileZipIcon, Stethoscope02Icon, FileEditIcon, SearchList01Icon, BrainIcon, GlobalIcon, } from "@hugeicons/core-free-icons"; import { cn } from '@/lib/utils'; import { FolderPicker } from './FolderPicker'; import { PromptInput, PromptInputTextarea, PromptInputFooter, PromptInputTools, PromptInputButton, PromptInputSubmit, usePromptInputAttachments, } from '@/components/ai-elements/prompt-input'; import { SquareIcon } from 'lucide-react'; import type { ChatStatus } from 'ai'; import type { FileAttachment } from '@/types'; import { nanoid } from 'nanoid'; // Accepted file types for upload const ACCEPTED_FILE_TYPES = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/*', '.md', '.json', '.csv', '.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs', ].join(','); // Max file sizes const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB const MAX_DOC_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILE_SIZE = MAX_DOC_SIZE; // Use larger limit; we validate per-type in conversion interface MessageInputProps { onSend: (content: string, files?: FileAttachment[]) => 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; immediate?: boolean; installedSource?: "agents" | "claude"; icon?: typeof CommandLineIcon; } interface CommandBadge { command: string; label: string; description: string; isSkill: boolean; installedSource?: "agents" | "claude"; } type PopoverMode = 'file' | 'skill' | null; // Expansion prompts for CLI-only commands (not natively supported by SDK). // SDK-native commands (/compact, /init, /review) are sent as-is — the SDK handles them directly. const COMMAND_PROMPTS: Record = { '/doctor': 'Run diagnostic checks on this project. Check system health, dependencies, configuration files, and report any issues.', '/terminal-setup': 'Help me configure my terminal for optimal use with Claude Code. Check current setup and suggest improvements.', '/memory': 'Show the current CLAUDE.md project memory file and help me review or edit it.', }; const BUILT_IN_COMMANDS: PopoverItem[] = [ { label: 'help', value: '/help', description: 'Show available commands and tips', builtIn: true, immediate: true, icon: HelpCircleIcon }, { label: 'clear', value: '/clear', description: 'Clear conversation history', builtIn: true, immediate: true, icon: Delete02Icon }, { label: 'cost', value: '/cost', description: 'Show token usage statistics', builtIn: true, immediate: true, icon: Coins01Icon }, { label: 'compact', value: '/compact', description: 'Compress conversation context', builtIn: true, icon: FileZipIcon }, { label: 'doctor', value: '/doctor', description: 'Diagnose project health', builtIn: true, icon: Stethoscope02Icon }, { label: 'init', value: '/init', description: 'Initialize CLAUDE.md for project', builtIn: true, icon: FileEditIcon }, { label: 'review', value: '/review', description: 'Review code quality', builtIn: true, icon: SearchList01Icon }, { label: 'terminal-setup', value: '/terminal-setup', description: 'Configure terminal settings', builtIn: true, icon: CommandLineIcon }, { label: 'memory', value: '/memory', description: 'Edit project memory file', builtIn: true, icon: BrainIcon }, ]; 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' }, ]; // Default Claude model options — labels are dynamically overridden by active provider const DEFAULT_MODEL_OPTIONS = [ { value: 'sonnet', label: 'Sonnet 4.5' }, { value: 'opus', label: 'Opus 4.6' }, { value: 'haiku', label: 'Haiku 4.5' }, ]; // Provider-specific model label mappings (alias → display name) const PROVIDER_MODEL_LABELS: Record> = { // GLM Coding Plan (Z.AI / 智谱) 'https://api.z.ai/api/anthropic': { sonnet: 'GLM-4.7', opus: 'GLM-4.7', haiku: 'GLM-4.5-Air', }, 'https://open.bigmodel.cn/api/anthropic': { sonnet: 'GLM-4.7', opus: 'GLM-4.7', haiku: 'GLM-4.5-Air', }, // Kimi Coding Plan 'https://api.kimi.com/coding/': { sonnet: 'Kimi K2.5', opus: 'Kimi K2.5', haiku: 'Kimi K2.5', }, // Moonshot Open Platform 'https://api.moonshot.ai/anthropic': { sonnet: 'Kimi K2.5', opus: 'Kimi K2.5', haiku: 'Kimi K2.5', }, 'https://api.moonshot.cn/anthropic': { sonnet: 'Kimi K2.5', opus: 'Kimi K2.5', haiku: 'Kimi K2.5', }, // MiniMax Coding Plan 'https://api.minimaxi.com/anthropic': { sonnet: 'MiniMax-M2.1', opus: 'MiniMax-M2.1', haiku: 'MiniMax-M2.1', }, 'https://api.minimax.io/anthropic': { sonnet: 'MiniMax-M2.1', opus: 'MiniMax-M2.1', haiku: 'MiniMax-M2.1', }, // OpenRouter — keeps Claude names, provider handles routing 'https://openrouter.ai/api': { sonnet: 'Sonnet 4.5', opus: 'Opus 4.6', haiku: 'Haiku 4.5', }, }; /** * Convert a data URL to a FileAttachment object. */ async function dataUrlToFileAttachment( dataUrl: string, filename: string, mediaType: string, ): Promise { // data:image/png;base64, — extract the base64 part const base64 = dataUrl.includes(',') ? dataUrl.split(',')[1] : dataUrl; // Estimate raw size from base64 length const size = Math.ceil((base64.length * 3) / 4); return { id: nanoid(), name: filename, type: mediaType || 'application/octet-stream', size, data: base64, }; } /** * Submit button that's aware of file attachments. Must be rendered inside PromptInput. */ function FileAwareSubmitButton({ status, onStop, disabled, inputValue, hasBadge, }: { status: ChatStatus; onStop?: () => void; disabled?: boolean; inputValue: string; hasBadge: boolean; }) { const attachments = usePromptInputAttachments(); const hasFiles = attachments.files.length > 0; const isStreaming = status === 'streaming' || status === 'submitted'; return ( {isStreaming ? ( ) : ( )} ); } /** * Attachment button that opens the file dialog. Must be rendered inside PromptInput. */ function AttachFileButton() { const attachments = usePromptInputAttachments(); return ( attachments.openFileDialog()} tooltip="Attach files" > ); } /** * Bridge component that listens for 'attach-file-to-chat' custom events * from the file tree and adds files as attachments. Must be rendered inside PromptInput. */ function FileTreeAttachmentBridge() { const attachments = usePromptInputAttachments(); const attachmentsRef = useRef(attachments); useEffect(() => { attachmentsRef.current = attachments; }, [attachments]); useEffect(() => { const handler = async (e: Event) => { const customEvent = e as CustomEvent<{ path: string }>; const filePath = customEvent.detail?.path; if (!filePath) return; try { const res = await fetch(`/api/files/raw?path=${encodeURIComponent(filePath)}`); if (!res.ok) { console.warn(`[FileTreeAttachment] Failed to fetch file: ${res.status} ${res.statusText}`, filePath); return; } const blob = await res.blob(); // Handle both Unix (/) and Windows (\) path separators const filename = filePath.split(/[/\\]/).pop() || 'file'; const file = new File([blob], filename, { type: blob.type || 'application/octet-stream' }); attachmentsRef.current.add([file]); } catch (err) { console.warn('[FileTreeAttachment] Error attaching file:', filePath, err); } }; window.addEventListener('attach-file-to-chat', handler); return () => window.removeEventListener('attach-file-to-chat', handler); }, []); return null; } /** * Capsule display for attached files, rendered inside PromptInput context. */ function FileAttachmentsCapsules() { const attachments = usePromptInputAttachments(); if (attachments.files.length === 0) return null; return (
{attachments.files.map((file) => { const isImage = file.mediaType?.startsWith('image/'); return ( {isImage && file.url && ( // eslint-disable-next-line @next/next/no-img-element {file.filename )} {file.filename || 'file'} ); })}
); } export function MessageInput({ onSend, onCommand, onStop, disabled, isStreaming, sessionId, modelName, onModelChange, workingDirectory, onWorkingDirectoryChange, mode = 'code', onModeChange, }: MessageInputProps) { const textareaRef = useRef(null); const popoverRef = useRef(null); const searchInputRef = useRef(null); const modeMenuRef = useRef(null); const modelMenuRef = useRef(null); const [popoverMode, setPopoverMode] = useState(null); const [popoverItems, setPopoverItems] = useState([]); const [popoverFilter, setPopoverFilter] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const [triggerPos, setTriggerPos] = useState(null); const [folderPickerOpen, setFolderPickerOpen] = useState(false); const [modeMenuOpen, setModeMenuOpen] = useState(false); const [modelMenuOpen, setModelMenuOpen] = useState(false); const [inputValue, setInputValue] = useState(''); const [badge, setBadge] = useState(null); const [activeProviderBaseUrl, setActiveProviderBaseUrl] = useState(null); const [activeProviderName, setActiveProviderName] = useState(null); // Fetch active provider to adapt model labels useEffect(() => { fetch('/api/providers') .then((r) => r.json()) .then((data) => { const active = (data.providers || []).find((p: { is_active: number }) => p.is_active === 1); if (active) { setActiveProviderBaseUrl(active.base_url || null); setActiveProviderName(active.name || null); } else { setActiveProviderBaseUrl(null); setActiveProviderName(null); } }) .catch(() => {}); }, []); // Compute model options based on active provider const MODEL_OPTIONS = DEFAULT_MODEL_OPTIONS.map((opt) => { if (activeProviderBaseUrl && PROVIDER_MODEL_LABELS[activeProviderBaseUrl]) { const label = PROVIDER_MODEL_LABELS[activeProviderBaseUrl][opt.value]; if (label) return { ...opt, label }; } return opt; }); // 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) // Returns all items unfiltered — filtering is done by filteredItems const fetchSkills = useCallback(async () => { let apiSkills: PopoverItem[] = []; try { const res = await fetch('/api/skills'); if (res.ok) { const data = await res.json(); const skills = data.skills || []; apiSkills = skills .map((s: { name: string; description: string; source?: string; installedSource?: "agents" | "claude" }) => ({ label: s.name, value: `/${s.name}`, description: s.description || "", builtIn: false, installedSource: s.installedSource, })); } } catch { // API not available - just use built-in commands } // Deduplicate: remove API skills that share a name with built-in commands const builtInNames = new Set(BUILT_IN_COMMANDS.map(c => c.label)); const uniqueSkills = apiSkills.filter(s => !builtInNames.has(s.label)); return [...BUILT_IN_COMMANDS, ...uniqueSkills]; }, []); // Close popover const closePopover = useCallback(() => { setPopoverMode(null); setPopoverItems([]); setPopoverFilter(''); setSelectedIndex(0); setTriggerPos(null); }, []); // Remove active badge const removeBadge = useCallback(() => { setBadge(null); setTimeout(() => textareaRef.current?.focus(), 0); }, []); // Insert selected item const insertItem = useCallback((item: PopoverItem) => { if (triggerPos === null) return; // Immediate built-in commands: execute right away if (item.builtIn && item.immediate && onCommand) { setInputValue(''); closePopover(); onCommand(item.value); return; } // Non-immediate commands (prompt-based built-ins and skills): show as badge if (popoverMode === 'skill') { setBadge({ command: item.value, label: item.label, description: item.description || '', isSkill: !item.builtIn, installedSource: item.installedSource, }); setInputValue(''); closePopover(); setTimeout(() => textareaRef.current?.focus(), 0); return; } // File mention: insert into text const currentVal = inputValue; const before = currentVal.slice(0, triggerPos); const cursorEnd = triggerPos + popoverFilter.length + 1; const after = currentVal.slice(cursorEnd); const insertText = `@${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(); setPopoverItems(items); return; } if (popoverMode) { closePopover(); } }, [fetchFiles, fetchSkills, popoverMode, closePopover]); const handleSubmit = useCallback(async (msg: { text: string; files: Array<{ type: string; url: string; filename?: string; mediaType?: string }> }, e: FormEvent) => { e.preventDefault(); const content = inputValue.trim(); closePopover(); // Convert PromptInput FileUIParts (with data URLs) to FileAttachment[] const convertFiles = async (): Promise => { if (!msg.files || msg.files.length === 0) return []; const attachments: FileAttachment[] = []; for (const file of msg.files) { if (!file.url) continue; try { const attachment = await dataUrlToFileAttachment( file.url, file.filename || 'file', file.mediaType || 'application/octet-stream', ); // Enforce per-type size limits const isImage = attachment.type.startsWith('image/'); const sizeLimit = isImage ? MAX_IMAGE_SIZE : MAX_DOC_SIZE; if (attachment.size <= sizeLimit) { attachments.push(attachment); } } catch { // Skip files that fail conversion } } return attachments; }; // If badge is active, expand the command/skill and send if (badge && !isStreaming) { let expandedPrompt = ''; if (badge.isSkill) { // Fetch skill content from API try { const sourceParam = badge.installedSource ? `?source=${badge.installedSource}` : ""; const res = await fetch( `/api/skills/${encodeURIComponent(badge.label)}${sourceParam}` ); if (res.ok) { const data = await res.json(); expandedPrompt = data.skill?.content || ''; } } catch { // Fallback: use command name } } else { // Built-in prompt command expansion expandedPrompt = COMMAND_PROMPTS[badge.command] || ''; } const finalPrompt = content ? `${expandedPrompt}\n\nUser context: ${content}` : expandedPrompt || badge.command; const files = await convertFiles(); setBadge(null); setInputValue(''); onSend(finalPrompt, files.length > 0 ? files : undefined); return; } const files = await convertFiles(); const hasFiles = files.length > 0; if ((!content && !hasFiles) || disabled || isStreaming) return; // Check if it's a direct slash command typed in the input if (content.startsWith('/') && !hasFiles) { const cmd = BUILT_IN_COMMANDS.find(c => c.value === content); if (cmd) { if (cmd.immediate && onCommand) { setInputValue(''); onCommand(content); return; } // Non-immediate: show as badge for user to add context setBadge({ command: cmd.value, label: cmd.label, description: cmd.description || '', isSkill: false, }); setInputValue(''); return; } // Not a built-in command — treat as a skill const skillName = content.slice(1); if (skillName) { setBadge({ command: content, label: skillName, description: '', isSkill: true, }); setInputValue(''); return; } } onSend(content || 'Please review the attached file(s).', hasFiles ? files : undefined); setInputValue(''); }, [inputValue, onSend, onCommand, disabled, isStreaming, closePopover, badge]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { // 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; } } // Backspace removes badge when input is empty if (e.key === 'Backspace' && badge && !inputValue) { e.preventDefault(); removeBadge(); return; } // Escape removes badge if (e.key === 'Escape' && badge) { e.preventDefault(); removeBadge(); return; } }, [popoverMode, popoverItems, popoverFilter, selectedIndex, insertItem, closePopover, badge, inputValue, removeBadge] ); // 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 (
{/* Popover */} {popoverMode && filteredItems.length > 0 && (() => { const builtInItems = filteredItems.filter(item => item.builtIn); const skillItems = filteredItems.filter(item => !item.builtIn); let globalIdx = 0; const renderItem = (item: PopoverItem, idx: number) => ( ); return (
{popoverMode === 'skill' ? (
{ const val = e.target.value; setPopoverFilter(val); setSelectedIndex(0); // Sync textarea: replace the filter portion after / if (triggerPos !== null) { const before = inputValue.slice(0, triggerPos + 1); setInputValue(before + val); } }} onKeyDown={(e) => { if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((prev) => (prev + 1) % filteredItems.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((prev) => (prev - 1 + filteredItems.length) % filteredItems.length); } else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); if (filteredItems[selectedIndex]) { insertItem(filteredItems[selectedIndex]); } } else if (e.key === 'Escape') { e.preventDefault(); closePopover(); textareaRef.current?.focus(); } }} className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none" autoFocus />
) : (
Files
)}
{popoverMode === 'file' ? ( filteredItems.map((item, i) => renderItem(item, i)) ) : ( <> {builtInItems.length > 0 && ( <>
Commands
{builtInItems.map((item) => { const idx = globalIdx++; return renderItem(item, idx); })} )} {skillItems.length > 0 && ( <>
Skills
{skillItems.map((item) => { const idx = globalIdx++; return renderItem(item, idx); })} )} )}
); })()} {/* PromptInput replaces the old input area */} {/* Bridge: listens for file tree "+" button events */} {/* Command badge */} {badge && (
{badge.command} {badge.description && ( {badge.description} )}
)} {/* File attachment capsules */} handleInputChange(e.currentTarget.value)} onKeyDown={handleKeyDown} disabled={disabled} className="min-h-10" /> {/* Attach file button */} {/* Folder picker button */} setFolderPickerOpen(true)} tooltip={workingDirectory || 'Select project folder'} > {folderShortName || 'Folder'} {/* Mode selector */}
setModeMenuOpen((prev) => !prev)} > {currentMode.label} {/* Mode dropdown */} {modeMenuOpen && (
{MODE_OPTIONS.map((opt) => { const isActive = opt.value === mode; return ( ); })}
)}
{/* Model selector */}
setModelMenuOpen((prev) => !prev)} > {currentModelOption.label} {modelMenuOpen && (
{MODEL_OPTIONS.map((opt) => { const isActive = opt.value === currentModelValue; return ( ); })}
)}
{/* FolderPicker dialog */} { onWorkingDirectoryChange?.(dir); }} initialPath={workingDirectory || undefined} />
); }