Feat add autocomplete (#18)

Co-authored-by: Shubham Naik <shub@memgpt.ai>
This commit is contained in:
Shubham Naik
2025-10-28 14:24:56 -07:00
committed by GitHub
parent 4ac4412fcc
commit 948684dfac
9 changed files with 711 additions and 28 deletions

View 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>
);
}

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

View File

@@ -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>
);

View File

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