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