mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 21:03:19 +00:00
feat: Fuzzy search via
This commit is contained in:
parent
138d111b05
commit
b8e5f8db6d
3 changed files with 221 additions and 6 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue