From 7ce41e52f49a6fc466d4300bddee14b70bff73aa Mon Sep 17 00:00:00 2001 From: Devansh Jain <31609257+devanshrj@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:54:52 -0800 Subject: [PATCH] feat: Autocomplete for slash commands (#216) --- src/cli/components/AgentInfoBar.tsx | 71 ++++++++ src/cli/components/CommandPreview.tsx | 89 ---------- src/cli/components/FileAutocomplete.tsx | 167 +++++++----------- src/cli/components/Input.tsx | 129 -------------- src/cli/components/InputAssist.tsx | 29 ++- src/cli/components/InputRich.tsx | 30 ++++ .../components/SlashCommandAutocomplete.tsx | 121 +++++++++++++ src/cli/components/types/autocomplete.ts | 33 ++++ src/cli/hooks/useAutocompleteNavigation.ts | 73 ++++++++ 9 files changed, 416 insertions(+), 326 deletions(-) create mode 100644 src/cli/components/AgentInfoBar.tsx delete mode 100644 src/cli/components/CommandPreview.tsx delete mode 100644 src/cli/components/Input.tsx create mode 100644 src/cli/components/SlashCommandAutocomplete.tsx create mode 100644 src/cli/components/types/autocomplete.ts create mode 100644 src/cli/hooks/useAutocompleteNavigation.ts diff --git a/src/cli/components/AgentInfoBar.tsx b/src/cli/components/AgentInfoBar.tsx new file mode 100644 index 0000000..856906f --- /dev/null +++ b/src/cli/components/AgentInfoBar.tsx @@ -0,0 +1,71 @@ +import { Box, Text } from "ink"; +import Link from "ink-link"; +import { useMemo } from "react"; +import { settingsManager } from "../../settings-manager"; +import { colors } from "./colors"; + +interface AgentInfoBarProps { + agentId?: string; + agentName?: string | null; + serverUrl?: string; +} + +/** + * Shows agent info bar with current agent details and useful links + */ +export function AgentInfoBar({ + agentId, + agentName, + serverUrl, +}: AgentInfoBarProps) { + // Check if current agent is pinned + const isPinned = useMemo(() => { + if (!agentId) return false; + const localPinned = settingsManager.getLocalPinnedAgents(); + const globalPinned = settingsManager.getGlobalPinnedAgents(); + return localPinned.includes(agentId) || globalPinned.includes(agentId); + }, [agentId]); + + const isCloudUser = serverUrl?.includes("api.letta.com"); + const showBottomBar = agentId && agentId !== "loading"; + + if (!showBottomBar) { + return null; + } + + return ( + + + Current agent: + {agentName || "Unnamed"} + {isPinned ? ( + (pinned ✓) + ) : ( + (type /pin to pin agent) + )} + + + {agentId} + {isCloudUser && ( + <> + · + + Open in ADE ↗ + + · + + View usage ↗ + + + )} + {!isCloudUser && · {serverUrl}} + + + ); +} diff --git a/src/cli/components/CommandPreview.tsx b/src/cli/components/CommandPreview.tsx deleted file mode 100644 index 40fd546..0000000 --- a/src/cli/components/CommandPreview.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Box, Text } from "ink"; -import Link from "ink-link"; -import { useMemo } from "react"; -import { settingsManager } from "../../settings-manager"; -import { commands } from "../commands/registry"; -import { colors } from "./colors"; - -// Compute command list once at module level since it never changes -// Filter out hidden commands -const commandList = Object.entries(commands) - .filter(([, { hidden }]) => !hidden) - .map(([cmd, { desc }]) => ({ - cmd, - desc, - })) - .sort((a, b) => a.cmd.localeCompare(b.cmd)); - -export function CommandPreview({ - currentInput, - agentId, - agentName, - serverUrl, -}: { - currentInput: string; - agentId?: string; - agentName?: string | null; - serverUrl?: string; -}) { - // Check if current agent is pinned - const isPinned = useMemo(() => { - if (!agentId) return false; - const localPinned = settingsManager.getLocalPinnedAgents(); - const globalPinned = settingsManager.getGlobalPinnedAgents(); - return localPinned.includes(agentId) || globalPinned.includes(agentId); - }, [agentId]); - - if (!currentInput.startsWith("/")) { - return null; - } - - const isCloudUser = serverUrl?.includes("api.letta.com"); - const showBottomBar = agentId && agentId !== "loading"; - - return ( - - {commandList.map((item) => ( - - - {item.cmd.padEnd(15)} {item.desc} - - - ))} - {showBottomBar && ( - - - Current agent: - {agentName || "Unnamed"} - {isPinned ? ( - (pinned ✓) - ) : ( - (type /pin to pin agent) - )} - - - {agentId} - {isCloudUser && ( - <> - · - - Open in ADE ↗ - - · - - View usage ↗ - - - )} - {!isCloudUser && · {serverUrl}} - - - )} - - ); -} diff --git a/src/cli/components/FileAutocomplete.tsx b/src/cli/components/FileAutocomplete.tsx index 58552c1..f083075 100644 --- a/src/cli/components/FileAutocomplete.tsx +++ b/src/cli/components/FileAutocomplete.tsx @@ -1,18 +1,53 @@ -import { Box, Text, useInput } from "ink"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { Box, Text } from "ink"; +import { useEffect, useRef, useState } from "react"; import { searchFiles } from "../helpers/fileSearch"; +import { useAutocompleteNavigation } from "../hooks/useAutocompleteNavigation"; import { colors } from "./colors"; +import type { AutocompleteProps, FileMatch } from "./types/autocomplete"; -interface FileMatch { - path: string; - type: "file" | "dir" | "url"; -} +// Extract the text after the "@" symbol where the cursor is positioned +function extractSearchQuery( + 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); + } + } + } -interface FileAutocompleteProps { - currentInput: string; - cursorPosition?: number; - onSelect?: (path: string) => void; - onActiveChange?: (isActive: boolean) => void; + 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 }; } export function FileAutocomplete({ @@ -20,78 +55,19 @@ export function FileAutocomplete({ cursorPosition = currentInput.length, onSelect, onActiveChange, -}: FileAutocompleteProps) { +}: AutocompleteProps) { 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); - } - } + // Use shared navigation hook (with manual active state management due to async loading) + const { selectedIndex } = useAutocompleteNavigation({ + matches, + maxVisible: 10, + onSelect: onSelect ? (item) => onSelect(item.path) : undefined, + manageActiveState: false, // We manage active state manually due to async loading + disabled: isLoading, }); useEffect(() => { @@ -104,7 +80,6 @@ export function FileAutocomplete({ if (!result) { setMatches([]); - setSelectedIndex(0); onActiveChange?.(false); return; } @@ -120,7 +95,6 @@ export function FileAutocomplete({ // 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; } @@ -131,7 +105,6 @@ export function FileAutocomplete({ // 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; @@ -139,7 +112,6 @@ export function FileAutocomplete({ // No valid selection was made, hide setMatches([]); - setSelectedIndex(0); onActiveChange?.(false); return; } @@ -151,13 +123,11 @@ export function FileAutocomplete({ 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); }); @@ -167,7 +137,6 @@ export function FileAutocomplete({ // 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; } @@ -182,7 +151,6 @@ export function FileAutocomplete({ searchFiles(query, true) // Enable deep search .then((results) => { setMatches(results); - setSelectedIndex(0); setIsLoading(false); onActiveChange?.(results.length > 0); // Remember this query had valid matches @@ -192,7 +160,6 @@ export function FileAutocomplete({ }) .catch(() => { setMatches([]); - setSelectedIndex(0); setIsLoading(false); onActiveChange?.(false); }); @@ -208,7 +175,6 @@ export function FileAutocomplete({ currentInput, cursorPosition, onActiveChange, - extractSearchQuery, lastValidQuery, matches[0]?.path, ]); @@ -238,22 +204,25 @@ export function FileAutocomplete({ {matches.length > 0 ? ( <> {matches.slice(0, 10).map((item, idx) => ( - + + {idx === selectedIndex ? "▶ " : " "} - {idx === selectedIndex ? "▶ " : " "} {item.type === "dir" ? "📁" : item.type === "url" ? "🔗" : "📄"} - - {item.path} - + {" "} + {item.path} + ))} {matches.length > 10 && ( ... and {matches.length - 10} more diff --git a/src/cli/components/Input.tsx b/src/cli/components/Input.tsx deleted file mode 100644 index a7dca4a..0000000 --- a/src/cli/components/Input.tsx +++ /dev/null @@ -1,129 +0,0 @@ -// Import useInput from vendored Ink for bracketed paste support -import { Box, Text, useInput } from "ink"; -import { useEffect, useRef, useState } from "react"; -import { CommandPreview } from "./CommandPreview"; -import { PasteAwareTextInput } from "./PasteAwareTextInput"; - -// Only show token count when it exceeds this threshold -const COUNTER_VISIBLE_THRESHOLD = 1000; - -// Stable reference to prevent re-renders during typing -const EMPTY_STATUS = " "; - -export function Input({ - streaming, - tokenCount, - thinkingMessage, - onSubmit, -}: { - streaming: boolean; - tokenCount: number; - thinkingMessage: string; - onSubmit: (message?: string) => void; -}) { - const [value, setValue] = useState(""); - const [escapePressed, setEscapePressed] = useState(false); - const escapeTimerRef = useRef | null>(null); - const [ctrlCPressed, setCtrlCPressed] = useState(false); - const ctrlCTimerRef = useRef | null>(null); - const previousValueRef = useRef(value); - - // Handle escape key for double-escape-to-clear - useInput((_input, key) => { - if (key.escape && value) { - // Only work when input is non-empty - if (escapePressed) { - // Second escape - clear input - setValue(""); - setEscapePressed(false); - if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current); - } else { - // First escape - start 1-second timer - setEscapePressed(true); - if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current); - escapeTimerRef.current = setTimeout(() => { - setEscapePressed(false); - }, 1000); - } - } - }); - - // Handle CTRL-C for double-ctrl-c-to-exit - useInput((input, key) => { - if (input === "c" && key.ctrl) { - if (ctrlCPressed) { - // Second CTRL-C - exit application - process.exit(0); - } else { - // First CTRL-C - start 1-second timer - setCtrlCPressed(true); - if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current); - ctrlCTimerRef.current = setTimeout(() => { - setCtrlCPressed(false); - }, 1000); - } - } - }); - - // Reset escape and ctrl-c state when user types (value changes) - useEffect(() => { - if (value !== previousValueRef.current && value !== "") { - setEscapePressed(false); - if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current); - setCtrlCPressed(false); - if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current); - } - previousValueRef.current = value; - }, [value]); - - // Clean up timers on unmount - useEffect(() => { - return () => { - if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current); - if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current); - }; - }, []); - - const handleSubmit = () => { - if (streaming) { - return; - } - onSubmit(value); - setValue(""); - }; - - const footerText = ctrlCPressed - ? "Press CTRL-C again to exit" - : escapePressed - ? "Press Esc again to clear" - : "Press / for commands"; - - const thinkingText = streaming - ? tokenCount > COUNTER_VISIBLE_THRESHOLD - ? `${thinkingMessage}… (${tokenCount}↑)` - : `${thinkingMessage}…` - : EMPTY_STATUS; - - return ( - - {/* Live status / token counter (per-turn) - always takes up space to prevent layout shift */} - {thinkingText} - - {"> "} - - - {value.startsWith("/") ? ( - - ) : ( - - {footerText} - Letta Code v0.1 - - )} - - ); -} diff --git a/src/cli/components/InputAssist.tsx b/src/cli/components/InputAssist.tsx index 61b9e38..982f955 100644 --- a/src/cli/components/InputAssist.tsx +++ b/src/cli/components/InputAssist.tsx @@ -1,10 +1,13 @@ -import { CommandPreview } from "./CommandPreview"; +import { Box } from "ink"; +import { AgentInfoBar } from "./AgentInfoBar"; import { FileAutocomplete } from "./FileAutocomplete"; +import { SlashCommandAutocomplete } from "./SlashCommandAutocomplete"; interface InputAssistProps { currentInput: string; cursorPosition: number; onFileSelect: (path: string) => void; + onCommandSelect: (command: string) => void; onAutocompleteActiveChange: (isActive: boolean) => void; agentId?: string; agentName?: string | null; @@ -14,13 +17,14 @@ interface InputAssistProps { /** * Shows contextual assistance below the input: * - File autocomplete when "@" is detected - * - Command preview when "/" is detected + * - Slash command autocomplete when "/" is detected * - Nothing otherwise */ export function InputAssist({ currentInput, cursorPosition, onFileSelect, + onCommandSelect, onAutocompleteActiveChange, agentId, agentName, @@ -38,15 +42,22 @@ export function InputAssist({ ); } - // Show command preview when input starts with / + // Show slash command autocomplete when input starts with / if (currentInput.startsWith("/")) { return ( - + + + + ); } diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 33cd747..ee5fac6 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -453,6 +453,35 @@ export function Input({ setCursorPos(newCursorPos); }; + // Handle slash command selection from autocomplete + const handleCommandSelect = (selectedCommand: string) => { + // Find the "/" at start of input and replace the command + const slashIndex = value.indexOf("/"); + if (slashIndex === -1) return; + + const beforeSlash = value.slice(0, slashIndex); + const afterSlash = value.slice(slashIndex + 1); + const spaceIndex = afterSlash.indexOf(" "); + + let newValue: string; + let newCursorPos: number; + + // Replace the command part with the selected command + if (spaceIndex === -1) { + // No space after /command, replace to end + newValue = `${beforeSlash}${selectedCommand} `; + newCursorPos = newValue.length; + } else { + // Space exists, replace only the command part + const afterCommand = afterSlash.slice(spaceIndex); + newValue = `${beforeSlash}${selectedCommand}${afterCommand}`; + newCursorPos = beforeSlash.length + selectedCommand.length; + } + + setValue(newValue); + setCursorPos(newCursorPos); + }; + // Get display name and color for permission mode const getModeInfo = () => { switch (currentMode) { @@ -542,6 +571,7 @@ export function Input({ currentInput={value} cursorPosition={currentCursorPosition} onFileSelect={handleFileSelect} + onCommandSelect={handleCommandSelect} onAutocompleteActiveChange={setIsAutocompleteActive} agentId={agentId} agentName={agentName} diff --git a/src/cli/components/SlashCommandAutocomplete.tsx b/src/cli/components/SlashCommandAutocomplete.tsx new file mode 100644 index 0000000..b649654 --- /dev/null +++ b/src/cli/components/SlashCommandAutocomplete.tsx @@ -0,0 +1,121 @@ +import { Box, Text } from "ink"; +import { useEffect, useState } from "react"; +import { commands } from "../commands/registry"; +import { useAutocompleteNavigation } from "../hooks/useAutocompleteNavigation"; +import { colors } from "./colors"; +import type { AutocompleteProps, CommandMatch } from "./types/autocomplete"; + +// Compute filtered command list (excluding hidden commands) +const allCommands: CommandMatch[] = Object.entries(commands) + .filter(([, { hidden }]) => !hidden) + .map(([cmd, { desc }]) => ({ + cmd, + desc, + })) + .sort((a, b) => a.cmd.localeCompare(b.cmd)); + +// Extract the text after the "/" symbol where the cursor is positioned +function extractSearchQuery( + input: string, + cursor: number, +): { query: string; hasSpaceAfter: boolean } | null { + if (!input.startsWith("/")) return null; + + const afterSlash = input.slice(1); + const spaceIndex = afterSlash.indexOf(" "); + const endPos = spaceIndex === -1 ? input.length : 1 + spaceIndex; + + // Check if cursor is within this /command + if (cursor < 0 || cursor > endPos) { + return null; + } + + const query = + spaceIndex === -1 ? afterSlash : afterSlash.slice(0, spaceIndex); + const hasSpaceAfter = spaceIndex !== -1; + + return { query, hasSpaceAfter }; +} + +export function SlashCommandAutocomplete({ + currentInput, + cursorPosition = currentInput.length, + onSelect, + onActiveChange, +}: AutocompleteProps) { + const [matches, setMatches] = useState([]); + + const { selectedIndex } = useAutocompleteNavigation({ + matches, + onSelect: onSelect ? (item) => onSelect(item.cmd) : undefined, + onActiveChange, + }); + + // Update matches when input changes + useEffect(() => { + const result = extractSearchQuery(currentInput, cursorPosition); + + if (!result) { + setMatches([]); + return; + } + + const { query, hasSpaceAfter } = result; + + // If there's a space after the command, user has moved on - hide autocomplete + if (hasSpaceAfter) { + setMatches([]); + return; + } + + let newMatches: CommandMatch[]; + + // If query is empty (just typed "/"), show all commands + if (query.length === 0) { + newMatches = allCommands; + } else { + // Filter commands that contain the query (case-insensitive) + // Match against the command name without the leading "/" + const lowerQuery = query.toLowerCase(); + newMatches = allCommands.filter((item) => { + const cmdName = item.cmd.slice(1).toLowerCase(); // Remove leading "/" + return cmdName.includes(lowerQuery); + }); + } + + setMatches(newMatches); + }, [currentInput, cursorPosition]); + + // Don't show if input doesn't start with "/" + if (!currentInput.startsWith("/")) { + return null; + } + + // Don't show if no matches + if (matches.length === 0) { + return null; + } + + return ( + + ↑↓ navigate, Tab/Enter select + {matches.map((item, idx) => ( + + {idx === selectedIndex ? "▶ " : " "} + {item.cmd.padEnd(14)}{" "} + {item.desc} + + ))} + + ); +} diff --git a/src/cli/components/types/autocomplete.ts b/src/cli/components/types/autocomplete.ts new file mode 100644 index 0000000..625a7e4 --- /dev/null +++ b/src/cli/components/types/autocomplete.ts @@ -0,0 +1,33 @@ +/** + * Shared types for autocomplete components + */ + +/** + * Base props shared by all autocomplete components + */ +export interface AutocompleteProps { + /** Current input text from the user */ + currentInput: string; + /** Current cursor position in the input */ + cursorPosition?: number; + /** Callback when an item is selected */ + onSelect?: (value: string) => void; + /** Callback when autocomplete active state changes */ + onActiveChange?: (isActive: boolean) => void; +} + +/** + * File autocomplete match item + */ +export interface FileMatch { + path: string; + type: "file" | "dir" | "url"; +} + +/** + * Slash command autocomplete match item + */ +export interface CommandMatch { + cmd: string; + desc: string; +} diff --git a/src/cli/hooks/useAutocompleteNavigation.ts b/src/cli/hooks/useAutocompleteNavigation.ts new file mode 100644 index 0000000..1f7e559 --- /dev/null +++ b/src/cli/hooks/useAutocompleteNavigation.ts @@ -0,0 +1,73 @@ +import { useInput } from "ink"; +import { useEffect, useRef, useState } from "react"; + +interface UseAutocompleteNavigationOptions { + /** Array of items to navigate through */ + matches: T[]; + /** Maximum number of visible items (for wrapping navigation) */ + maxVisible?: number; + /** Callback when an item is selected via Tab or Enter */ + onSelect?: (item: T) => void; + /** Callback when active state changes (has matches or not) */ + onActiveChange?: (isActive: boolean) => void; + /** Skip automatic active state management (for components with async loading) */ + manageActiveState?: boolean; + /** Whether navigation is currently disabled (e.g., during loading) */ + disabled?: boolean; +} + +interface UseAutocompleteNavigationResult { + /** Currently selected index */ + selectedIndex: number; +} + +/** + * Shared hook for autocomplete keyboard navigation. + * Handles up/down arrow keys for selection and Tab/Enter for confirmation. + */ +export function useAutocompleteNavigation({ + matches, + maxVisible = 10, + onSelect, + onActiveChange, + manageActiveState = true, + disabled = false, +}: UseAutocompleteNavigationOptions): UseAutocompleteNavigationResult { + const [selectedIndex, setSelectedIndex] = useState(0); + const prevMatchCountRef = useRef(0); + + // Reset selected index when matches change significantly + useEffect(() => { + if (matches.length !== prevMatchCountRef.current) { + setSelectedIndex(0); + prevMatchCountRef.current = matches.length; + } + }, [matches.length]); + + // Notify parent about active state changes (only if manageActiveState is true) + useEffect(() => { + if (manageActiveState) { + onActiveChange?.(matches.length > 0); + } + }, [matches.length, onActiveChange, manageActiveState]); + + // Handle keyboard navigation + useInput((_input, key) => { + if (!matches.length || disabled) return; + + const maxIndex = Math.min(matches.length, maxVisible) - 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) { + const selected = matches[selectedIndex]; + if (selected) { + onSelect(selected); + } + } + }); + + return { selectedIndex }; +}