chore: Tab for slash command autocomplete (#249)
This commit is contained in:
54
src/cli/components/Autocomplete.tsx
Normal file
54
src/cli/components/Autocomplete.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user