diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index fbbcf3b74..47ac34c8e 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -148,7 +148,6 @@ export class InteractiveMode { private defaultEditor: CustomEditor; private editor: EditorComponent; private autocompleteProvider: CombinedAutocompleteProvider | undefined; - private fdPath: string | undefined; private editorContainer: Container; private footer: FooterComponent; private footerDataProvider: FooterDataProvider; @@ -281,7 +280,7 @@ export class InteractiveMode { initTheme(this.settingsManager.getTheme(), true); } - private setupAutocomplete(fdPath: string | undefined): void { + private setupAutocomplete(): void { // Define commands for autocomplete const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({ name: command.name, @@ -350,7 +349,6 @@ export class InteractiveMode { this.autocompleteProvider = new CombinedAutocompleteProvider( [...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList], process.cwd(), - fdPath, ); this.defaultEditor.setAutocompleteProvider(this.autocompleteProvider); if (this.editor !== this.defaultEditor) { @@ -364,10 +362,9 @@ export class InteractiveMode { // Load changelog (only show new entries, skip for resumed sessions) this.changelogMarkdown = this.getChangelogForDisplay(); - // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir) - // Both are needed: fd for autocomplete, rg for grep tool and bash commands - const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]); - this.fdPath = fdPath; + // Ensure rg is available (downloads if missing, adds to PATH via getBinDir) + // rg is needed for grep tool and bash commands + await ensureTool("rg"); // Add header container as first child this.ui.addChild(this.headerContainer); @@ -1135,7 +1132,7 @@ export class InteractiveMode { }); setRegisteredThemes(this.session.resourceLoader.getThemes().themes); - this.setupAutocomplete(this.fdPath); + this.setupAutocomplete(); const extensionRunner = this.session.extensionRunner; if (!extensionRunner) { @@ -3192,7 +3189,7 @@ export class InteractiveMode { }, onEnableSkillCommandsChange: (enabled) => { this.settingsManager.setEnableSkillCommands(enabled); - this.setupAutocomplete(this.fdPath); + this.setupAutocomplete(); }, onSteeringModeChange: (mode) => { this.session.setSteeringMode(mode); @@ -3943,7 +3940,7 @@ export class InteractiveMode { } this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor()); this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); - this.setupAutocomplete(this.fdPath); + this.setupAutocomplete(); const runner = this.session.extensionRunner; if (runner) { this.setupExtensionShortcuts(runner); diff --git a/packages/pi-tui/package.json b/packages/pi-tui/package.json index c5611eff7..a16b6726d 100644 --- a/packages/pi-tui/package.json +++ b/packages/pi-tui/package.json @@ -9,6 +9,7 @@ "build": "tsc -p tsconfig.json" }, "dependencies": { + "@gsd/native": "*", "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", diff --git a/packages/pi-tui/src/autocomplete.ts b/packages/pi-tui/src/autocomplete.ts index 7a7f55b53..b42fc6fd1 100644 --- a/packages/pi-tui/src/autocomplete.ts +++ b/packages/pi-tui/src/autocomplete.ts @@ -1,7 +1,7 @@ -import { spawnSync } from "child_process"; import { readdirSync, statSync } from "fs"; import { homedir } from "os"; import { basename, dirname, join } from "path"; +import { fuzzyFind } from "@gsd/native/fd"; import { fuzzyFilter } from "./fuzzy.js"; const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]); @@ -84,67 +84,6 @@ function buildCompletionValue( return `${openQuote}${path}${closeQuote}`; } -// Use fd to walk directory tree (fast, respects .gitignore) -function walkDirectoryWithFd( - baseDir: string, - fdPath: string, - query: string, - maxResults: number, -): Array<{ path: string; isDirectory: boolean }> { - const args = [ - "--base-directory", - baseDir, - "--max-results", - String(maxResults), - "--type", - "f", - "--type", - "d", - "--full-path", - "--hidden", - "--exclude", - ".git", - "--exclude", - ".git/*", - "--exclude", - ".git/**", - ]; - - // Add query as pattern if provided - if (query) { - args.push(query); - } - - const result = spawnSync(fdPath, args, { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - maxBuffer: 10 * 1024 * 1024, - }); - - if (result.status !== 0 || !result.stdout) { - return []; - } - - const lines = result.stdout.trim().split("\n").filter(Boolean); - const results: Array<{ path: string; isDirectory: boolean }> = []; - - for (const line of lines) { - const normalizedPath = line.endsWith("/") ? line.slice(0, -1) : line; - if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) { - continue; - } - - // fd outputs directories with trailing / - const isDirectory = line.endsWith("/"); - results.push({ - path: line, - isDirectory, - }); - } - - return results; -} - export interface AutocompleteItem { value: string; label: string; @@ -190,16 +129,13 @@ export interface AutocompleteProvider { export class CombinedAutocompleteProvider implements AutocompleteProvider { private commands: (SlashCommand | AutocompleteItem)[]; private basePath: string; - private fdPath: string | null; constructor( commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string = process.cwd(), - fdPath: string | null = null, ) { this.commands = commands; this.basePath = basePath; - this.fdPath = fdPath; } getSuggestions( @@ -614,59 +550,28 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } } - // Score an entry against the query (higher = better match) - // isDirectory adds bonus to prioritize folders - private scoreEntry(filePath: string, query: string, isDirectory: boolean): number { - const fileName = basename(filePath); - const lowerFileName = fileName.toLowerCase(); - const lowerQuery = query.toLowerCase(); - - let score = 0; - - // Exact filename match (highest) - if (lowerFileName === lowerQuery) score = 100; - // Filename starts with query - else if (lowerFileName.startsWith(lowerQuery)) score = 80; - // Substring match in filename - else if (lowerFileName.includes(lowerQuery)) score = 50; - // Substring match in full path - else if (filePath.toLowerCase().includes(lowerQuery)) score = 30; - - // Directories get a bonus to appear first - if (isDirectory && score > 0) score += 10; - - return score; - } - - // Fuzzy file search using fd (fast, respects .gitignore) + // Fuzzy file search using the native fd module (fast, respects .gitignore) private getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): AutocompleteItem[] { - if (!this.fdPath) { - // fd not available, return empty results - return []; - } - try { const scopedQuery = this.resolveScopedFuzzyQuery(query); - const fdBaseDir = scopedQuery?.baseDir ?? this.basePath; - const fdQuery = scopedQuery?.query ?? query; - const entries = walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100); + const searchPath = scopedQuery?.baseDir ?? this.basePath; + const searchQuery = scopedQuery?.query ?? query; - // Score entries - const scoredEntries = entries - .map((entry) => ({ - ...entry, - score: fdQuery ? this.scoreEntry(entry.path, fdQuery, entry.isDirectory) : 1, - })) - .filter((entry) => entry.score > 0); + const result = fuzzyFind({ + query: searchQuery, + path: searchPath, + hidden: true, + gitignore: true, + maxResults: 100, + }); - // Sort by score (descending) and take top 20 - scoredEntries.sort((a, b) => b.score - a.score); - const topEntries = scoredEntries.slice(0, 20); + // Take top 20 matches (already sorted by score descending from native module) + const topMatches = result.matches.slice(0, 20); // Build suggestions const suggestions: AutocompleteItem[] = []; - for (const { path: entryPath, isDirectory } of topEntries) { - // fd already includes trailing / for directories + for (const { path: entryPath, isDirectory } of topMatches) { + // Native module includes trailing / for directories const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath; const displayPath = scopedQuery ? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash)