import * as React from "react"; import { api } from "../api/client"; import { SLASH_COMMANDS, type SlashCommand } from "../slashCommands"; const { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, } = React; export interface ChatInputHandle { appendToInput(text: string): void; } interface ChatInputProps { loading: boolean; queuedMessages: { id: string; text: string }[]; onSubmit: (message: string) => void; onCancel: () => void; onRemoveQueuedMessage: (id: string) => void; } /** Fuzzy-match: returns true if all chars of `query` appear in order in `str`. */ function fuzzyMatch(str: string, query: string): boolean { if (!query) return true; const lower = str.toLowerCase(); const q = query.toLowerCase(); let qi = 0; for (let i = 0; i < lower.length && qi < q.length; i++) { if (lower[i] === q[qi]) qi++; } return qi === q.length; } /** Score a fuzzy match: lower is better. Exact prefix match wins, then shorter paths. */ function fuzzyScore(str: string, query: string): number { const lower = str.toLowerCase(); const q = query.toLowerCase(); // Prefer matches where query appears as a contiguous substring if (lower.includes(q)) return lower.indexOf(q); return str.length; } interface FilePickerOverlayProps { query: string; files: string[]; selectedIndex: number; onSelect: (file: string) => void; onDismiss: () => void; anchorRef: React.RefObject; } function FilePickerOverlay({ query, files, selectedIndex, onSelect, }: FilePickerOverlayProps) { const filtered = files .filter((f) => fuzzyMatch(f, query)) .sort((a, b) => fuzzyScore(a, query) - fuzzyScore(b, query)) .slice(0, 10); if (filtered.length === 0) return null; return (
{filtered.map((file, idx) => ( ))}
); } interface SlashCommandPickerOverlayProps { query: string; selectedIndex: number; onSelect: (cmd: SlashCommand) => void; } function SlashCommandPickerOverlay({ query, selectedIndex, onSelect, }: SlashCommandPickerOverlayProps) { const filtered = SLASH_COMMANDS.filter((cmd) => fuzzyMatch(cmd.name, query), ).sort((a, b) => fuzzyScore(a.name, query) - fuzzyScore(b.name, query)); if (filtered.length === 0) return null; return (
{filtered.map((cmd, idx) => ( ))}
); } export const ChatInput = forwardRef( function ChatInput( { loading, queuedMessages, onSubmit, onCancel, onRemoveQueuedMessage }, ref, ) { const [input, setInput] = useState(""); const inputRef = useRef(null); // File picker state const [projectFiles, setProjectFiles] = useState([]); const [pickerQuery, setPickerQuery] = useState(null); const [pickerSelectedIndex, setPickerSelectedIndex] = useState(0); const [pickerAtStart, setPickerAtStart] = useState(0); // Slash command picker state const [slashQuery, setSlashQuery] = useState(null); const [slashSelectedIndex, setSlashSelectedIndex] = useState(0); useImperativeHandle(ref, () => ({ appendToInput(text: string) { setInput((prev) => (prev ? `${prev}\n${text}` : text)); }, })); useEffect(() => { inputRef.current?.focus(); }, []); // Compute filtered files for current picker query const filteredFiles = pickerQuery !== null ? projectFiles .filter((f) => fuzzyMatch(f, pickerQuery)) .sort( (a, b) => fuzzyScore(a, pickerQuery) - fuzzyScore(b, pickerQuery), ) .slice(0, 10) : []; const dismissPicker = useCallback(() => { setPickerQuery(null); setPickerSelectedIndex(0); }, []); // Compute filtered slash commands for current query const filteredCommands = slashQuery !== null ? SLASH_COMMANDS.filter((cmd) => fuzzyMatch(cmd.name, slashQuery)).sort( (a, b) => fuzzyScore(a.name, slashQuery) - fuzzyScore(b.name, slashQuery), ) : []; const dismissSlashPicker = useCallback(() => { setSlashQuery(null); setSlashSelectedIndex(0); }, []); const selectCommand = useCallback( (cmd: SlashCommand) => { // Extract base command (first word, e.g. "/assign" from "/assign ") const baseCommand = cmd.name.split(" ")[0]; setInput(`${baseCommand} `); dismissSlashPicker(); setTimeout(() => inputRef.current?.focus(), 0); }, [dismissSlashPicker], ); const selectFile = useCallback( (file: string) => { // Replace the @query portion with @file const before = input.slice(0, pickerAtStart); const cursorPos = inputRef.current?.selectionStart ?? input.length; const after = input.slice(cursorPos); setInput(`${before}@${file}${after}`); dismissPicker(); // Restore focus after state update setTimeout(() => inputRef.current?.focus(), 0); }, [input, pickerAtStart, dismissPicker], ); const handleInputChange = useCallback( (e: React.ChangeEvent) => { const val = e.target.value; setInput(val); const cursor = e.target.selectionStart ?? val.length; const textUpToCursor = val.slice(0, cursor); // Slash command picker: triggered when input starts with / and no space yet const slashMatch = textUpToCursor.match(/^\/(\S*)$/); if (slashMatch) { setSlashQuery(slashMatch[1]); setSlashSelectedIndex(0); if (pickerQuery !== null) dismissPicker(); return; } if (slashQuery !== null) dismissSlashPicker(); // File picker: triggered by @ at start or after whitespace const atMatch = textUpToCursor.match(/(^|[\s\n])@([^\s@]*)$/); if (atMatch) { const query = atMatch[2]; const atPos = textUpToCursor.lastIndexOf("@"); setPickerAtStart(atPos); setPickerQuery(query); setPickerSelectedIndex(0); // Lazily load files on first trigger if (projectFiles.length === 0) { api .listProjectFiles() .then(setProjectFiles) .catch(() => {}); } } else { if (pickerQuery !== null) dismissPicker(); } }, [ projectFiles.length, pickerQuery, dismissPicker, slashQuery, dismissSlashPicker, ], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { // Slash command picker navigation if (slashQuery !== null && filteredCommands.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); setSlashSelectedIndex((i) => Math.min(i + 1, filteredCommands.length - 1), ); return; } if (e.key === "ArrowUp") { e.preventDefault(); setSlashSelectedIndex((i) => Math.max(i - 1, 0)); return; } if (e.key === "Tab" || e.key === "Enter") { e.preventDefault(); selectCommand( filteredCommands[slashSelectedIndex] ?? filteredCommands[0], ); return; } if (e.key === "Escape") { e.preventDefault(); dismissSlashPicker(); return; } } else if (e.key === "Escape" && slashQuery !== null) { e.preventDefault(); dismissSlashPicker(); return; } // File picker navigation if (pickerQuery !== null && filteredFiles.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); setPickerSelectedIndex((i) => Math.min(i + 1, filteredFiles.length - 1), ); return; } if (e.key === "ArrowUp") { e.preventDefault(); setPickerSelectedIndex((i) => Math.max(i - 1, 0)); return; } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); selectFile(filteredFiles[pickerSelectedIndex] ?? filteredFiles[0]); return; } if (e.key === "Escape") { e.preventDefault(); dismissPicker(); return; } } else if (e.key === "Escape" && pickerQuery !== null) { e.preventDefault(); dismissPicker(); return; } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }, [ slashQuery, filteredCommands, slashSelectedIndex, selectCommand, dismissSlashPicker, pickerQuery, filteredFiles, pickerSelectedIndex, selectFile, dismissPicker, ], ); const handleSubmit = () => { if (!input.trim()) return; onSubmit(input); setInput(""); dismissPicker(); dismissSlashPicker(); }; return (
{/* Queued message indicators */} {queuedMessages.map(({ id, text }) => (
Queued {text}
))} {/* Input row with file picker overlay */}
{slashQuery !== null && ( )} {pickerQuery !== null && ( )}