mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 20:03:05 +00:00
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:
parent
754e745b1f
commit
a61eca5dee
7 changed files with 250 additions and 108 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,7 +3,7 @@ dist/
|
|||
*.log
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
packages/*/node_modules/
|
||||
# packages/*/node_modules/
|
||||
packages/*/dist/
|
||||
packages/*/dist-chrome/
|
||||
packages/*/dist-firefox/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
188
packages/coding-agent/src/tools-manager.ts
Normal file
188
packages/coding-agent/src/tools-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue