diff --git a/.gitignore b/.gitignore index 241d278..18c6f86 100644 --- a/.gitignore +++ b/.gitignore @@ -148,3 +148,4 @@ vite.config.ts.timestamp-* # Letta Code local settings .letta/settings.local.json +.idea diff --git a/src/cli/components/FileAutocomplete.tsx b/src/cli/components/FileAutocomplete.tsx new file mode 100644 index 0000000..fa9760c --- /dev/null +++ b/src/cli/components/FileAutocomplete.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const [lastValidQuery, setLastValidQuery] = useState(""); + + // 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 ( + + + File/URL autocomplete (↑↓ to navigate, Tab/Enter to select): + + {isLoading ? ( + Searching... + ) : ( + matches.slice(0, 10).map((item, idx) => ( + + + {idx === selectedIndex ? "▶ " : " "} + {item.type === "dir" ? "📁" : item.type === "url" ? "🔗" : "📄"} + + {item.path} + + )) + )} + {matches.length > 10 && ( + ... and {matches.length - 10} more + )} + + ); +} diff --git a/src/cli/components/InputAssist.tsx b/src/cli/components/InputAssist.tsx new file mode 100644 index 0000000..980f5d5 --- /dev/null +++ b/src/cli/components/InputAssist.tsx @@ -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 ( + + ); + } + + // Show command preview when input starts with / + if (currentInput.startsWith("/")) { + return ; + } + + // No assistance needed + return null; +} diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 1d2325e..6d6b40e 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -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( externalMode || permissionMode.getMode(), ); + const [isAutocompleteActive, setIsAutocompleteActive] = useState(false); + const [cursorPos, setCursorPos] = useState(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} /> @@ -261,28 +303,31 @@ export function Input({ {/* Bottom horizontal divider */} {horizontalLine} - {value.startsWith("/") ? ( - - ) : ( - - {ctrlCPressed ? ( - Press CTRL-C again to exit - ) : escapePressed ? ( - Press Esc again to clear - ) : modeInfo ? ( - - ⏵⏵ {modeInfo.name} - - {" "} - (shift+tab to cycle) - + + + + {ctrlCPressed ? ( + Press CTRL-C again to exit + ) : escapePressed ? ( + Press Esc again to clear + ) : modeInfo ? ( + + ⏵⏵ {modeInfo.name} + + {" "} + (shift+tab to cycle) - ) : ( - Press / for commands - )} - https://discord.gg/letta - - )} + + ) : ( + Press / for commands or @ for files + )} + https://discord.gg/letta + ); diff --git a/src/cli/components/PasteAwareTextInput.tsx b/src/cli/components/PasteAwareTextInput.tsx index f413b5d..e46a93a 100644 --- a/src/cli/components/PasteAwareTextInput.tsx +++ b/src/cli/components/PasteAwareTextInput.tsx @@ -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; }; diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index 0eecc4d..7d69ea5 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -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; diff --git a/src/cli/helpers/fileSearch.ts b/src/cli/helpers/fileSearch.ts new file mode 100644 index 0000000..e04da10 --- /dev/null +++ b/src/cli/helpers/fileSearch.ts @@ -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 { + 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; +} diff --git a/src/headless.ts b/src/headless.ts index a1fb405..f8b5e86 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -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, diff --git a/src/tests/fileSearch.test.ts b/src/tests/fileSearch.test.ts new file mode 100644 index 0000000..544cc68 --- /dev/null +++ b/src/tests/fileSearch.test.ts @@ -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); +});