Spaces:
Sleeping
Sleeping
| import { useState, useRef, useEffect, useCallback } from "react"; | |
| import { cn } from "@/lib/utils"; | |
| import { Send, Square, Slash, ChevronUp } from "lucide-react"; | |
| import { SLASH_COMMANDS } from "@shared/claw-types"; | |
| interface ChatInputProps { | |
| onSend: (message: string) => void; | |
| onSlashCommand?: (command: string, args: string) => void; | |
| isStreaming: boolean; | |
| onStop: () => void; | |
| disabled?: boolean; | |
| placeholder?: string; | |
| } | |
| export function ChatInput({ | |
| onSend, | |
| onSlashCommand, | |
| isStreaming, | |
| onStop, | |
| disabled, | |
| placeholder, | |
| }: ChatInputProps) { | |
| const [value, setValue] = useState(""); | |
| const [showCommands, setShowCommands] = useState(false); | |
| const [selectedCommand, setSelectedCommand] = useState(0); | |
| const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| // Filter commands based on input | |
| const filteredCommands = value.startsWith("/") | |
| ? SLASH_COMMANDS.filter((cmd) => | |
| cmd.name.toLowerCase().startsWith(value.split(" ")[0].toLowerCase()) | |
| ) | |
| : []; | |
| useEffect(() => { | |
| setShowCommands(value.startsWith("/") && filteredCommands.length > 0 && !value.includes(" ")); | |
| setSelectedCommand(0); | |
| }, [value, filteredCommands.length]); | |
| // Auto-resize textarea | |
| useEffect(() => { | |
| const textarea = textareaRef.current; | |
| if (textarea) { | |
| textarea.style.height = "auto"; | |
| textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px"; | |
| } | |
| }, [value]); | |
| const handleSubmit = useCallback(() => { | |
| const trimmed = value.trim(); | |
| if (!trimmed || disabled) return; | |
| if (trimmed.startsWith("/")) { | |
| const parts = trimmed.split(" "); | |
| const cmd = parts[0]; | |
| const args = parts.slice(1).join(" "); | |
| if (onSlashCommand) { | |
| onSlashCommand(cmd, args); | |
| } | |
| } else { | |
| onSend(trimmed); | |
| } | |
| setValue(""); | |
| }, [value, disabled, onSend, onSlashCommand]); | |
| const handleKeyDown = (e: React.KeyboardEvent) => { | |
| if (showCommands) { | |
| if (e.key === "ArrowDown") { | |
| e.preventDefault(); | |
| setSelectedCommand((prev) => Math.min(prev + 1, filteredCommands.length - 1)); | |
| return; | |
| } | |
| if (e.key === "ArrowUp") { | |
| e.preventDefault(); | |
| setSelectedCommand((prev) => Math.max(prev - 1, 0)); | |
| return; | |
| } | |
| if (e.key === "Tab" || e.key === "Enter") { | |
| e.preventDefault(); | |
| const cmd = filteredCommands[selectedCommand]; | |
| if (cmd) { | |
| setValue(cmd.name + " "); | |
| setShowCommands(false); | |
| } | |
| return; | |
| } | |
| if (e.key === "Escape") { | |
| setShowCommands(false); | |
| return; | |
| } | |
| } | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| if (isStreaming) return; | |
| handleSubmit(); | |
| } | |
| }; | |
| // Group commands by category for display | |
| const groupedCommands = filteredCommands.reduce<Record<string, typeof filteredCommands>>((acc, cmd) => { | |
| const cat = cmd.category; | |
| if (!acc[cat]) acc[cat] = []; | |
| acc[cat].push(cmd); | |
| return acc; | |
| }, {}); | |
| return ( | |
| <div className="relative"> | |
| {/* Slash command autocomplete */} | |
| {showCommands && ( | |
| <div className="absolute bottom-full left-0 right-0 mb-1 bg-popover border border-border rounded-lg shadow-xl overflow-hidden z-50 max-h-[320px] overflow-y-auto"> | |
| {Object.entries(groupedCommands).map(([category, cmds]) => ( | |
| <div key={category}> | |
| <div className="px-3 py-1 text-[10px] uppercase tracking-wider text-muted-foreground/60 bg-muted/30 font-semibold"> | |
| {category} | |
| </div> | |
| {cmds.map((cmd) => { | |
| const globalIdx = filteredCommands.indexOf(cmd); | |
| return ( | |
| <button | |
| key={cmd.name} | |
| className={cn( | |
| "w-full flex items-center gap-3 px-3 py-1.5 text-left transition-colors", | |
| globalIdx === selectedCommand | |
| ? "bg-accent text-accent-foreground" | |
| : "hover:bg-accent/50" | |
| )} | |
| onClick={() => { | |
| setValue(cmd.name + " "); | |
| setShowCommands(false); | |
| textareaRef.current?.focus(); | |
| }} | |
| > | |
| <Slash className="size-3 text-muted-foreground shrink-0" /> | |
| <span className="font-mono text-xs">{cmd.name}</span> | |
| <span className="text-[11px] text-muted-foreground truncate">{cmd.description}</span> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {/* Input area */} | |
| <div className="flex items-end gap-2 bg-secondary/50 border border-border rounded-xl px-3 py-2 focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20 transition-all"> | |
| <textarea | |
| ref={textareaRef} | |
| value={value} | |
| onChange={(e) => setValue(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder={ | |
| placeholder || | |
| (isStreaming | |
| ? "Waiting for response..." | |
| : "Message Claw... (/ for commands, Shift+Enter for newline)") | |
| } | |
| disabled={disabled || isStreaming} | |
| rows={1} | |
| className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground resize-none outline-none min-h-[24px] max-h-[200px] py-1 font-mono" | |
| /> | |
| {isStreaming ? ( | |
| <button | |
| onClick={onStop} | |
| className="shrink-0 size-8 rounded-lg bg-destructive/20 text-destructive hover:bg-destructive/30 flex items-center justify-center transition-colors" | |
| title="Stop generation" | |
| > | |
| <Square className="size-3.5" /> | |
| </button> | |
| ) : ( | |
| <button | |
| onClick={handleSubmit} | |
| disabled={!value.trim() || disabled} | |
| className={cn( | |
| "shrink-0 size-8 rounded-lg flex items-center justify-center transition-all", | |
| value.trim() && !disabled | |
| ? "bg-primary text-primary-foreground hover:bg-primary/90" | |
| : "bg-muted text-muted-foreground" | |
| )} | |
| title="Send message (Enter)" | |
| > | |
| <ChevronUp className="size-4" /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |