feat: Fuzzy search via

This commit is contained in:
Tino Ehrich 2025-11-26 11:47:37 +01:00 committed by Mario Zechner
parent 138d111b05
commit b8e5f8db6d
3 changed files with 221 additions and 6 deletions

View file

@ -460,6 +460,17 @@ Aborts any in-flight agent work, clears all messages, and creates a new session
The interactive input editor includes several productivity features:
### File Reference (`@`)
Type **`@`** to fuzzy-search for files in your project:
- `@editor` → finds files with "editor" in the name
- `@readme` → finds README files anywhere in the project
- Autocomplete triggers immediately when you type `@`
- Use **Up/Down arrows** to navigate, **Tab**/**Enter** to select
- Only shows attachable files (text, code, images)
Uses `fdfind`/`fd` for fast searching if available, falls back to `find` on all Unix systems.
### Path Completion
Press **Tab** to autocomplete file and directory paths:

View file

@ -1,3 +1,4 @@
import { execSync } from "child_process";
import { readdirSync, statSync } from "fs";
import mimeTypes from "mime-types";
import { homedir } from "os";
@ -130,6 +131,7 @@ export interface AutocompleteProvider {
export class CombinedAutocompleteProvider implements AutocompleteProvider {
private commands: (SlashCommand | AutocompleteItem)[];
private basePath: string;
private fdCommand: string | null | undefined = undefined; // undefined = not checked yet
constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string = process.cwd()) {
this.commands = commands;
@ -144,6 +146,20 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
// Check for @ file reference (fuzzy search) - must be after a space or at start
const atMatch = textBeforeCursor.match(/(?:^|[\s])(@[^\s]*)$/);
if (atMatch) {
const prefix = atMatch[1] ?? "@"; // The @... part
const query = prefix.slice(1); // Remove the @
const suggestions = this.getFuzzyFileSuggestions(query);
if (suggestions.length === 0) return null;
return {
items: suggestions,
prefix: prefix,
};
}
// Check for slash commands
if (textBeforeCursor.startsWith("/")) {
const spaceIndex = textBeforeCursor.indexOf(" ");
@ -478,6 +494,158 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
}
}
// Score a file against the query (higher = better match)
private scoreFile(filePath: string, query: string): number {
const fileName = basename(filePath);
const lowerFileName = fileName.toLowerCase();
const lowerQuery = query.toLowerCase();
// Exact filename match (highest)
if (lowerFileName === lowerQuery) return 100;
// Filename starts with query
if (lowerFileName.startsWith(lowerQuery)) return 80;
// Substring match in filename
if (lowerFileName.includes(lowerQuery)) return 50;
// Substring match in full path
if (filePath.toLowerCase().includes(lowerQuery)) return 30;
return 0;
}
// Fuzzy file search using fdfind, fd, or find (fallback)
private getFuzzyFileSuggestions(query: string): AutocompleteItem[] {
try {
let result: string;
const fdCommand = this.getFdCommand();
if (fdCommand) {
const args = ["-t", "f", "--max-results", "100"];
if (query) {
args.push(query);
}
result = execSync(`${fdCommand} ${args.join(" ")}`, {
cwd: this.basePath,
encoding: "utf-8",
timeout: 2000,
maxBuffer: 1024 * 1024,
});
} else {
// Fallback to find
const pattern = query ? `*${query}*` : "*";
const cmd = [
"find",
".",
"-type",
"f",
"-iname",
`'${pattern}'`,
"!",
"-path",
"'*/.git/*'",
"!",
"-path",
"'*/node_modules/*'",
"!",
"-path",
"'*/__pycache__/*'",
"!",
"-path",
"'*/.venv/*'",
"!",
"-path",
"'*/dist/*'",
"!",
"-path",
"'*/build/*'",
"2>/dev/null",
"|",
"head",
"-100",
].join(" ");
result = execSync(cmd, {
cwd: this.basePath,
encoding: "utf-8",
timeout: 3000,
maxBuffer: 1024 * 1024,
shell: "/bin/bash",
});
}
const files = result
.trim()
.split("\n")
.filter((f) => f.length > 0)
.map((f) => (f.startsWith("./") ? f.slice(2) : f));
// Score and filter files
const scoredFiles: { path: string; score: number }[] = [];
for (const filePath of files) {
const fullPath = join(this.basePath, filePath);
if (!isAttachableFile(fullPath)) {
continue;
}
const score = query ? this.scoreFile(filePath, query) : 1;
if (score > 0) {
scoredFiles.push({ path: filePath, score });
}
}
// Sort by score (descending) and take top 20
scoredFiles.sort((a, b) => b.score - a.score);
const topFiles = scoredFiles.slice(0, 20);
// Build suggestions
const suggestions: AutocompleteItem[] = [];
for (const { path: filePath } of topFiles) {
const fileName = basename(filePath);
const dirPath = dirname(filePath);
suggestions.push({
value: "@" + filePath,
label: fileName,
description: dirPath === "." ? "" : dirPath,
});
}
return suggestions;
} catch (e) {
return [];
}
}
// Check which fd command is available (fdfind on Debian/Ubuntu, fd elsewhere)
// Result is cached after first check
private getFdCommand(): string | null {
if (this.fdCommand !== undefined) {
return this.fdCommand;
}
try {
execSync("fdfind --version", { encoding: "utf-8", timeout: 1000 });
this.fdCommand = "fdfind";
return this.fdCommand;
} catch {
try {
execSync("fd --version", { encoding: "utf-8", timeout: 1000 });
this.fdCommand = "fd";
return this.fdCommand;
} catch {
this.fdCommand = null;
return null;
}
}
}
// Force file completion (called on Tab key) - always returns suggestions
getForceFileSuggestions(
lines: string[],

View file

@ -492,6 +492,16 @@ export class Editor implements Component {
if (char === "/" && this.isAtStartOfMessage()) {
this.tryTriggerAutocomplete();
}
// Auto-trigger for "@" file reference (fuzzy search)
else if (char === "@") {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
// Only trigger if @ is after whitespace or at start of line
const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2];
if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") {
this.tryTriggerAutocomplete();
}
}
// Also auto-trigger when typing letters in a slash command context
else if (/[a-zA-Z0-9]/.test(char)) {
const currentLine = this.state.lines[this.state.cursorLine] || "";
@ -500,6 +510,10 @@ export class Editor implements Component {
if (textBeforeCursor.trimStart().startsWith("/")) {
this.tryTriggerAutocomplete();
}
// Check if we're in an @ file reference context
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
this.tryTriggerAutocomplete();
}
}
} else {
this.updateAutocomplete();
@ -643,12 +657,17 @@ export class Editor implements Component {
if (this.isAutocompleting) {
this.updateAutocomplete();
} else {
// If autocomplete was cancelled (no matches), re-trigger if we're in slash command context
// If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
// Slash command context
if (textBeforeCursor.trimStart().startsWith("/")) {
this.tryTriggerAutocomplete();
}
// @ file reference context
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
this.tryTriggerAutocomplete();
}
}
}
@ -766,6 +785,22 @@ export class Editor implements Component {
if (this.onChange) {
this.onChange(this.getText());
}
// Update or re-trigger autocomplete after forward delete
if (this.isAutocompleting) {
this.updateAutocomplete();
} else {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
// Slash command context
if (textBeforeCursor.trimStart().startsWith("/")) {
this.tryTriggerAutocomplete();
}
// @ file reference context
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
this.tryTriggerAutocomplete();
}
}
}
private moveCursor(deltaLine: number, deltaCol: number): void {
@ -898,12 +933,13 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
if (suggestions && suggestions.items.length > 0) {
this.autocompletePrefix = suggestions.prefix;
if (this.autocompleteList) {
// Update the existing list with new items
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
}
// Always create new SelectList to ensure update
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
} else {
// No more matches, cancel autocomplete
// No matches - check if we're still in a valid context before cancelling
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
this.cancelAutocomplete();
}
}