From b8e5f8db6d7dcc6963335fcfb0200b247b7dca2d Mon Sep 17 00:00:00 2001 From: Tino Ehrich Date: Wed, 26 Nov 2025 11:47:37 +0100 Subject: [PATCH] feat: Fuzzy search via --- packages/coding-agent/README.md | 11 ++ packages/tui/src/autocomplete.ts | 168 ++++++++++++++++++++++++++ packages/tui/src/components/editor.ts | 48 +++++++- 3 files changed, 221 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 31223204..350f631e 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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: diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index d06391dd..e71fefa5 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -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[], diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 515cc3ef..e27b4b00 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -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(); } }