feat: Autocomplete for slash commands (#216)
This commit is contained in:
71
src/cli/components/AgentInfoBar.tsx
Normal file
71
src/cli/components/AgentInfoBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
121
src/cli/components/SlashCommandAutocomplete.tsx
Normal file
121
src/cli/components/SlashCommandAutocomplete.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/cli/components/types/autocomplete.ts
Normal file
33
src/cli/components/types/autocomplete.ts
Normal 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;
|
||||
}
|
||||
73
src/cli/hooks/useAutocompleteNavigation.ts
Normal file
73
src/cli/hooks/useAutocompleteNavigation.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user