mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 22:03:45 +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:
|
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
|
### Path Completion
|
||||||
|
|
||||||
Press **Tab** to autocomplete file and directory paths:
|
Press **Tab** to autocomplete file and directory paths:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { execSync } from "child_process";
|
||||||
import { readdirSync, statSync } from "fs";
|
import { readdirSync, statSync } from "fs";
|
||||||
import mimeTypes from "mime-types";
|
import mimeTypes from "mime-types";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
|
|
@ -130,6 +131,7 @@ export interface AutocompleteProvider {
|
||||||
export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||||
private commands: (SlashCommand | AutocompleteItem)[];
|
private commands: (SlashCommand | AutocompleteItem)[];
|
||||||
private basePath: string;
|
private basePath: string;
|
||||||
|
private fdCommand: string | null | undefined = undefined; // undefined = not checked yet
|
||||||
|
|
||||||
constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string = process.cwd()) {
|
constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string = process.cwd()) {
|
||||||
this.commands = commands;
|
this.commands = commands;
|
||||||
|
|
@ -144,6 +146,20 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||||
const currentLine = lines[cursorLine] || "";
|
const currentLine = lines[cursorLine] || "";
|
||||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
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
|
// Check for slash commands
|
||||||
if (textBeforeCursor.startsWith("/")) {
|
if (textBeforeCursor.startsWith("/")) {
|
||||||
const spaceIndex = textBeforeCursor.indexOf(" ");
|
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
|
// Force file completion (called on Tab key) - always returns suggestions
|
||||||
getForceFileSuggestions(
|
getForceFileSuggestions(
|
||||||
lines: string[],
|
lines: string[],
|
||||||
|
|
|
||||||
|
|
@ -492,6 +492,16 @@ export class Editor implements Component {
|
||||||
if (char === "/" && this.isAtStartOfMessage()) {
|
if (char === "/" && this.isAtStartOfMessage()) {
|
||||||
this.tryTriggerAutocomplete();
|
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
|
// Also auto-trigger when typing letters in a slash command context
|
||||||
else if (/[a-zA-Z0-9]/.test(char)) {
|
else if (/[a-zA-Z0-9]/.test(char)) {
|
||||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||||
|
|
@ -500,6 +510,10 @@ export class Editor implements Component {
|
||||||
if (textBeforeCursor.trimStart().startsWith("/")) {
|
if (textBeforeCursor.trimStart().startsWith("/")) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
}
|
}
|
||||||
|
// Check if we're in an @ file reference context
|
||||||
|
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
||||||
|
this.tryTriggerAutocomplete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.updateAutocomplete();
|
this.updateAutocomplete();
|
||||||
|
|
@ -643,12 +657,17 @@ export class Editor implements Component {
|
||||||
if (this.isAutocompleting) {
|
if (this.isAutocompleting) {
|
||||||
this.updateAutocomplete();
|
this.updateAutocomplete();
|
||||||
} else {
|
} 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 currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||||
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||||
|
// Slash command context
|
||||||
if (textBeforeCursor.trimStart().startsWith("/")) {
|
if (textBeforeCursor.trimStart().startsWith("/")) {
|
||||||
this.tryTriggerAutocomplete();
|
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) {
|
if (this.onChange) {
|
||||||
this.onChange(this.getText());
|
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 {
|
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) {
|
if (suggestions && suggestions.items.length > 0) {
|
||||||
this.autocompletePrefix = suggestions.prefix;
|
this.autocompletePrefix = suggestions.prefix;
|
||||||
if (this.autocompleteList) {
|
// Always create new SelectList to ensure update
|
||||||
// Update the existing list with new items
|
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
|
||||||
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
|
|
||||||
}
|
|
||||||
} else {
|
} 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();
|
this.cancelAutocomplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue