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:
Mario Zechner 2026-01-11 17:56:11 +01:00
parent 92486e026c
commit 9655907624
15 changed files with 244 additions and 127 deletions

View file

@ -2,6 +2,11 @@
## [Unreleased]
### Added
- `fuzzyFilter()` and `fuzzyMatch()` utilities for fuzzy text matching
- Slash command autocomplete now uses fuzzy matching instead of prefix matching
### Fixed
- Cursor now moves to end of content on exit, preventing status line from being overwritten ([#629](https://github.com/badlogic/pi-mono/pull/629) by [@tallshort](https://github.com/tallshort))

View file

@ -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
View 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);
}

View file

@ -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,

View file

@ -0,0 +1,93 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { fuzzyFilter, fuzzyMatch } from "../src/fuzzy.js";
describe("fuzzyMatch", () => {
it("empty query matches everything with score 0", () => {
const result = fuzzyMatch("", "anything");
assert.strictEqual(result.matches, true);
assert.strictEqual(result.score, 0);
});
it("query longer than text does not match", () => {
const result = fuzzyMatch("longquery", "short");
assert.strictEqual(result.matches, false);
});
it("exact match has good score", () => {
const result = fuzzyMatch("test", "test");
assert.strictEqual(result.matches, true);
assert.ok(result.score < 0); // Should be negative due to consecutive bonuses
});
it("characters must appear in order", () => {
const matchInOrder = fuzzyMatch("abc", "aXbXc");
assert.strictEqual(matchInOrder.matches, true);
const matchOutOfOrder = fuzzyMatch("abc", "cba");
assert.strictEqual(matchOutOfOrder.matches, false);
});
it("case insensitive matching", () => {
const result = fuzzyMatch("ABC", "abc");
assert.strictEqual(result.matches, true);
const result2 = fuzzyMatch("abc", "ABC");
assert.strictEqual(result2.matches, true);
});
it("consecutive matches score better than scattered matches", () => {
const consecutive = fuzzyMatch("foo", "foobar");
const scattered = fuzzyMatch("foo", "f_o_o_bar");
assert.strictEqual(consecutive.matches, true);
assert.strictEqual(scattered.matches, true);
assert.ok(consecutive.score < scattered.score);
});
it("word boundary matches score better", () => {
const atBoundary = fuzzyMatch("fb", "foo-bar");
const notAtBoundary = fuzzyMatch("fb", "afbx");
assert.strictEqual(atBoundary.matches, true);
assert.strictEqual(notAtBoundary.matches, true);
assert.ok(atBoundary.score < notAtBoundary.score);
});
});
describe("fuzzyFilter", () => {
it("empty query returns all items unchanged", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "", (x: string) => x);
assert.deepStrictEqual(result, items);
});
it("filters out non-matching items", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "an", (x: string) => x);
assert.ok(result.includes("banana"));
assert.ok(!result.includes("apple"));
assert.ok(!result.includes("cherry"));
});
it("sorts results by match quality", () => {
const items = ["a_p_p", "app", "application"];
const result = fuzzyFilter(items, "app", (x: string) => x);
// "app" should be first (exact consecutive match at start)
assert.strictEqual(result[0], "app");
});
it("works with custom getText function", () => {
const items = [
{ name: "foo", id: 1 },
{ name: "bar", id: 2 },
{ name: "foobar", id: 3 },
];
const result = fuzzyFilter(items, "foo", (item: { name: string; id: number }) => item.name);
assert.strictEqual(result.length, 2);
assert.ok(result.map((r) => r.name).includes("foo"));
assert.ok(result.map((r) => r.name).includes("foobar"));
});
});