'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(null); const popoverRef = 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(''); // 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) => { 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) => { // 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 (
{/* Popover */} {popoverMode && filteredItems.length > 0 && (
{popoverMode === 'file' ? 'Files' : 'Commands'}
{filteredItems.map((item, i) => ( ))}
)} {/* PromptInput replaces the old input area */} handleInputChange(e.currentTarget.value)} onKeyDown={handleKeyDown} disabled={disabled || isStreaming} className="min-h-10" /> {/* 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} />
); }