mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 06:04:51 +00:00
feat: Add skill slash commands and fuzzy matching for all commands
- Skills registered as /skill:name commands for quick access - Toggle via /settings or skills.enableSkillCommands in settings.json - Fuzzy matching for all slash command autocomplete (type /skbra for /skill:brave-search) - Moved fuzzy module from coding-agent to tui package for reuse Closes #630 by @Dwsy (reimplemented with fixes)
This commit is contained in:
parent
92486e026c
commit
9655907624
15 changed files with 244 additions and 127 deletions
|
|
@ -2,6 +2,7 @@ import { spawnSync } from "child_process";
|
|||
import { readdirSync, statSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { basename, dirname, join } from "path";
|
||||
import { fuzzyFilter } from "./fuzzy.js";
|
||||
|
||||
// Use fd to walk directory tree (fast, respects .gitignore)
|
||||
function walkDirectoryWithFd(
|
||||
|
|
@ -126,18 +127,19 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||
const spaceIndex = textBeforeCursor.indexOf(" ");
|
||||
|
||||
if (spaceIndex === -1) {
|
||||
// No space yet - complete command names
|
||||
// No space yet - complete command names with fuzzy matching
|
||||
const prefix = textBeforeCursor.slice(1); // Remove the "/"
|
||||
const filtered = this.commands
|
||||
.filter((cmd) => {
|
||||
const name = "name" in cmd ? cmd.name : cmd.value; // Check if SlashCommand or AutocompleteItem
|
||||
return name?.toLowerCase().startsWith(prefix.toLowerCase());
|
||||
})
|
||||
.map((cmd) => ({
|
||||
value: "name" in cmd ? cmd.name : cmd.value,
|
||||
label: "name" in cmd ? cmd.name : cmd.label,
|
||||
...(cmd.description && { description: cmd.description }),
|
||||
}));
|
||||
const commandItems = this.commands.map((cmd) => ({
|
||||
name: "name" in cmd ? cmd.name : cmd.value,
|
||||
label: "name" in cmd ? cmd.name : cmd.label,
|
||||
description: cmd.description,
|
||||
}));
|
||||
|
||||
const filtered = fuzzyFilter(commandItems, prefix, (item) => item.name).map((item) => ({
|
||||
value: item.name,
|
||||
label: item.label,
|
||||
...(item.description && { description: item.description }),
|
||||
}));
|
||||
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
|
|
|
|||
107
packages/tui/src/fuzzy.ts
Normal file
107
packages/tui/src/fuzzy.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Fuzzy matching utilities.
|
||||
* Matches if all query characters appear in order (not necessarily consecutive).
|
||||
* Lower score = better match.
|
||||
*/
|
||||
|
||||
export interface FuzzyMatch {
|
||||
matches: boolean;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
||||
const queryLower = query.toLowerCase();
|
||||
const textLower = text.toLowerCase();
|
||||
|
||||
if (queryLower.length === 0) {
|
||||
return { matches: true, score: 0 };
|
||||
}
|
||||
|
||||
if (queryLower.length > textLower.length) {
|
||||
return { matches: false, score: 0 };
|
||||
}
|
||||
|
||||
let queryIndex = 0;
|
||||
let score = 0;
|
||||
let lastMatchIndex = -1;
|
||||
let consecutiveMatches = 0;
|
||||
|
||||
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
|
||||
if (textLower[i] === queryLower[queryIndex]) {
|
||||
const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
|
||||
|
||||
// Reward consecutive matches
|
||||
if (lastMatchIndex === i - 1) {
|
||||
consecutiveMatches++;
|
||||
score -= consecutiveMatches * 5;
|
||||
} else {
|
||||
consecutiveMatches = 0;
|
||||
// Penalize gaps
|
||||
if (lastMatchIndex >= 0) {
|
||||
score += (i - lastMatchIndex - 1) * 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Reward word boundary matches
|
||||
if (isWordBoundary) {
|
||||
score -= 10;
|
||||
}
|
||||
|
||||
// Slight penalty for later matches
|
||||
score += i * 0.1;
|
||||
|
||||
lastMatchIndex = i;
|
||||
queryIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (queryIndex < queryLower.length) {
|
||||
return { matches: false, score: 0 };
|
||||
}
|
||||
|
||||
return { matches: true, score };
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and sort items by fuzzy match quality (best matches first).
|
||||
* Supports space-separated tokens: all tokens must match.
|
||||
*/
|
||||
export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
|
||||
if (!query.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const tokens = query
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const results: { item: T; totalScore: number }[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const text = getText(item);
|
||||
let totalScore = 0;
|
||||
let allMatch = true;
|
||||
|
||||
for (const token of tokens) {
|
||||
const match = fuzzyMatch(token, text);
|
||||
if (match.matches) {
|
||||
totalScore += match.score;
|
||||
} else {
|
||||
allMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allMatch) {
|
||||
results.push({ item, totalScore });
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => a.totalScore - b.totalScore);
|
||||
return results.map((r) => r.item);
|
||||
}
|
||||
|
|
@ -22,6 +22,8 @@ export { Text } from "./components/text.js";
|
|||
export { TruncatedText } from "./components/truncated-text.js";
|
||||
// Editor component interface (for custom editors)
|
||||
export type { EditorComponent } from "./editor-component.js";
|
||||
// Fuzzy matching
|
||||
export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.js";
|
||||
// Keybindings
|
||||
export {
|
||||
DEFAULT_EDITOR_KEYBINDINGS,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue