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

1
.gitignore vendored
View File

@@ -148,3 +148,4 @@ vite.config.ts.timestamp-*
# Letta Code local settings # Letta Code local settings
.letta/settings.local.json .letta/settings.local.json
.idea

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 type { PermissionMode } from "../../permissions/mode";
import { permissionMode } from "../../permissions/mode"; import { permissionMode } from "../../permissions/mode";
import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { CommandPreview } from "./CommandPreview";
import { colors } from "./colors"; import { colors } from "./colors";
import { InputAssist } from "./InputAssist";
import { PasteAwareTextInput } from "./PasteAwareTextInput"; import { PasteAwareTextInput } from "./PasteAwareTextInput";
import { ShimmerText } from "./ShimmerText"; import { ShimmerText } from "./ShimmerText";
@@ -51,6 +51,9 @@ export function Input({
const [currentMode, setCurrentMode] = useState<PermissionMode>( const [currentMode, setCurrentMode] = useState<PermissionMode>(
externalMode || permissionMode.getMode(), 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) // Sync with external mode changes (from plan approval dialog)
useEffect(() => { useEffect(() => {
@@ -172,6 +175,11 @@ export function Input({
}, [streaming, thinkingMessage, visible]); }, [streaming, thinkingMessage, visible]);
const handleSubmit = async () => { const handleSubmit = async () => {
// Don't submit if autocomplete is active with matches
if (isAutocompleteActive) {
return;
}
if (streaming || commandRunning) { if (streaming || commandRunning) {
return; 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 // Get display name and color for permission mode
const getModeInfo = () => { const getModeInfo = () => {
switch (currentMode) { switch (currentMode) {
@@ -254,6 +294,8 @@ export function Input({
value={value} value={value}
onChange={setValue} onChange={setValue}
onSubmit={handleSubmit} onSubmit={handleSubmit}
cursorPosition={cursorPos}
onCursorMove={setCurrentCursorPosition}
/> />
</Box> </Box>
</Box> </Box>
@@ -261,28 +303,31 @@ export function Input({
{/* Bottom horizontal divider */} {/* Bottom horizontal divider */}
<Text dimColor>{horizontalLine}</Text> <Text dimColor>{horizontalLine}</Text>
{value.startsWith("/") ? ( <InputAssist
<CommandPreview currentInput={value} /> currentInput={value}
) : ( cursorPosition={currentCursorPosition}
<Box justifyContent="space-between" marginBottom={1}> onFileSelect={handleFileSelect}
{ctrlCPressed ? ( onAutocompleteActiveChange={setIsAutocompleteActive}
<Text dimColor>Press CTRL-C again to exit</Text> />
) : escapePressed ? (
<Text dimColor>Press Esc again to clear</Text> <Box justifyContent="space-between" marginBottom={1}>
) : modeInfo ? ( {ctrlCPressed ? (
<Text> <Text dimColor>Press CTRL-C again to exit</Text>
<Text color={modeInfo.color}> {modeInfo.name}</Text> ) : escapePressed ? (
<Text color={modeInfo.color} dimColor> <Text dimColor>Press Esc again to clear</Text>
{" "} ) : modeInfo ? (
(shift+tab to cycle) <Text>
</Text> <Text color={modeInfo.color}> {modeInfo.name}</Text>
<Text color={modeInfo.color} dimColor>
{" "}
(shift+tab to cycle)
</Text> </Text>
) : ( </Text>
<Text dimColor>Press / for commands</Text> ) : (
)} <Text dimColor>Press / for commands or @ for files</Text>
<Text dimColor>https://discord.gg/letta</Text> )}
</Box> <Text dimColor>https://discord.gg/letta</Text>
)} </Box>
</Box> </Box>
</Box> </Box>
); );

View File

@@ -20,6 +20,8 @@ interface PasteAwareTextInputProps {
onSubmit?: (value: string) => void; onSubmit?: (value: string) => void;
placeholder?: string; placeholder?: string;
focus?: boolean; focus?: boolean;
cursorPosition?: number;
onCursorMove?: (position: number) => void;
} }
function countLines(text: string): number { function countLines(text: string): number {
@@ -32,6 +34,8 @@ export function PasteAwareTextInput({
onSubmit, onSubmit,
placeholder, placeholder,
focus = true, focus = true,
cursorPosition,
onCursorMove,
}: PasteAwareTextInputProps) { }: PasteAwareTextInputProps) {
const [displayValue, setDisplayValue] = useState(value); const [displayValue, setDisplayValue] = useState(value);
const [actualValue, setActualValue] = useState(value); const [actualValue, setActualValue] = useState(value);
@@ -41,6 +45,20 @@ export function PasteAwareTextInput({
const [nudgeCursorOffset, setNudgeCursorOffset] = useState< const [nudgeCursorOffset, setNudgeCursorOffset] = useState<
number | undefined number | undefined
>(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<{ const TextInputAny = RawTextInput as unknown as React.ComponentType<{
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
@@ -218,7 +236,7 @@ export function PasteAwareTextInput({
const resolved = resolvePlaceholders(newValue); const resolved = resolvePlaceholders(newValue);
setActualValue(resolved); setActualValue(resolved);
onChange(newValue); onChange(newValue);
// Default caret behavior on typing/appends: move to end // Default: cursor moves to end (most common case)
caretOffsetRef.current = newValue.length; caretOffsetRef.current = newValue.length;
}; };

View File

@@ -201,11 +201,13 @@ export function onChunk(
// TODO remove once SDK v1 has proper typing for in-stream errors // 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) // 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.) // These are emitted when LLM errors occur during streaming (rate limits, timeouts, etc.)
const chunkAny = chunk as any; const chunkWithError = chunk as typeof chunk & {
if (chunkAny.error && !chunk.messageType) { 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 errorId = `err-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const errorMsg = chunkAny.error.message || "An error occurred"; const errorMsg = chunkWithError.error.message || "An error occurred";
const errorDetail = chunkAny.error.detail || ""; const errorDetail = chunkWithError.error.detail || "";
const fullErrorText = errorDetail const fullErrorText = errorDetail
? `${errorMsg}: ${errorDetail}` ? `${errorMsg}: ${errorDetail}`
: errorMsg; : errorMsg;

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

View File

@@ -147,7 +147,14 @@ export async function handleHeadlessCommand(argv: string[]) {
// Track approval requests // Track approval requests
if (chunk.messageType === "approval_request_message") { 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) { if (toolCall?.toolCallId && toolCall?.name) {
approval = { approval = {
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,

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