import { Box, Text, useInput } from "ink"; import { useCallback, useEffect, useRef, useState } from "react"; import { searchFiles } from "../helpers/fileSearch"; import { colors } from "./colors"; interface FileMatch { path: string; type: "file" | "dir" | "url"; } interface FileAutocompleteProps { currentInput: string; cursorPosition?: number; onSelect?: (path: string) => void; onActiveChange?: (isActive: boolean) => void; } export function FileAutocomplete({ currentInput, cursorPosition = currentInput.length, onSelect, onActiveChange, }: FileAutocompleteProps) { const [matches, setMatches] = useState([]); const [isLoading, setIsLoading] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const [lastValidQuery, setLastValidQuery] = useState(""); const debounceTimeout = useRef(null); // Extract the text after the "@" symbol where the cursor is positioned const extractSearchQuery = useCallback( ( input: string, cursor: number, ): { query: string; hasSpaceAfter: boolean; atIndex: number } | null => { // Find all @ positions const atPositions: number[] = []; for (let i = 0; i < input.length; i++) { if (input[i] === "@") { // Only count @ at start or after space if (i === 0 || input[i - 1] === " ") { atPositions.push(i); } } } if (atPositions.length === 0) return null; // Find which @ the cursor is in let atIndex = -1; for (const pos of atPositions) { // Find the end of this @reference (next space or end of string) const afterAt = input.slice(pos + 1); const spaceIndex = afterAt.indexOf(" "); const endPos = spaceIndex === -1 ? input.length : pos + 1 + spaceIndex; // Check if cursor is within this @reference if (cursor >= pos && cursor <= endPos) { atIndex = pos; break; } } // If cursor is not in any @reference, don't show autocomplete if (atIndex === -1) return null; // Get text after "@" until next space or end const afterAt = input.slice(atIndex + 1); const spaceIndex = afterAt.indexOf(" "); const query = spaceIndex === -1 ? afterAt : afterAt.slice(0, spaceIndex); const hasSpaceAfter = spaceIndex !== -1; return { query, hasSpaceAfter, atIndex }; }, [], ); // Handle keyboard navigation useInput((_input, key) => { if (!matches.length || isLoading) return; const maxIndex = Math.min(matches.length, 10) - 1; if (key.upArrow) { setSelectedIndex((prev) => (prev > 0 ? prev - 1 : maxIndex)); } else if (key.downArrow) { setSelectedIndex((prev) => (prev < maxIndex ? prev + 1 : 0)); } else if ((key.tab || key.return) && onSelect) { // Insert selected file path on Tab or Enter const selected = matches[selectedIndex]; if (selected) { onSelect(selected.path); } } }); useEffect(() => { // Clear any existing debounce timeout if (debounceTimeout.current) { clearTimeout(debounceTimeout.current); } const result = extractSearchQuery(currentInput, cursorPosition); if (!result) { setMatches([]); setSelectedIndex(0); onActiveChange?.(false); return; } const { query, hasSpaceAfter } = result; // If there's text after the space, user has moved on - hide autocomplete // But keep it open if there's just a trailing space (allows editing the path) if (hasSpaceAfter && query.length > 0) { const atIndex = currentInput.lastIndexOf("@"); const afterSpace = currentInput.slice(atIndex + 1 + query.length + 1); // Always hide if there's more non-whitespace content after, or another @ if (afterSpace.trim().length > 0 || afterSpace.includes("@")) { setMatches([]); setSelectedIndex(0); onActiveChange?.(false); return; } // Just a trailing space - check if this query had valid matches when selected // Use lastValidQuery to remember what was successfully selected if (query === lastValidQuery && lastValidQuery.length > 0) { // Show the selected file (non-interactive) if (matches[0]?.path !== query) { setMatches([{ path: query, type: "file" }]); setSelectedIndex(0); } onActiveChange?.(false); // Don't block Enter key return; } // No valid selection was made, hide setMatches([]); setSelectedIndex(0); onActiveChange?.(false); return; } // If query is empty (just typed "@"), show current directory contents (no debounce) if (query.length === 0) { setIsLoading(true); onActiveChange?.(true); searchFiles("", false) // Don't do deep search for empty query .then((results) => { setMatches(results); setSelectedIndex(0); setIsLoading(false); onActiveChange?.(results.length > 0); }) .catch(() => { setMatches([]); setSelectedIndex(0); setIsLoading(false); onActiveChange?.(false); }); return; } // Check if it's a URL pattern (no debounce) if (query.startsWith("http://") || query.startsWith("https://")) { setMatches([{ path: query, type: "url" }]); setSelectedIndex(0); onActiveChange?.(true); return; } // Debounce the file search (300ms delay) // Keep existing matches visible while debouncing setIsLoading(true); onActiveChange?.(true); debounceTimeout.current = setTimeout(() => { // Search for matching files (deep search through subdirectories) searchFiles(query, true) // Enable deep search .then((results) => { setMatches(results); setSelectedIndex(0); setIsLoading(false); onActiveChange?.(results.length > 0); // Remember this query had valid matches if (results.length > 0) { setLastValidQuery(query); } }) .catch(() => { setMatches([]); setSelectedIndex(0); setIsLoading(false); onActiveChange?.(false); }); }, 300); // Cleanup function to clear timeout on unmount return () => { if (debounceTimeout.current) { clearTimeout(debounceTimeout.current); } }; }, [ currentInput, cursorPosition, onActiveChange, extractSearchQuery, lastValidQuery, matches[0]?.path, ]); // Don't show if no "@" in input if (!currentInput.includes("@")) { return null; } // Don't show if no matches and not loading if (matches.length === 0 && !isLoading) { return null; } return ( File/URL autocomplete (↑↓ to navigate, Tab/Enter to select): {isLoading && " Searching..."} {matches.length > 0 ? ( <> {matches.slice(0, 10).map((item, idx) => ( {idx === selectedIndex ? "▶ " : " "} {item.type === "dir" ? "📁" : item.type === "url" ? "🔗" : "📄"} {item.path} ))} {matches.length > 10 && ( ... and {matches.length - 10} more )} ) : ( isLoading && Searching... )} ); }