fix: file @ autocomplete performance using fd

- Replace slow synchronous directory walking with fd for fuzzy file search
- Auto-download fd to ~/.pi/agent/tools/ if not found in PATH
- Performance improved from ~900ms to ~10ms per keystroke on large repos
- Remove minimatch dependency from tui package
- Graceful degradation if fd unavailable (empty results)

Fixes #69
This commit is contained in:
Mario Zechner 2025-11-28 23:38:44 +01:00
parent 754e745b1f
commit a61eca5dee
7 changed files with 250 additions and 108 deletions

View file

@ -5,6 +5,7 @@
### Fixed
- **Prompt Restoration on API Key Error**: When submitting a message fails due to missing API key, the prompt is now restored to the editor instead of being lost. ([#77](https://github.com/badlogic/pi-mono/issues/77))
- **File `@` Autocomplete Performance**: Fixed severe UI jank when using `@` for file attachment in large repositories. The file picker now uses `fd` (a fast file finder) instead of synchronous directory walking with minimatch. On a 55k file repo, search time dropped from ~900ms to ~10ms per keystroke. If `fd` is not installed, it will be automatically downloaded to `~/.pi/agent/tools/` on first use. ([#69](https://github.com/badlogic/pi-mono/issues/69))
## [0.10.2] - 2025-11-27

View file

@ -12,6 +12,7 @@ import { SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
import { initTheme } from "./theme/theme.js";
import { codingTools } from "./tools/index.js";
import { ensureTool } from "./tools-manager.js";
import { SessionSelectorComponent } from "./tui/session-selector.js";
import { TuiRenderer } from "./tui/tui-renderer.js";
@ -612,6 +613,7 @@ async function runInteractiveMode(
initialMessages: string[] = [],
initialMessage?: string,
initialAttachments?: Attachment[],
fdPath: string | null = null,
): Promise<void> {
const renderer = new TuiRenderer(
agent,
@ -621,6 +623,7 @@ async function runInteractiveMode(
changelogMarkdown,
newVersion,
scopedModels,
fdPath,
);
// Initialize TUI (subscribes to agent events internally)
@ -1133,6 +1136,9 @@ export async function main(args: string[]) {
console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`));
}
// Ensure fd tool is available for file autocomplete
const fdPath = await ensureTool("fd");
// Interactive mode - use TUI (may have initial messages from CLI args)
await runInteractiveMode(
agent,
@ -1146,6 +1152,7 @@ export async function main(args: string[]) {
parsed.messages,
initialMessage,
initialAttachments,
fdPath,
);
} else {
// Non-interactive mode (--print flag or --mode flag)

View file

@ -0,0 +1,188 @@
import chalk from "chalk";
import { spawnSync } from "child_process";
import { chmodSync, createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from "fs";
import { arch, homedir, platform } from "os";
import { join } from "path";
import { Readable } from "stream";
import { finished } from "stream/promises";
const TOOLS_DIR = join(homedir(), ".pi", "agent", "tools");
interface ToolConfig {
name: string;
repo: string; // GitHub repo (e.g., "sharkdp/fd")
binaryName: string; // Name of the binary inside the archive
getAssetName: (version: string, plat: string, architecture: string) => string | null;
}
const TOOLS: Record<string, ToolConfig> = {
fd: {
name: "fd",
repo: "sharkdp/fd",
binaryName: "fd",
getAssetName: (version, plat, architecture) => {
if (plat === "darwin") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `fd-v${version}-${archStr}-apple-darwin.tar.gz`;
} else if (plat === "linux") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `fd-v${version}-${archStr}-unknown-linux-gnu.tar.gz`;
} else if (plat === "win32") {
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
return `fd-v${version}-${archStr}-pc-windows-msvc.zip`;
}
return null;
},
},
};
// Check if a command exists in PATH by trying to run it
function commandExists(cmd: string): boolean {
try {
spawnSync(cmd, ["--version"], { stdio: "pipe" });
return true;
} catch {
return false;
}
}
// Get the path to a tool (system-wide or in our tools dir)
export function getToolPath(tool: "fd"): string | null {
const config = TOOLS[tool];
if (!config) return null;
// Check our tools directory first
const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : ""));
if (existsSync(localPath)) {
return localPath;
}
// Check system PATH - if found, just return the command name (it's in PATH)
if (commandExists(config.binaryName)) {
return config.binaryName;
}
return null;
}
// Fetch latest release version from GitHub
async function getLatestVersion(repo: string): Promise<string> {
const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
headers: { "User-Agent": "pi-coding-agent" },
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
const data = await response.json();
return data.tag_name.replace(/^v/, "");
}
// Download a file from URL
async function downloadFile(url: string, dest: string): Promise<void> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download: ${response.status}`);
}
if (!response.body) {
throw new Error("No response body");
}
const fileStream = createWriteStream(dest);
await finished(Readable.fromWeb(response.body as any).pipe(fileStream));
}
// Download and install a tool
async function downloadTool(tool: "fd"): Promise<string> {
const config = TOOLS[tool];
if (!config) throw new Error(`Unknown tool: ${tool}`);
const plat = platform();
const architecture = arch();
// Get latest version
const version = await getLatestVersion(config.repo);
// Get asset name for this platform
const assetName = config.getAssetName(version, plat, architecture);
if (!assetName) {
throw new Error(`Unsupported platform: ${plat}/${architecture}`);
}
// Create tools directory
mkdirSync(TOOLS_DIR, { recursive: true });
const downloadUrl = `https://github.com/${config.repo}/releases/download/v${version}/${assetName}`;
const archivePath = join(TOOLS_DIR, assetName);
const binaryExt = plat === "win32" ? ".exe" : "";
const binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt);
// Download
await downloadFile(downloadUrl, archivePath);
// Extract
const extractDir = join(TOOLS_DIR, "extract_tmp");
mkdirSync(extractDir, { recursive: true });
try {
if (assetName.endsWith(".tar.gz")) {
spawnSync("tar", ["xzf", archivePath, "-C", extractDir], { stdio: "pipe" });
} else if (assetName.endsWith(".zip")) {
spawnSync("unzip", ["-o", archivePath, "-d", extractDir], { stdio: "pipe" });
}
// Find the binary in extracted files
const extractedDir = join(extractDir, assetName.replace(/\.(tar\.gz|zip)$/, ""));
const extractedBinary = join(extractedDir, config.binaryName + binaryExt);
if (existsSync(extractedBinary)) {
renameSync(extractedBinary, binaryPath);
} else {
throw new Error(`Binary not found in archive: ${extractedBinary}`);
}
// Make executable (Unix only)
if (plat !== "win32") {
chmodSync(binaryPath, 0o755);
}
} finally {
// Cleanup
rmSync(archivePath, { force: true });
rmSync(extractDir, { recursive: true, force: true });
}
return binaryPath;
}
// Ensure a tool is available, downloading if necessary
// Returns the path to the tool, or null if unavailable
export async function ensureTool(tool: "fd", silent: boolean = false): Promise<string | null> {
const existingPath = getToolPath(tool);
if (existingPath) {
return existingPath;
}
const config = TOOLS[tool];
if (!config) return null;
// Tool not found - download it
if (!silent) {
console.log(chalk.dim(`${config.name} not found. Downloading...`));
}
try {
const path = await downloadTool(tool);
if (!silent) {
console.log(chalk.dim(`${config.name} installed to ${path}`));
}
return path;
} catch (e) {
if (!silent) {
console.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`));
}
return null;
}
}

View file

@ -105,6 +105,7 @@ export class TuiRenderer {
changelogMarkdown: string | null = null,
newVersion: string | null = null,
scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }> = [],
fdPath: string | null = null,
) {
this.agent = agent;
this.sessionManager = sessionManager;
@ -194,6 +195,7 @@ export class TuiRenderer {
clearCommand,
],
process.cwd(),
fdPath,
);
this.editor.setAutocompleteProvider(autocompleteProvider);
}