/** * Chat Input Component * * 支持响应式:responsive=true 时适配移动端键盘和触摸操作 * 支持斜杠命令:输入 / 时显示命令菜单 * 支持系统命令:输入 : 时显示系统命令菜单 * 支持文件提及:输入 @ 时显示文件搜索菜单 */ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { Square, Sparkles } from 'lucide-react'; import { motion } from 'framer-motion'; import clsx from 'clsx'; import { CommandMenu, type CommandMenuItem } from './CommandMenu.js'; import { SystemCommandMenu, type SystemCommandMenuItem } from './SystemCommandMenu.js'; import { FileMenu, type FileMenuItem } from './FileMenu.js'; import { FileMentionTag } from './FileMentionTag.js'; import { AgentModeSelector, type AgentModeType } from './AgentModeSelector.js'; import { useCommands } from '../hooks/useCommands.js'; import { useSystemCommands } from '../hooks/useSystemCommands.js'; import { useFileMention } from '../hooks/useFileMention.js'; interface ChatInputProps { onSend: (content: string) => void; onCancel: () => void; isLoading: boolean; disabled?: boolean; /** 是否启用响应式布局(移动端适配) */ responsive?: boolean; /** 是否启用斜杠命令 */ enableCommands?: boolean; /** 是否启用文件提及 (@) */ enableFileMention?: boolean; /** Agent 模式 (build/plan) */ agentMode?: AgentModeType; /** Agent 模式变更回调 */ onAgentModeChange?: (mode: AgentModeType) => void; /** 是否自动授权文件写入/编辑 */ autoApprove?: boolean; /** 自动授权变更回调 */ onAutoApproveChange?: (enabled: boolean) => void; } export function ChatInput({ onSend, onCancel, isLoading, disabled, responsive = false, enableCommands = true, enableFileMention = true, agentMode = 'build', onAgentModeChange, autoApprove = false, onAutoApproveChange, }: ChatInputProps) { const [input, setInput] = useState(''); const [showCommandMenu, setShowCommandMenu] = useState(false); const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); const [showSystemCommandMenu, setShowSystemCommandMenu] = useState(false); const [selectedSystemCommandIndex, setSelectedSystemCommandIndex] = useState(0); const textareaRef = useRef(null); // 命令系统 const { filteredCommands, isLoading: commandsLoading, filterCommands, } = useCommands({ autoLoad: enableCommands }); // 系统命令系统 const { filteredCommands: filteredSystemCommands, isLoading: systemCommandsLoading, filterCommands: filterSystemCommands, } = useSystemCommands({ autoLoad: enableCommands }); // 文件提及系统 const { isOpen: showFileMenu, files: filteredFiles, isLoading: filesLoading, selectedIndex: fileSelectedIndex, setSelectedIndex: setFileSelectedIndex, checkTrigger: checkFileTrigger, getReplacementText, close: closeFileMenu, mentionStart, } = useFileMention({ enabled: enableFileMention }); // 解析输入中已存在的文件提及 (匹配 @path/to/file 格式) const mentionedFiles = useMemo(() => { const regex = /@([\w./-]+)/g; const files: string[] = []; let match; while ((match = regex.exec(input)) !== null) { files.push(match[1]); } return files; }, [input]); // 移除指定的文件提及 const handleRemoveFile = useCallback( (filePath: string) => { // 移除 @filepath 和后面的空格 const regex = new RegExp(`@${filePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g'); setInput((prev) => prev.replace(regex, '').trim()); textareaRef.current?.focus(); }, [] ); // 自动调整高度 useEffect(() => { const textarea = textareaRef.current; if (textarea) { textarea.style.height = 'auto'; // 响应式模式下移动端最大高度稍小 const maxHeight = responsive && window.innerWidth < 768 ? 120 : 200; textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`; } }, [input, responsive]); // 检测斜杠命令输入 const checkCommandTrigger = useCallback( (value: string) => { if (!enableCommands) return; // 检测是否在输入斜杠命令 // 条件:以 / 开头,且 / 后面没有空格(还在输入命令名) const slashMatch = value.match(/^\/(\S*)$/); if (slashMatch) { const query = slashMatch[1]; // / 后面的内容 setShowCommandMenu(true); setSelectedCommandIndex(0); filterCommands(query); } else { setShowCommandMenu(false); } }, [enableCommands, filterCommands] ); // 检测系统命令输入 const checkSystemCommandTrigger = useCallback( (value: string) => { if (!enableCommands) return; // 检测是否在输入系统命令 // 条件:以 : 开头,且 : 后面没有空格(还在输入命令名) const colonMatch = value.match(/^:(\S*)$/); if (colonMatch) { const query = colonMatch[1]; // : 后面的内容 setShowSystemCommandMenu(true); setSelectedSystemCommandIndex(0); filterSystemCommands(query); } else { setShowSystemCommandMenu(false); } }, [enableCommands, filterSystemCommands] ); // 处理输入变化 const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value; const cursorPos = e.target.selectionStart; setInput(value); // 检查斜杠命令触发 checkCommandTrigger(value); // 检查系统命令触发 checkSystemCommandTrigger(value); // 检查文件提及触发(只在非命令输入模式下) if (enableFileMention && !value.startsWith('/') && !value.startsWith(':')) { checkFileTrigger(value, cursorPos); } else { closeFileMenu(); } }; // 选择命令 const handleCommandSelect = useCallback((command: CommandMenuItem) => { // 替换输入内容为 /command + 空格,准备输入参数 setInput(`/${command.name} `); setShowCommandMenu(false); // 聚焦输入框 textareaRef.current?.focus(); }, []); // 选择文件 const handleFileSelect = useCallback( (file: FileMenuItem) => { if (mentionStart === null) return; // 获取当前光标位置 const cursorPos = textareaRef.current?.selectionStart || input.length; // 替换 @ 到光标之间的内容 const before = input.slice(0, mentionStart); const after = input.slice(cursorPos); const replacement = getReplacementText(file); const newInput = before + replacement + after; setInput(newInput); closeFileMenu(); // 聚焦输入框并设置光标位置 setTimeout(() => { textareaRef.current?.focus(); const newPos = before.length + replacement.length; textareaRef.current?.setSelectionRange(newPos, newPos); }, 0); }, [input, mentionStart, getReplacementText, closeFileMenu] ); // 关闭命令菜单 const handleCommandMenuClose = useCallback(() => { setShowCommandMenu(false); }, []); // 选择系统命令 const handleSystemCommandSelect = useCallback((command: SystemCommandMenuItem) => { // 直接发送系统命令 setInput(`:${command.name}`); setShowSystemCommandMenu(false); // 聚焦输入框 textareaRef.current?.focus(); }, []); // 关闭系统命令菜单 const handleSystemCommandMenuClose = useCallback(() => { setShowSystemCommandMenu(false); }, []); const handleSubmit = () => { const trimmed = input.trim(); if (!trimmed || isLoading || disabled) return; // 关闭所有菜单 setShowCommandMenu(false); setShowSystemCommandMenu(false); closeFileMenu(); onSend(trimmed); setInput(''); // 重置高度 if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } }; const handleKeyDown = (e: React.KeyboardEvent) => { // 文件菜单优先处理(因为可以在任意位置触发) if (showFileMenu && filteredFiles.length > 0) { if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) { // 这些键由 FileMenu 处理 return; } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (filteredFiles[fileSelectedIndex]) { handleFileSelect(filteredFiles[fileSelectedIndex]); } return; } } // 斜杠命令菜单处理 if (showCommandMenu && filteredCommands.length > 0) { if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) { // 这些键由 CommandMenu 处理,阻止默认行为 return; } if (e.key === 'Enter' && !e.shiftKey) { // Enter 选择命令 e.preventDefault(); if (filteredCommands[selectedCommandIndex]) { handleCommandSelect(filteredCommands[selectedCommandIndex]); } return; } } // 系统命令菜单处理 if (showSystemCommandMenu && filteredSystemCommands.length > 0) { if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) { // 这些键由 SystemCommandMenu 处理,阻止默认行为 return; } if (e.key === 'Enter' && !e.shiftKey) { // Enter 选择系统命令 e.preventDefault(); if (filteredSystemCommands[selectedSystemCommandIndex]) { handleSystemCommandSelect(filteredSystemCommands[selectedSystemCommandIndex]); } return; } } // Enter 发送,Shift+Enter 换行 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }; return (
{/* Command Menu (斜杠命令) */} {enableCommands && ( )} {/* System Command Menu (系统命令) */} {enableCommands && ( )} {/* File Menu */} {enableFileMention && ( )}
{/* 主输入容器 - 现代化卡片设计 */}
{/* 已选文件标签 */} {mentionedFiles.length > 0 && (
{mentionedFiles.map((file, index) => ( handleRemoveFile(file)} /> ))}
)} {/* 输入区域 */}
{/* Agent 模式选择器 */} {onAgentModeChange && (
{})} disabled={disabled || isLoading} />
)}