diff --git a/.gitignore b/.gitignore index 2708e6d6..98e604d3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ dist/ *.log .DS_Store *.tsbuildinfo -packages/*/node_modules/ +# packages/*/node_modules/ packages/*/dist/ packages/*/dist-chrome/ packages/*/dist-firefox/ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1c176ab7..3c565c19 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 43d667ab..ac6075c9 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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 { 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) diff --git a/packages/coding-agent/src/tools-manager.ts b/packages/coding-agent/src/tools-manager.ts new file mode 100644 index 00000000..a7531796 --- /dev/null +++ b/packages/coding-agent/src/tools-manager.ts @@ -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 = { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index ab9ae524..6aa9c604 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -105,6 +105,7 @@ export class TuiRenderer { changelogMarkdown: string | null = null, newVersion: string | null = null, scopedModels: Array<{ model: Model; 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); } diff --git a/packages/tui/package.json b/packages/tui/package.json index 2a43a9c1..f491a699 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -41,7 +41,6 @@ "chalk": "^5.5.0", "marked": "^15.0.12", "mime-types": "^3.0.1", - "minimatch": "^10.1.1", "string-width": "^8.1.0" }, "devDependencies": { diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 8adebd12..a33435ad 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -1,110 +1,44 @@ -import { type Dirent, readdirSync, readFileSync } from "fs"; -import { minimatch } from "minimatch"; +import { spawnSync } from "child_process"; +import { readdirSync } from "fs"; import { homedir } from "os"; -import { basename, dirname, join, relative } from "path"; +import { basename, dirname, join } from "path"; -// Parse gitignore-style file into patterns -function parseIgnoreFile(filePath: string): string[] { - try { - const content = readFileSync(filePath, "utf-8"); - return content - .split("\n") - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith("#")); - } catch { - return []; - } -} - -// Check if a path matches gitignore patterns -function isIgnored(filePath: string, patterns: string[]): boolean { - const pathWithoutSlash = filePath.endsWith("/") ? filePath.slice(0, -1) : filePath; - const isDir = filePath.endsWith("/"); - - let ignored = false; - - for (const pattern of patterns) { - let p = pattern; - const negated = p.startsWith("!"); - if (negated) p = p.slice(1); - - // Directory-only pattern - const dirOnly = p.endsWith("/"); - if (dirOnly) { - if (!isDir) continue; - p = p.slice(0, -1); - } - - // Remove leading slash (means anchored to root) - const anchored = p.startsWith("/"); - if (anchored) p = p.slice(1); - - // Match - either at any level or anchored - const matchPattern = anchored ? p : "**/" + p; - const matches = minimatch(pathWithoutSlash, matchPattern, { dot: true }); - - if (matches) { - ignored = !negated; - } - } - - return ignored; -} - -// Walk directory tree respecting .gitignore, similar to fd -function walkDirectory( +// 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 results: Array<{ path: string; isDirectory: boolean }> = []; - const rootIgnorePatterns = parseIgnoreFile(join(baseDir, ".gitignore")); + const args = ["--base-directory", baseDir, "--max-results", String(maxResults), "--type", "f", "--type", "d"]; - function walk(currentDir: string, ignorePatterns: string[]): void { - if (results.length >= maxResults) return; - - // Load local .gitignore if exists - const localPatterns = parseIgnoreFile(join(currentDir, ".gitignore")); - const combinedPatterns = [...ignorePatterns, ...localPatterns]; - - let entries: Dirent[]; - try { - entries = readdirSync(currentDir, { withFileTypes: true }); - } catch { - return; // Can't read directory, skip - } - - for (const entry of entries) { - if (results.length >= maxResults) return; - - // Skip hidden files/dirs - if (entry.name.startsWith(".")) continue; - - const fullPath = join(currentDir, entry.name); - const relativePath = relative(baseDir, fullPath); - - // Check if ignored - const pathToCheck = entry.isDirectory() ? relativePath + "/" : relativePath; - if (isIgnored(pathToCheck, combinedPatterns)) continue; - - if (entry.isDirectory()) { - // Check if dir matches query - if (!query || entry.name.toLowerCase().includes(query.toLowerCase())) { - results.push({ path: relativePath + "/", isDirectory: true }); - } - - // Recurse - walk(fullPath, combinedPatterns); - } else { - // Check if file matches query - if (!query || entry.name.toLowerCase().includes(query.toLowerCase())) { - results.push({ path: relativePath, isDirectory: false }); - } - } - } + // 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) { + // fd outputs directories with trailing / + const isDirectory = line.endsWith("/"); + results.push({ + path: line, + isDirectory, + }); } - walk(baseDir, rootIgnorePatterns); return results; } @@ -153,10 +87,16 @@ 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()) { + constructor( + commands: (SlashCommand | AutocompleteItem)[] = [], + basePath: string = process.cwd(), + fdPath: string | null = null, + ) { this.commands = commands; this.basePath = basePath; + this.fdPath = fdPath; } getSuggestions( @@ -528,10 +468,15 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return score; } - // Fuzzy file search using pure Node.js directory walking (respects .gitignore) + // Fuzzy file search using fd (fast, respects .gitignore) private getFuzzyFileSuggestions(query: string): AutocompleteItem[] { + if (!this.fdPath) { + // fd not available, return empty results + return []; + } + try { - const entries = walkDirectory(this.basePath, query, 100); + const entries = walkDirectoryWithFd(this.basePath, this.fdPath, query, 100); // Score entries const scoredEntries = entries @@ -548,14 +493,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { // Build suggestions const suggestions: AutocompleteItem[] = []; for (const { path: entryPath, isDirectory } of topEntries) { - const entryName = basename(entryPath.endsWith("/") ? entryPath.slice(0, -1) : entryPath); - const normalizedPath = entryPath.endsWith("/") ? entryPath.slice(0, -1) : entryPath; - const valuePath = isDirectory ? normalizedPath + "/" : normalizedPath; + // fd already includes trailing / for directories + const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath; + const entryName = basename(pathWithoutSlash); suggestions.push({ - value: "@" + valuePath, + value: "@" + entryPath, label: entryName + (isDirectory ? "/" : ""), - description: normalizedPath, + description: pathWithoutSlash, }); }