AI Chat Input
A minimalist chat input built for AI tools. Typing "/" opens a searchable command menu that can be navigated with the keyboard.
No matching commands
AiChatInput.jsx
'use client' import { useEffect, useRef, useState } from 'react' import { ArrowUp, Code2, FileText, Languages, Lightbulb, Sparkles, SpellCheck2 } from 'lucide-react' // Add or edit your slash commands here const COMMANDS = [ { id: 'summarize', label: 'Summarize', description: 'Condense a long piece of text', icon: FileText }, { id: 'translate', label: 'Translate', description: 'Convert text into another language', icon: Languages }, { id: 'explain', label: 'Explain', description: 'Break a concept down into simple terms', icon: Lightbulb }, { id: 'code', label: 'Write Code', description: 'Generate or debug a snippet of code', icon: Code2 }, { id: 'brainstorm', label: 'Brainstorm', description: 'Generate ideas around a topic', icon: Sparkles }, { id: 'fix-grammar', label: 'Fix Grammar', description: 'Clean up grammar and tone', icon: SpellCheck2 }, ] export default function AiChatInput() { const [value, setValue] = useState('') const [highlightedIndex, setHighlightedIndex] = useState(0) const [forceClosed, setForceClosed] = useState(false) const textareaRef = useRef(null) const wrapRef = useRef(null) const isMenuOpen = value.startsWith('/') && !value.includes(' ') && !forceClosed const query = value.slice(1).toLowerCase() const filteredCommands = isMenuOpen ? COMMANDS.filter((cmd) => cmd.id.includes(query) || cmd.label.toLowerCase().includes(query)) : [] useEffect(() => { setHighlightedIndex(0) }, [value]) useEffect(() => { const textarea = textareaRef.current if (!textarea) return textarea.style.height = 'auto' textarea.style.height = Math.min(textarea.scrollHeight, 160) + 'px' }, [value]) useEffect(() => { if (!isMenuOpen) return const handlePointerDown = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) { setForceClosed(true) } } document.addEventListener('mousedown', handlePointerDown) return () => document.removeEventListener('mousedown', handlePointerDown) }, [isMenuOpen]) const selectCommand = (command) => { setValue('/' + command.id + ' ') setForceClosed(true) textareaRef.current?.focus() } const handleChange = (e) => { setValue(e.target.value) setForceClosed(false) } const handleKeyDown = (e) => { if (!isMenuOpen) return if (e.key === 'ArrowDown') { e.preventDefault() setHighlightedIndex((i) => (filteredCommands.length ? (i + 1) % filteredCommands.length : 0)) } else if (e.key === 'ArrowUp') { e.preventDefault() setHighlightedIndex((i) => (filteredCommands.length ? (i - 1 + filteredCommands.length) % filteredCommands.length : 0)) } else if (e.key === 'Enter') { if (!filteredCommands.length) return e.preventDefault() selectCommand(filteredCommands[highlightedIndex]) } else if (e.key === 'Escape') { e.preventDefault() setForceClosed(true) } } const canSend = value.trim().length > 0 return ( <div ref={wrapRef} className="relative w-full max-w-xl mx-auto"> {/* Slash-command menu */} <div className={`absolute bottom-full left-0 right-0 mb-2 grid transition-all duration-200 ease-out ${ isMenuOpen ? 'grid-rows-[1fr] opacity-100 translate-y-0' : 'grid-rows-[0fr] opacity-0 translate-y-1 pointer-events-none' }`} > <div className="overflow-hidden rounded-2xl border border-[#E5E5E5] dark:border-[#3A3A3A] bg-white/95 dark:bg-[#161616]/95 backdrop-blur-xl shadow-[0_20px_44px_-24px_rgba(0,0,0,0.35)]"> <div className="p-1.5" role="listbox"> {filteredCommands.length === 0 ? ( <p className="px-3 py-3 text-[13px] text-[#6e6e73] dark:text-[#a1a1a6]">No matching commands</p> ) : ( filteredCommands.map((command, index) => { const Icon = command.icon const isHighlighted = index === highlightedIndex return ( <button key={command.id} type="button" role="option" aria-selected={isHighlighted} onMouseDown={(e) => e.preventDefault()} onClick={() => selectCommand(command)} onMouseEnter={() => setHighlightedIndex(index)} className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left cursor-pointer transition-colors duration-100 ${ isHighlighted ? 'bg-[#7e60d6]/10 dark:bg-[#a98cf3]/10' : '' }`} > <span className="flex items-center justify-center w-8 h-8 rounded-lg shrink-0 bg-[#7e60d6]/10 dark:bg-[#a98cf3]/10 text-[#7e60d6] dark:text-[#a98cf3]"> <Icon size={16} strokeWidth={1.8} /> </span> <span className="min-w-0"> <span className="block text-[13.5px] font-medium text-[#1d1d1f] dark:text-[#f5f5f7]"> {command.label} </span> <span className="block text-[12px] text-[#6e6e73] dark:text-[#a1a1a6] truncate"> {command.description} </span> </span> </button> ) }) )} </div> </div> </div> {/* Input row */} <div className="flex items-center gap-2 rounded-2xl border border-[#E5E5E5] dark:border-[#3A3A3A] bg-white/70 dark:bg-[#161616]/70 backdrop-blur-xl px-4 py-3 transition-colors duration-150 focus-within:border-[#7e60d6] dark:focus-within:border-[#a98cf3]"> <textarea ref={textareaRef} value={value} onChange={handleChange} onKeyDown={handleKeyDown} rows={1} placeholder="Ask anything, or type / for commands" className="flex-1 resize-none bg-transparent outline-none text-[14.5px] leading-relaxed text-[#1d1d1f] dark:text-[#f5f5f7] placeholder:text-[#6e6e73] dark:placeholder:text-[#a1a1a6] max-h-40" /> <button type="button" disabled={!canSend} className={`flex items-center justify-center w-8 h-8 rounded-full shrink-0 transition-all duration-150 ${ canSend ? 'bg-[#7e60d6] text-white cursor-pointer hover:bg-[#6d4fc4]' : 'bg-[#E5E5E5] dark:bg-[#3A3A3A] text-[#a1a1a6] cursor-not-allowed' }`} > <ArrowUp size={16} strokeWidth={2.2} /> </button> </div> </div> ) }