chore: Tab for slash command autocomplete (#249)

This commit is contained in:
Devansh Jain
2025-12-16 16:46:13 -08:00
committed by GitHub
parent 0f6ec5e21b
commit e9d6b16e86
7 changed files with 110 additions and 46 deletions

View File

@@ -0,0 +1,54 @@
import { Box, Text } from "ink";
import type { ReactNode } from "react";
import { colors } from "./colors";
interface AutocompleteBoxProps {
/** Header text shown at top of autocomplete */
header: ReactNode;
children: ReactNode;
}
/**
* Shared container for autocomplete dropdowns.
* Provides consistent styling for both file and command autocomplete.
*/
export function AutocompleteBox({ header, children }: AutocompleteBoxProps) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={colors.command.border}
paddingX={1}
marginBottom={1}
>
<Text dimColor>{header}</Text>
{children}
</Box>
);
}
interface AutocompleteItemProps {
/** Whether this item is currently selected */
selected: boolean;
/** Unique key for React */
children: ReactNode;
}
/**
* Shared item component for autocomplete lists.
* Handles selection indicator and styling.
*/
export function AutocompleteItem({
selected,
children,
}: AutocompleteItemProps) {
return (
<Text
color={selected ? colors.command.selected : undefined}
bold={selected}
>
{selected ? "▶ " : " "}
{children}
</Text>
);
}

View File

@@ -1,7 +1,8 @@
import { Box, Text } from "ink";
import { Text } from "ink";
import { useEffect, useRef, useState } from "react";
import { searchFiles } from "../helpers/fileSearch";
import { useAutocompleteNavigation } from "../hooks/useAutocompleteNavigation";
import { AutocompleteBox, AutocompleteItem } from "./Autocomplete";
import { colors } from "./colors";
import type { AutocompleteProps, FileMatch } from "./types/autocomplete";
@@ -189,29 +190,19 @@ export function FileAutocomplete({
return null;
}
const header = (
<>
File/URL autocomplete ( navigate, Tab/Enter select):
{isLoading && " Searching..."}
</>
);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={colors.command.border}
paddingX={1}
marginBottom={1}
>
<Text dimColor>
File/URL autocomplete ( to navigate, Tab/Enter to select):
{isLoading && " Searching..."}
</Text>
<AutocompleteBox header={header}>
{matches.length > 0 ? (
<>
{matches.slice(0, 10).map((item, idx) => (
<Text
key={item.path}
color={
idx === selectedIndex ? colors.command.selected : undefined
}
bold={idx === selectedIndex}
>
{idx === selectedIndex ? "▶ " : " "}
<AutocompleteItem key={item.path} selected={idx === selectedIndex}>
<Text
color={
idx !== selectedIndex && item.type === "dir"
@@ -222,7 +213,7 @@ export function FileAutocomplete({
{item.type === "dir" ? "📁" : item.type === "url" ? "🔗" : "📄"}
</Text>{" "}
{item.path}
</Text>
</AutocompleteItem>
))}
{matches.length > 10 && (
<Text dimColor>... and {matches.length - 10} more</Text>
@@ -231,6 +222,6 @@ export function FileAutocomplete({
) : (
isLoading && <Text dimColor>Searching...</Text>
)}
</Box>
</AutocompleteBox>
);
}

View File

@@ -9,6 +9,7 @@ interface InputAssistProps {
cursorPosition: number;
onFileSelect: (path: string) => void;
onCommandSelect: (command: string) => void;
onCommandAutocomplete: (command: string) => void;
onAutocompleteActiveChange: (isActive: boolean) => void;
agentId?: string;
agentName?: string | null;
@@ -27,6 +28,7 @@ export function InputAssist({
cursorPosition,
onFileSelect,
onCommandSelect,
onCommandAutocomplete,
onAutocompleteActiveChange,
agentId,
agentName,
@@ -68,6 +70,7 @@ export function InputAssist({
currentInput={currentInput}
cursorPosition={cursorPosition}
onSelect={onCommandSelect}
onAutocomplete={onCommandAutocomplete}
onActiveChange={onAutocompleteActiveChange}
agentId={agentId}
workingDirectory={workingDirectory}

View File

@@ -458,10 +458,10 @@ export function Input({
setCursorPos(newCursorPos);
};
// Handle slash command selection from autocomplete
// Handle slash command selection from autocomplete (Enter key - execute)
const handleCommandSelect = async (selectedCommand: string) => {
// For slash commands, submit immediately when selected from autocomplete
// This provides a better UX - selecting /model should open the model selector
// For slash commands, submit immediately when selected via Enter
// This provides a better UX - pressing Enter on /model should open the model selector
const commandToSubmit = selectedCommand.trim();
// Add to history if not a duplicate of the last entry
@@ -477,6 +477,14 @@ export function Input({
await onSubmit(commandToSubmit);
};
// Handle slash command autocomplete (Tab key - fill text only)
const handleCommandAutocomplete = (selectedCommand: string) => {
// Just fill in the command text without executing
// User can then press Enter to execute or continue typing arguments
setValue(selectedCommand);
setCursorPos(selectedCommand.length);
};
// Get display name and color for permission mode
const getModeInfo = () => {
switch (currentMode) {
@@ -567,6 +575,7 @@ export function Input({
cursorPosition={currentCursorPosition}
onFileSelect={handleFileSelect}
onCommandSelect={handleCommandSelect}
onCommandAutocomplete={handleCommandAutocomplete}
onAutocompleteActiveChange={setIsAutocompleteActive}
agentId={agentId}
agentName={agentName}

View File

@@ -1,9 +1,9 @@
import { Box, Text } from "ink";
import { Text } from "ink";
import { useEffect, useMemo, useState } from "react";
import { settingsManager } from "../../settings-manager";
import { commands } from "../commands/registry";
import { useAutocompleteNavigation } from "../hooks/useAutocompleteNavigation";
import { colors } from "./colors";
import { AutocompleteBox, AutocompleteItem } from "./Autocomplete";
import type { AutocompleteProps, CommandMatch } from "./types/autocomplete";
// Compute filtered command list (excluding hidden commands)
@@ -42,6 +42,7 @@ export function SlashCommandAutocomplete({
currentInput,
cursorPosition = currentInput.length,
onSelect,
onAutocomplete,
onActiveChange,
agentId,
workingDirectory = process.cwd(),
@@ -82,6 +83,9 @@ export function SlashCommandAutocomplete({
const { selectedIndex } = useAutocompleteNavigation({
matches,
onSelect: onSelect ? (item) => onSelect(item.cmd) : undefined,
onAutocomplete: onAutocomplete
? (item) => onAutocomplete(item.cmd)
: undefined,
onActiveChange,
});
@@ -131,25 +135,13 @@ export function SlashCommandAutocomplete({
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={colors.command.border}
paddingX={1}
marginBottom={1}
>
<Text dimColor> navigate, Tab/Enter select</Text>
<AutocompleteBox header="↑↓ navigate, Tab autocomplete, Enter execute">
{matches.map((item, idx) => (
<Text
key={item.cmd}
color={idx === selectedIndex ? colors.command.selected : undefined}
bold={idx === selectedIndex}
>
{idx === selectedIndex ? "▶ " : " "}
<AutocompleteItem key={item.cmd} selected={idx === selectedIndex}>
{item.cmd.padEnd(14)}{" "}
<Text dimColor={idx !== selectedIndex}>{item.desc}</Text>
</Text>
</AutocompleteItem>
))}
</Box>
</AutocompleteBox>
);
}

View File

@@ -10,8 +10,10 @@ export interface AutocompleteProps {
currentInput: string;
/** Current cursor position in the input */
cursorPosition?: number;
/** Callback when an item is selected */
/** Callback when an item is selected (Enter key - may execute) */
onSelect?: (value: string) => void;
/** Callback when an item is autocompleted (Tab key - fill text only) */
onAutocomplete?: (value: string) => void;
/** Callback when autocomplete active state changes */
onActiveChange?: (isActive: boolean) => void;
/** Current agent ID for context-sensitive command filtering */

View File

@@ -6,8 +6,10 @@ interface UseAutocompleteNavigationOptions<T> {
matches: T[];
/** Maximum number of visible items (for wrapping navigation) */
maxVisible?: number;
/** Callback when an item is selected via Tab or Enter */
/** Callback when an item is selected via Tab (autocomplete only) or Enter (when onAutocomplete is not provided) */
onSelect?: (item: T) => void;
/** Callback when an item is autocompleted via Tab (fill text without executing) */
onAutocomplete?: (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) */
@@ -29,6 +31,7 @@ export function useAutocompleteNavigation<T>({
matches,
maxVisible,
onSelect,
onAutocomplete,
onActiveChange,
manageActiveState = true,
disabled = false,
@@ -65,7 +68,17 @@ export function useAutocompleteNavigation<T>({
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) {
} else if (key.tab) {
const selected = matches[selectedIndex];
if (selected) {
// Tab: use onAutocomplete if provided, otherwise fall back to onSelect
if (onAutocomplete) {
onAutocomplete(selected);
} else if (onSelect) {
onSelect(selected);
}
}
} else if (key.return && onSelect) {
const selected = matches[selectedIndex];
if (selected) {
onSelect(selected);