Feat add autocomplete (#18)
Co-authored-by: Shubham Naik <shub@memgpt.ai>
This commit is contained in:
246
src/cli/components/FileAutocomplete.tsx
Normal file
246
src/cli/components/FileAutocomplete.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { searchFiles } from "../helpers/fileSearch";
|
||||
import { colors } from "./colors";
|
||||
|
||||
interface FileMatch {
|
||||
path: string;
|
||||
type: "file" | "dir" | "url";
|
||||
}
|
||||
|
||||
interface FileAutocompleteProps {
|
||||
currentInput: string;
|
||||
cursorPosition?: number;
|
||||
onSelect?: (path: string) => void;
|
||||
onActiveChange?: (isActive: boolean) => void;
|
||||
}
|
||||
|
||||
export function FileAutocomplete({
|
||||
currentInput,
|
||||
cursorPosition = currentInput.length,
|
||||
onSelect,
|
||||
onActiveChange,
|
||||
}: FileAutocompleteProps) {
|
||||
const [matches, setMatches] = useState<FileMatch[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [lastValidQuery, setLastValidQuery] = useState<string>("");
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const result = extractSearchQuery(currentInput, cursorPosition);
|
||||
|
||||
if (!result) {
|
||||
setMatches([]);
|
||||
setSelectedIndex(0);
|
||||
onActiveChange?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { query, hasSpaceAfter } = result;
|
||||
|
||||
// If there's text after the space, user has moved on - hide autocomplete
|
||||
// But keep it open if there's just a trailing space (allows editing the path)
|
||||
if (hasSpaceAfter && query.length > 0) {
|
||||
const atIndex = currentInput.lastIndexOf("@");
|
||||
const afterSpace = currentInput.slice(atIndex + 1 + query.length + 1);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Just a trailing space - check if this query had valid matches when selected
|
||||
// Use lastValidQuery to remember what was successfully selected
|
||||
if (query === lastValidQuery && lastValidQuery.length > 0) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// No valid selection was made, hide
|
||||
setMatches([]);
|
||||
setSelectedIndex(0);
|
||||
onActiveChange?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If query is empty (just typed "@"), show current directory contents
|
||||
if (query.length === 0) {
|
||||
setIsLoading(true);
|
||||
onActiveChange?.(true);
|
||||
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);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a URL pattern
|
||||
if (query.startsWith("http://") || query.startsWith("https://")) {
|
||||
setMatches([{ path: query, type: "url" }]);
|
||||
setSelectedIndex(0);
|
||||
onActiveChange?.(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Search for matching files (deep search through subdirectories)
|
||||
setIsLoading(true);
|
||||
onActiveChange?.(true);
|
||||
searchFiles(query, true) // Enable deep search
|
||||
.then((results) => {
|
||||
setMatches(results);
|
||||
setSelectedIndex(0);
|
||||
setIsLoading(false);
|
||||
onActiveChange?.(results.length > 0);
|
||||
// Remember this query had valid matches
|
||||
if (results.length > 0) {
|
||||
setLastValidQuery(query);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setMatches([]);
|
||||
setSelectedIndex(0);
|
||||
setIsLoading(false);
|
||||
onActiveChange?.(false);
|
||||
});
|
||||
}, [
|
||||
currentInput,
|
||||
cursorPosition,
|
||||
onActiveChange,
|
||||
extractSearchQuery,
|
||||
lastValidQuery,
|
||||
matches[0]?.path,
|
||||
]);
|
||||
|
||||
// Don't show if no "@" in input
|
||||
if (!currentInput.includes("@")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't show if no matches and not loading
|
||||
if (matches.length === 0 && !isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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):
|
||||
</Text>
|
||||
{isLoading ? (
|
||||
<Text dimColor>Searching...</Text>
|
||||
) : (
|
||||
matches.slice(0, 10).map((item, idx) => (
|
||||
<Box key={item.path} flexDirection="row" gap={1}>
|
||||
<Text
|
||||
color={
|
||||
idx === selectedIndex
|
||||
? colors.status.success
|
||||
: 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>
|
||||
))
|
||||
)}
|
||||
{matches.length > 10 && (
|
||||
<Text dimColor>... and {matches.length - 10} more</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
42
src/cli/components/InputAssist.tsx
Normal file
42
src/cli/components/InputAssist.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { CommandPreview } from "./CommandPreview";
|
||||
import { FileAutocomplete } from "./FileAutocomplete";
|
||||
|
||||
interface InputAssistProps {
|
||||
currentInput: string;
|
||||
cursorPosition: number;
|
||||
onFileSelect: (path: string) => void;
|
||||
onAutocompleteActiveChange: (isActive: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows contextual assistance below the input:
|
||||
* - File autocomplete when "@" is detected
|
||||
* - Command preview when "/" is detected
|
||||
* - Nothing otherwise
|
||||
*/
|
||||
export function InputAssist({
|
||||
currentInput,
|
||||
cursorPosition,
|
||||
onFileSelect,
|
||||
onAutocompleteActiveChange,
|
||||
}: InputAssistProps) {
|
||||
// Show file autocomplete when @ is present
|
||||
if (currentInput.includes("@")) {
|
||||
return (
|
||||
<FileAutocomplete
|
||||
currentInput={currentInput}
|
||||
cursorPosition={cursorPosition}
|
||||
onSelect={onFileSelect}
|
||||
onActiveChange={onAutocompleteActiveChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Show command preview when input starts with /
|
||||
if (currentInput.startsWith("/")) {
|
||||
return <CommandPreview currentInput={currentInput} />;
|
||||
}
|
||||
|
||||
// No assistance needed
|
||||
return null;
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { PermissionMode } from "../../permissions/mode";
|
||||
import { permissionMode } from "../../permissions/mode";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { CommandPreview } from "./CommandPreview";
|
||||
import { colors } from "./colors";
|
||||
import { InputAssist } from "./InputAssist";
|
||||
import { PasteAwareTextInput } from "./PasteAwareTextInput";
|
||||
import { ShimmerText } from "./ShimmerText";
|
||||
|
||||
@@ -51,6 +51,9 @@ export function Input({
|
||||
const [currentMode, setCurrentMode] = useState<PermissionMode>(
|
||||
externalMode || permissionMode.getMode(),
|
||||
);
|
||||
const [isAutocompleteActive, setIsAutocompleteActive] = useState(false);
|
||||
const [cursorPos, setCursorPos] = useState<number | undefined>(undefined);
|
||||
const [currentCursorPosition, setCurrentCursorPosition] = useState(0);
|
||||
|
||||
// Sync with external mode changes (from plan approval dialog)
|
||||
useEffect(() => {
|
||||
@@ -172,6 +175,11 @@ export function Input({
|
||||
}, [streaming, thinkingMessage, visible]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Don't submit if autocomplete is active with matches
|
||||
if (isAutocompleteActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (streaming || commandRunning) {
|
||||
return;
|
||||
}
|
||||
@@ -184,6 +192,38 @@ export function Input({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file selection from autocomplete
|
||||
const handleFileSelect = (selectedPath: string) => {
|
||||
// Find the last "@" and replace everything after it with the selected path
|
||||
const atIndex = value.lastIndexOf("@");
|
||||
if (atIndex === -1) return;
|
||||
|
||||
const beforeAt = value.slice(0, atIndex);
|
||||
const afterAt = value.slice(atIndex + 1);
|
||||
const spaceIndex = afterAt.indexOf(" ");
|
||||
|
||||
let newValue: string;
|
||||
let newCursorPos: number;
|
||||
|
||||
// Replace the query part with the selected path
|
||||
if (spaceIndex === -1) {
|
||||
// No space after @query, replace to end
|
||||
newValue = `${beforeAt}@${selectedPath} `;
|
||||
newCursorPos = newValue.length;
|
||||
} else {
|
||||
// Space exists, replace only the query part
|
||||
const afterQuery = afterAt.slice(spaceIndex);
|
||||
newValue = `${beforeAt}@${selectedPath}${afterQuery}`;
|
||||
newCursorPos = beforeAt.length + selectedPath.length + 1; // After the path
|
||||
}
|
||||
|
||||
setValue(newValue);
|
||||
setCursorPos(newCursorPos);
|
||||
|
||||
// Reset cursor position after a short delay so it only applies once
|
||||
setTimeout(() => setCursorPos(undefined), 50);
|
||||
};
|
||||
|
||||
// Get display name and color for permission mode
|
||||
const getModeInfo = () => {
|
||||
switch (currentMode) {
|
||||
@@ -254,6 +294,8 @@ export function Input({
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={handleSubmit}
|
||||
cursorPosition={cursorPos}
|
||||
onCursorMove={setCurrentCursorPosition}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -261,28 +303,31 @@ export function Input({
|
||||
{/* Bottom horizontal divider */}
|
||||
<Text dimColor>{horizontalLine}</Text>
|
||||
|
||||
{value.startsWith("/") ? (
|
||||
<CommandPreview currentInput={value} />
|
||||
) : (
|
||||
<Box justifyContent="space-between" marginBottom={1}>
|
||||
{ctrlCPressed ? (
|
||||
<Text dimColor>Press CTRL-C again to exit</Text>
|
||||
) : escapePressed ? (
|
||||
<Text dimColor>Press Esc again to clear</Text>
|
||||
) : modeInfo ? (
|
||||
<Text>
|
||||
<Text color={modeInfo.color}>⏵⏵ {modeInfo.name}</Text>
|
||||
<Text color={modeInfo.color} dimColor>
|
||||
{" "}
|
||||
(shift+tab to cycle)
|
||||
</Text>
|
||||
<InputAssist
|
||||
currentInput={value}
|
||||
cursorPosition={currentCursorPosition}
|
||||
onFileSelect={handleFileSelect}
|
||||
onAutocompleteActiveChange={setIsAutocompleteActive}
|
||||
/>
|
||||
|
||||
<Box justifyContent="space-between" marginBottom={1}>
|
||||
{ctrlCPressed ? (
|
||||
<Text dimColor>Press CTRL-C again to exit</Text>
|
||||
) : escapePressed ? (
|
||||
<Text dimColor>Press Esc again to clear</Text>
|
||||
) : modeInfo ? (
|
||||
<Text>
|
||||
<Text color={modeInfo.color}>⏵⏵ {modeInfo.name}</Text>
|
||||
<Text color={modeInfo.color} dimColor>
|
||||
{" "}
|
||||
(shift+tab to cycle)
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>Press / for commands</Text>
|
||||
)}
|
||||
<Text dimColor>https://discord.gg/letta</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>Press / for commands or @ for files</Text>
|
||||
)}
|
||||
<Text dimColor>https://discord.gg/letta</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,8 @@ interface PasteAwareTextInputProps {
|
||||
onSubmit?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
focus?: boolean;
|
||||
cursorPosition?: number;
|
||||
onCursorMove?: (position: number) => void;
|
||||
}
|
||||
|
||||
function countLines(text: string): number {
|
||||
@@ -32,6 +34,8 @@ export function PasteAwareTextInput({
|
||||
onSubmit,
|
||||
placeholder,
|
||||
focus = true,
|
||||
cursorPosition,
|
||||
onCursorMove,
|
||||
}: PasteAwareTextInputProps) {
|
||||
const [displayValue, setDisplayValue] = useState(value);
|
||||
const [actualValue, setActualValue] = useState(value);
|
||||
@@ -41,6 +45,20 @@ export function PasteAwareTextInput({
|
||||
const [nudgeCursorOffset, setNudgeCursorOffset] = useState<
|
||||
number | undefined
|
||||
>(undefined);
|
||||
|
||||
// Apply cursor position from parent
|
||||
useEffect(() => {
|
||||
if (typeof cursorPosition === "number") {
|
||||
setNudgeCursorOffset(cursorPosition);
|
||||
caretOffsetRef.current = cursorPosition;
|
||||
}
|
||||
}, [cursorPosition]);
|
||||
|
||||
// Notify parent of cursor position changes
|
||||
// Default assumption: cursor is at the end when typing
|
||||
useEffect(() => {
|
||||
onCursorMove?.(displayValue.length);
|
||||
}, [displayValue, onCursorMove]);
|
||||
const TextInputAny = RawTextInput as unknown as React.ComponentType<{
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -218,7 +236,7 @@ export function PasteAwareTextInput({
|
||||
const resolved = resolvePlaceholders(newValue);
|
||||
setActualValue(resolved);
|
||||
onChange(newValue);
|
||||
// Default caret behavior on typing/appends: move to end
|
||||
// Default: cursor moves to end (most common case)
|
||||
caretOffsetRef.current = newValue.length;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user