feat: Autocomplete for slash commands (#216)

This commit is contained in:
Devansh Jain
2025-12-15 12:54:52 -08:00
committed by GitHub
parent 3c0b60f82d
commit 7ce41e52f4
9 changed files with 416 additions and 326 deletions

View File

@@ -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 (
<Box
flexDirection="column"
borderStyle="round"
borderColor={colors.command.border}
paddingX={1}
marginBottom={1}
>
<Box>
<Text color="gray">Current agent: </Text>
<Text bold>{agentName || "Unnamed"}</Text>
{isPinned ? (
<Text color="green"> (pinned )</Text>
) : (
<Text color="gray"> (type /pin to pin agent)</Text>
)}
</Box>
<Box>
<Text dimColor>{agentId}</Text>
{isCloudUser && (
<>
<Text dimColor> · </Text>
<Link url={`https://app.letta.com/agents/${agentId}`}>
<Text color={colors.link.text}>Open in ADE </Text>
</Link>
<Text dimColor> · </Text>
<Link url="https://app.letta.com/settings/organization/usage">
<Text color={colors.link.text}>View usage </Text>
</Link>
</>
)}
{!isCloudUser && <Text dimColor> · {serverUrl}</Text>}
</Box>
</Box>
);
}

View File

@@ -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 (
<Box
flexDirection="column"
borderStyle="round"
borderColor={colors.command.border}
paddingX={1}
>
{commandList.map((item) => (
<Box key={item.cmd}>
<Text>
{item.cmd.padEnd(15)} <Text dimColor>{item.desc}</Text>
</Text>
</Box>
))}
{showBottomBar && (
<Box marginTop={1} flexDirection="column">
<Box>
<Text color="gray">Current agent: </Text>
<Text bold>{agentName || "Unnamed"}</Text>
{isPinned ? (
<Text color="green"> (pinned )</Text>
) : (
<Text color="gray"> (type /pin to pin agent)</Text>
)}
</Box>
<Box>
<Text dimColor>{agentId}</Text>
{isCloudUser && (
<>
<Text dimColor> · </Text>
<Link url={`https://app.letta.com/agents/${agentId}`}>
<Text color={colors.link.text}>Open in ADE </Text>
</Link>
<Text dimColor>· </Text>
<Link url="https://app.letta.com/settings/organization/usage">
<Text color={colors.link.text}>View usage </Text>
</Link>
</>
)}
{!isCloudUser && <Text dimColor> · {serverUrl}</Text>}
</Box>
</Box>
)}
</Box>
);
}

View File

@@ -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<FileMatch[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [lastValidQuery, setLastValidQuery] = useState<string>("");
const debounceTimeout = useRef<NodeJS.Timeout | null>(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) => (
<Box key={item.path} flexDirection="row" gap={1}>
<Text
key={item.path}
color={
idx === selectedIndex ? colors.command.selected : undefined
}
bold={idx === selectedIndex}
>
{idx === selectedIndex ? "▶ " : " "}
<Text
color={
idx === selectedIndex
? colors.status.success
: item.type === "dir"
? colors.status.processing
: undefined
idx !== selectedIndex && item.type === "dir"
? colors.status.processing
: undefined
}
bold={idx === selectedIndex}
>
{idx === selectedIndex ? "▶ " : " "}
{item.type === "dir" ? "📁" : item.type === "url" ? "🔗" : "📄"}
</Text>
<Text bold={idx === selectedIndex}>{item.path}</Text>
</Box>
</Text>{" "}
{item.path}
</Text>
))}
{matches.length > 10 && (
<Text dimColor>... and {matches.length - 10} more</Text>

View File

@@ -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<ReturnType<typeof setTimeout> | null>(null);
const [ctrlCPressed, setCtrlCPressed] = useState(false);
const ctrlCTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<Box flexDirection="column" gap={1}>
{/* Live status / token counter (per-turn) - always takes up space to prevent layout shift */}
<Text dimColor>{thinkingText}</Text>
<Box>
<Text dimColor>{"> "}</Text>
<PasteAwareTextInput
value={value}
onChange={setValue}
onSubmit={handleSubmit}
/>
</Box>
{value.startsWith("/") ? (
<CommandPreview currentInput={value} />
) : (
<Box justifyContent="space-between">
<Text dimColor>{footerText}</Text>
<Text dimColor>Letta Code v0.1</Text>
</Box>
)}
</Box>
);
}

View File

@@ -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 (
<CommandPreview
currentInput={currentInput}
agentId={agentId}
agentName={agentName}
serverUrl={serverUrl}
/>
<Box flexDirection="column">
<SlashCommandAutocomplete
currentInput={currentInput}
cursorPosition={cursorPosition}
onSelect={onCommandSelect}
onActiveChange={onAutocompleteActiveChange}
/>
<AgentInfoBar
agentId={agentId}
agentName={agentName}
serverUrl={serverUrl}
/>
</Box>
);
}

View File

@@ -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}

View File

@@ -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<CommandMatch[]>([]);
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 (
<Box
flexDirection="column"
borderStyle="round"
borderColor={colors.command.border}
paddingX={1}
marginBottom={1}
>
<Text dimColor> navigate, Tab/Enter select</Text>
{matches.map((item, idx) => (
<Text
key={item.cmd}
color={idx === selectedIndex ? colors.command.selected : undefined}
bold={idx === selectedIndex}
>
{idx === selectedIndex ? "▶ " : " "}
{item.cmd.padEnd(14)}{" "}
<Text dimColor={idx !== selectedIndex}>{item.desc}</Text>
</Text>
))}
</Box>
);
}

View File

@@ -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;
}

View File

@@ -0,0 +1,73 @@
import { useInput } from "ink";
import { useEffect, useRef, useState } from "react";
interface UseAutocompleteNavigationOptions<T> {
/** 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<T>({
matches,
maxVisible = 10,
onSelect,
onActiveChange,
manageActiveState = true,
disabled = false,
}: UseAutocompleteNavigationOptions<T>): 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 };
}