Feat add autocomplete (#18)
Co-authored-by: Shubham Naik <shub@memgpt.ai>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -148,3 +148,4 @@ vite.config.ts.timestamp-*
|
||||
|
||||
# Letta Code local settings
|
||||
.letta/settings.local.json
|
||||
.idea
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -201,11 +201,13 @@ export function onChunk(
|
||||
// TODO remove once SDK v1 has proper typing for in-stream errors
|
||||
// Check for streaming error objects (not typed in SDK but emitted by backend)
|
||||
// These are emitted when LLM errors occur during streaming (rate limits, timeouts, etc.)
|
||||
const chunkAny = chunk as any;
|
||||
if (chunkAny.error && !chunk.messageType) {
|
||||
const chunkWithError = chunk as typeof chunk & {
|
||||
error?: { message?: string; detail?: string };
|
||||
};
|
||||
if (chunkWithError.error && !chunk.messageType) {
|
||||
const errorId = `err-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const errorMsg = chunkAny.error.message || "An error occurred";
|
||||
const errorDetail = chunkAny.error.detail || "";
|
||||
const errorMsg = chunkWithError.error.message || "An error occurred";
|
||||
const errorDetail = chunkWithError.error.detail || "";
|
||||
const fullErrorText = errorDetail
|
||||
? `${errorMsg}: ${errorDetail}`
|
||||
: errorMsg;
|
||||
|
||||
176
src/cli/helpers/fileSearch.ts
Normal file
176
src/cli/helpers/fileSearch.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
interface FileMatch {
|
||||
path: string;
|
||||
type: "file" | "dir" | "url";
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively search a directory for files matching a pattern
|
||||
*/
|
||||
function searchDirectoryRecursive(
|
||||
dir: string,
|
||||
pattern: string,
|
||||
maxDepth: number = 3,
|
||||
currentDepth: number = 0,
|
||||
maxResults: number = 100,
|
||||
results: FileMatch[] = [],
|
||||
): FileMatch[] {
|
||||
if (currentDepth > maxDepth || results.length >= maxResults) {
|
||||
return results;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(dir);
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files and common ignore patterns
|
||||
if (
|
||||
entry.startsWith(".") ||
|
||||
entry === "node_modules" ||
|
||||
entry === "dist" ||
|
||||
entry === "build"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const fullPath = join(dir, entry);
|
||||
const stats = statSync(fullPath);
|
||||
|
||||
// Check if entry matches the pattern
|
||||
const matches =
|
||||
pattern.length === 0 ||
|
||||
entry.toLowerCase().includes(pattern.toLowerCase());
|
||||
|
||||
if (matches) {
|
||||
const relativePath = fullPath.startsWith(process.cwd())
|
||||
? fullPath.slice(process.cwd().length + 1)
|
||||
: fullPath;
|
||||
|
||||
results.push({
|
||||
path: relativePath,
|
||||
type: stats.isDirectory() ? "dir" : "file",
|
||||
});
|
||||
|
||||
if (results.length >= maxResults) {
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively search subdirectories
|
||||
if (stats.isDirectory()) {
|
||||
searchDirectoryRecursive(
|
||||
fullPath,
|
||||
pattern,
|
||||
maxDepth,
|
||||
currentDepth + 1,
|
||||
maxResults,
|
||||
results,
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {
|
||||
// Can't read directory, skip
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for files and directories matching the query
|
||||
* @param query - The search query (partial file path)
|
||||
* @param deep - Whether to search recursively through subdirectories
|
||||
* @returns Array of matching files and directories
|
||||
*/
|
||||
export async function searchFiles(
|
||||
query: string,
|
||||
deep: boolean = false,
|
||||
): Promise<FileMatch[]> {
|
||||
const results: FileMatch[] = [];
|
||||
|
||||
try {
|
||||
// Determine the directory to search in
|
||||
let searchDir = process.cwd();
|
||||
let searchPattern = query;
|
||||
|
||||
// Handle relative paths like "./src" or "../test"
|
||||
if (query.includes("/")) {
|
||||
const lastSlashIndex = query.lastIndexOf("/");
|
||||
const dirPart = query.slice(0, lastSlashIndex);
|
||||
searchPattern = query.slice(lastSlashIndex + 1);
|
||||
|
||||
// Resolve the directory path
|
||||
try {
|
||||
searchDir = resolve(process.cwd(), dirPart);
|
||||
} catch {
|
||||
// If path doesn't exist, return empty results
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (deep) {
|
||||
// Deep search: recursively search subdirectories
|
||||
const deepResults = searchDirectoryRecursive(
|
||||
searchDir,
|
||||
searchPattern,
|
||||
3, // Max depth of 3 levels
|
||||
0,
|
||||
100, // Max 100 results
|
||||
);
|
||||
results.push(...deepResults);
|
||||
} else {
|
||||
// Shallow search: only current directory
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = readdirSync(searchDir);
|
||||
} catch {
|
||||
// Directory doesn't exist or can't be read
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter entries matching the search pattern
|
||||
// If pattern is empty, show all entries (for when user just types "@")
|
||||
const matchingEntries =
|
||||
searchPattern.length === 0
|
||||
? entries
|
||||
: entries.filter((entry) =>
|
||||
entry.toLowerCase().includes(searchPattern.toLowerCase()),
|
||||
);
|
||||
|
||||
// Get stats for each matching entry
|
||||
for (const entry of matchingEntries.slice(0, 50)) {
|
||||
// Limit to 50 results
|
||||
try {
|
||||
const fullPath = join(searchDir, entry);
|
||||
const stats = statSync(fullPath);
|
||||
|
||||
// Make path relative to cwd if possible
|
||||
const relativePath = fullPath.startsWith(process.cwd())
|
||||
? fullPath.slice(process.cwd().length + 1)
|
||||
: fullPath;
|
||||
|
||||
results.push({
|
||||
path: relativePath,
|
||||
type: stats.isDirectory() ? "dir" : "file",
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: directories first, then files, alphabetically within each group
|
||||
results.sort((a, b) => {
|
||||
if (a.type === "dir" && b.type !== "dir") return -1;
|
||||
if (a.type !== "dir" && b.type === "dir") return 1;
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
} catch (error) {
|
||||
// Return empty array on any error
|
||||
console.error("File search error:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -147,7 +147,14 @@ export async function handleHeadlessCommand(argv: string[]) {
|
||||
|
||||
// Track approval requests
|
||||
if (chunk.messageType === "approval_request_message") {
|
||||
const toolCall = (chunk as any).toolCall;
|
||||
const chunkWithToolCall = chunk as typeof chunk & {
|
||||
toolCall?: {
|
||||
toolCallId?: string;
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
};
|
||||
};
|
||||
const toolCall = chunkWithToolCall.toolCall;
|
||||
if (toolCall?.toolCallId && toolCall?.name) {
|
||||
approval = {
|
||||
toolCallId: toolCall.toolCallId,
|
||||
|
||||
146
src/tests/fileSearch.test.ts
Normal file
146
src/tests/fileSearch.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { searchFiles } from "../cli/helpers/fileSearch";
|
||||
|
||||
const TEST_DIR = join(process.cwd(), ".test-filesearch");
|
||||
|
||||
beforeEach(() => {
|
||||
// Create test directory structure
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
mkdirSync(join(TEST_DIR, "src"), { recursive: true });
|
||||
mkdirSync(join(TEST_DIR, "src/components"), { recursive: true });
|
||||
mkdirSync(join(TEST_DIR, "tests"), { recursive: true });
|
||||
|
||||
// Create test files
|
||||
writeFileSync(join(TEST_DIR, "README.md"), "# Test");
|
||||
writeFileSync(join(TEST_DIR, "package.json"), "{}");
|
||||
writeFileSync(join(TEST_DIR, "src/index.ts"), "console.log('test')");
|
||||
writeFileSync(join(TEST_DIR, "src/App.tsx"), "export default App");
|
||||
writeFileSync(join(TEST_DIR, "src/components/Button.tsx"), "export Button");
|
||||
writeFileSync(join(TEST_DIR, "tests/app.test.ts"), "test()");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test directory
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("searchFiles finds files in current directory (shallow)", async () => {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(TEST_DIR);
|
||||
|
||||
const results = await searchFiles("", false);
|
||||
|
||||
process.chdir(originalCwd);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.some((r) => r.path === "README.md")).toBe(true);
|
||||
expect(results.some((r) => r.path === "package.json")).toBe(true);
|
||||
});
|
||||
|
||||
test("searchFiles filters by pattern (shallow)", async () => {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(TEST_DIR);
|
||||
|
||||
const results = await searchFiles("README", false);
|
||||
|
||||
process.chdir(originalCwd);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]?.path).toBe("README.md");
|
||||
expect(results[0]?.type).toBe("file");
|
||||
});
|
||||
|
||||
test("searchFiles finds files recursively (deep)", async () => {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(TEST_DIR);
|
||||
|
||||
const results = await searchFiles("App", true);
|
||||
|
||||
process.chdir(originalCwd);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.some((r) => r.path.includes("App.tsx"))).toBe(true);
|
||||
});
|
||||
|
||||
test("searchFiles finds files in subdirectories (deep)", async () => {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(TEST_DIR);
|
||||
|
||||
const results = await searchFiles("Button", true);
|
||||
|
||||
process.chdir(originalCwd);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
// Use platform-agnostic path check
|
||||
expect(results[0]?.path).toContain("components");
|
||||
expect(results[0]?.path).toContain("Button.tsx");
|
||||
expect(results[0]?.type).toBe("file");
|
||||
});
|
||||
|
||||
test("searchFiles identifies directories correctly", async () => {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(TEST_DIR);
|
||||
|
||||
const results = await searchFiles("", false);
|
||||
|
||||
process.chdir(originalCwd);
|
||||
|
||||
const srcDir = results.find((r) => r.path === "src");
|
||||
expect(srcDir).toBeDefined();
|
||||
expect(srcDir?.type).toBe("dir");
|
||||
});
|
||||
|
||||
test("searchFiles returns empty array for non-existent pattern", async () => {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(TEST_DIR);
|
||||
|
||||
const results = await searchFiles("nonexistent12345", true);
|
||||
|
||||
process.chdir(originalCwd);
|
||||
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
test("searchFiles case-insensitive matching", async () => {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(TEST_DIR);
|
||||
|
||||
const results = await searchFiles("readme", false);
|
||||
|
||||
process.chdir(originalCwd);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]?.path).toBe("README.md");
|
||||
});
|
||||
|
||||
test("searchFiles skips node_modules (deep)", async () => {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(TEST_DIR);
|
||||
|
||||
// Create node_modules directory
|
||||
mkdirSync(join(TEST_DIR, "node_modules/pkg"), { recursive: true });
|
||||
writeFileSync(join(TEST_DIR, "node_modules/pkg/index.js"), "module");
|
||||
|
||||
const results = await searchFiles("index", true);
|
||||
|
||||
process.chdir(originalCwd);
|
||||
|
||||
// Should find index.ts but not node_modules/pkg/index.js
|
||||
expect(results.some((r) => r.path.includes("node_modules"))).toBe(false);
|
||||
expect(results.some((r) => r.path.includes("index.ts"))).toBe(true);
|
||||
});
|
||||
|
||||
test("searchFiles handles relative path queries", async () => {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(TEST_DIR);
|
||||
|
||||
const results = await searchFiles("src/A", false);
|
||||
|
||||
process.chdir(originalCwd);
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
// Check that at least one result contains App.tsx
|
||||
expect(results.some((r) => r.path.includes("App.tsx"))).toBe(true);
|
||||
});
|
||||
Reference in New Issue
Block a user