import * as React from "react"; import { api } from "../api/client"; 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) => ( ))}
); } 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); 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); }, []); 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; // Find the last @ before the cursor that starts a reference token const textUpToCursor = val.slice(0, cursor); // Match @ not preceded by non-whitespace (i.e. @ at start or after space/newline) 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], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { 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(); } }, [ pickerQuery, filteredFiles, pickerSelectedIndex, selectFile, dismissPicker, ], ); const handleSubmit = () => { if (!input.trim()) return; onSubmit(input); setInput(""); dismissPicker(); }; return (
{/* Queued message indicators */} {queuedMessages.map(({ id, text }) => (
Queued {text}
))} {/* Input row with file picker overlay */}
{pickerQuery !== null && ( )}