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

@ -5,6 +5,8 @@
### Added
- `ctx.ui.setWorkingMessage()` extension API to customize the "Working..." message during streaming ([#625](https://github.com/badlogic/pi-mono/pull/625) by [@nicobailon](https://github.com/nicobailon))
- Skill slash commands: loaded skills are registered as `/skill:name` commands for quick access. Toggle via `/settings` or `skills.enableSkillCommands` in settings.json. ([#630](https://github.com/badlogic/pi-mono/pull/630) by [@Dwsy](https://github.com/Dwsy))
- Slash command autocomplete now uses fuzzy matching (type `/skbra` to match `/skill:brave-search`)
## [0.42.5] - 2026-01-11

View file

@ -801,7 +801,7 @@ Usage: `/component Button "onClick handler" "disabled support"`
Skills are self-contained capability packages that the agent loads on-demand. Pi implements the [Agent Skills standard](https://agentskills.io/specification), warning about violations but remaining lenient.
A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks. Skills are loaded when the agent decides a task matches the description, or when you explicitly ask to use one.
A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks. Skills are loaded when the agent decides a task matches the description, or when you explicitly ask to use one. You can also invoke skills directly via `/skill:name` commands (e.g., `/skill:brave-search`).
**Example use cases:**
- Web search and content extraction (Brave Search API)

View file

@ -160,6 +160,7 @@ Configure skill loading in `~/.pi/agent/settings.json`:
"enableClaudeProject": true,
"enablePiUser": true,
"enablePiProject": true,
"enableSkillCommands": true,
"customDirectories": ["~/my-skills-repo"],
"ignoredSkills": ["deprecated-skill"],
"includeSkills": ["git-*", "docker"]
@ -175,6 +176,7 @@ Configure skill loading in `~/.pi/agent/settings.json`:
| `enableClaudeProject` | `true` | Load from `<cwd>/.claude/skills/` |
| `enablePiUser` | `true` | Load from `~/.pi/agent/skills/` |
| `enablePiProject` | `true` | Load from `<cwd>/.pi/skills/` |
| `enableSkillCommands` | `true` | Register skills as `/skill:name` commands |
| `customDirectories` | `[]` | Additional directories to scan (supports `~` expansion) |
| `ignoredSkills` | `[]` | Glob patterns to exclude (e.g., `["deprecated-*", "test-skill"]`) |
| `includeSkills` | `[]` | Glob patterns to include (empty = all; e.g., `["git-*", "docker"]`) |
@ -207,6 +209,31 @@ This overrides the `includeSkills` setting for the current session.
This is progressive disclosure: only descriptions are always in context, full instructions load on-demand.
## Skill Commands
Skills are automatically registered as slash commands with a `/skill:` prefix:
```bash
/skill:brave-search # Load and execute the brave-search skill
/skill:pdf-tools extract # Load skill with arguments
```
Arguments after the command name are appended to the skill content as `User: <args>`.
Toggle skill commands via `/settings` or in `settings.json`:
```json
{
"skills": {
"enableSkillCommands": true
}
}
```
| Setting | Default | Description |
|---------|---------|-------------|
| `enableSkillCommands` | `true` | Register skills as `/skill:name` commands |
## Validation Warnings
Pi validates skills against the Agent Skills standard and warns (but still loads) non-compliant skills:

View file

@ -3,8 +3,8 @@
*/
import type { Api, Model } from "@mariozechner/pi-ai";
import { fuzzyFilter } from "@mariozechner/pi-tui";
import type { ModelRegistry } from "../core/model-registry.js";
import { fuzzyFilter } from "../utils/fuzzy.js";
/**
* Format a number as human-readable (e.g., 200000 -> "200K", 1000000 -> "1M")

View file

@ -25,6 +25,7 @@ export interface SkillsSettings {
enableClaudeProject?: boolean; // default: true
enablePiUser?: boolean; // default: true
enablePiProject?: boolean; // default: true
enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands
customDirectories?: string[]; // default: []
ignoredSkills?: string[]; // default: [] (glob patterns to exclude; takes precedence over includeSkills)
includeSkills?: string[]; // default: [] (empty = include all; glob patterns to filter)
@ -383,12 +384,25 @@ export class SettingsManager {
enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true,
enablePiUser: this.settings.skills?.enablePiUser ?? true,
enablePiProject: this.settings.skills?.enablePiProject ?? true,
enableSkillCommands: this.settings.skills?.enableSkillCommands ?? true,
customDirectories: [...(this.settings.skills?.customDirectories ?? [])],
ignoredSkills: [...(this.settings.skills?.ignoredSkills ?? [])],
includeSkills: [...(this.settings.skills?.includeSkills ?? [])],
};
}
getEnableSkillCommands(): boolean {
return this.settings.skills?.enableSkillCommands ?? true;
}
setEnableSkillCommands(enabled: boolean): void {
if (!this.globalSettings.skills) {
this.globalSettings.skills = {};
}
this.globalSettings.skills.enableSkillCommands = enabled;
this.save();
}
getThinkingBudgets(): ThinkingBudgetsSettings | undefined {
return this.settings.thinkingBudgets;
}

View file

@ -1,8 +1,7 @@
import { type Model, modelsAreEqual } from "@mariozechner/pi-ai";
import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import { Container, fuzzyFilter, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import type { ModelRegistry } from "../../../core/model-registry.js";
import type { SettingsManager } from "../../../core/settings-manager.js";
import { fuzzyFilter } from "../../../utils/fuzzy.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";

View file

@ -1,6 +1,7 @@
import {
type Component,
Container,
fuzzyFilter,
getEditorKeybindings,
Input,
Spacer,
@ -8,7 +9,6 @@ import {
truncateToWidth,
} from "@mariozechner/pi-tui";
import type { SessionInfo } from "../../../core/session-manager.js";
import { fuzzyFilter } from "../../../utils/fuzzy.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";

View file

@ -26,6 +26,7 @@ export interface SettingsConfig {
showImages: boolean;
autoResizeImages: boolean;
blockImages: boolean;
enableSkillCommands: boolean;
steeringMode: "all" | "one-at-a-time";
followUpMode: "all" | "one-at-a-time";
thinkingLevel: ThinkingLevel;
@ -42,6 +43,7 @@ export interface SettingsCallbacks {
onShowImagesChange: (enabled: boolean) => void;
onAutoResizeImagesChange: (enabled: boolean) => void;
onBlockImagesChange: (blocked: boolean) => void;
onEnableSkillCommandsChange: (enabled: boolean) => void;
onSteeringModeChange: (mode: "all" | "one-at-a-time") => void;
onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void;
onThinkingLevelChange: (level: ThinkingLevel) => void;
@ -255,6 +257,16 @@ export class SettingsSelectorComponent extends Container {
values: ["true", "false"],
});
// Skill commands toggle (insert after block-images)
const blockImagesIndex = items.findIndex((item) => item.id === "block-images");
items.splice(blockImagesIndex + 1, 0, {
id: "skill-commands",
label: "Skill commands",
description: "Register skills as /skill:name commands",
currentValue: config.enableSkillCommands ? "true" : "false",
values: ["true", "false"],
});
// Add borders
this.addChild(new DynamicBorder());
@ -276,6 +288,9 @@ export class SettingsSelectorComponent extends Container {
case "block-images":
callbacks.onBlockImagesChange(newValue === "true");
break;
case "skill-commands":
callbacks.onEnableSkillCommandsChange(newValue === "true");
break;
case "steering-mode":
callbacks.onSteeringModeChange(newValue as "all" | "one-at-a-time");
break;

View file

@ -21,6 +21,7 @@ import {
CombinedAutocompleteProvider,
type Component,
Container,
fuzzyFilter,
getEditorKeybindings,
Loader,
Markdown,
@ -50,7 +51,7 @@ import type { TruncationResult } from "../../core/tools/truncate.js";
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
import { copyToClipboard } from "../../utils/clipboard.js";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
import { fuzzyFilter } from "../../utils/fuzzy.js";
import { ensureTool } from "../../utils/tools-manager.js";
import { ArminComponent } from "./components/armin.js";
import { AssistantMessageComponent } from "./components/assistant-message.js";
@ -127,6 +128,7 @@ export class InteractiveMode {
private defaultEditor: CustomEditor;
private editor: EditorComponent;
private autocompleteProvider: CombinedAutocompleteProvider | undefined;
private fdPath: string | undefined;
private editorContainer: Container;
private footer: FooterComponent;
private footerDataProvider: FooterDataProvider;
@ -158,6 +160,9 @@ export class InteractiveMode {
// Thinking block visibility state
private hideThinkingBlock = false;
// Skill commands: command name -> skill file path
private skillCommands = new Map<string, string>();
// Agent subscription unsubscribe function
private unsubscribe?: () => void;
@ -304,15 +309,30 @@ export class InteractiveMode {
}),
);
// Build skill commands from session.skills (if enabled)
this.skillCommands.clear();
const skillCommandList: SlashCommand[] = [];
if (this.settingsManager.getEnableSkillCommands()) {
for (const skill of this.session.skills) {
const commandName = `skill:${skill.name}`;
this.skillCommands.set(commandName, skill.filePath);
skillCommandList.push({ name: commandName, description: skill.description });
}
}
// Setup autocomplete
this.autocompleteProvider = new CombinedAutocompleteProvider(
[...slashCommands, ...templateCommands, ...extensionCommands],
[...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList],
process.cwd(),
fdPath,
);
this.defaultEditor.setAutocompleteProvider(this.autocompleteProvider);
}
private rebuildAutocomplete(): void {
this.setupAutocomplete(this.fdPath);
}
async init(): Promise<void> {
if (this.isInitialized) return;
@ -320,8 +340,8 @@ export class InteractiveMode {
this.changelogMarkdown = this.getChangelogForDisplay();
// Setup autocomplete with fd tool for file path completion
const fdPath = await ensureTool("fd");
this.setupAutocomplete(fdPath);
this.fdPath = await ensureTool("fd");
this.setupAutocomplete(this.fdPath);
// Add header with keybindings from config
const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
@ -1480,6 +1500,20 @@ export class InteractiveMode {
return;
}
// Handle skill commands (/skill:name [args])
if (text.startsWith("/skill:")) {
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
const skillPath = this.skillCommands.get(commandName);
if (skillPath) {
this.editor.addToHistory?.(text);
this.editor.setText("");
await this.handleSkillCommand(skillPath, args);
return;
}
}
// Handle bash command (! for normal, !! for excluded from context)
if (text.startsWith("!")) {
const isExcluded = text.startsWith("!!");
@ -2442,6 +2476,7 @@ export class InteractiveMode {
showImages: this.settingsManager.getShowImages(),
autoResizeImages: this.settingsManager.getImageAutoResize(),
blockImages: this.settingsManager.getBlockImages(),
enableSkillCommands: this.settingsManager.getEnableSkillCommands(),
steeringMode: this.session.steeringMode,
followUpMode: this.session.followUpMode,
thinkingLevel: this.session.thinkingLevel,
@ -2471,6 +2506,10 @@ export class InteractiveMode {
onBlockImagesChange: (blocked) => {
this.settingsManager.setBlockImages(blocked);
},
onEnableSkillCommandsChange: (enabled) => {
this.settingsManager.setEnableSkillCommands(enabled);
this.rebuildAutocomplete();
},
onSteeringModeChange: (mode) => {
this.session.setSteeringMode(mode);
},
@ -3089,6 +3128,18 @@ export class InteractiveMode {
this.ui.requestRender();
}
private async handleSkillCommand(skillPath: string, args: string): Promise<void> {
try {
const content = fs.readFileSync(skillPath, "utf-8");
// Strip YAML frontmatter if present
const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
const message = args ? `${body}\n\n---\n\nUser: ${args}` : body;
await this.session.prompt(message);
} catch (err) {
this.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
}
}
private handleChangelogCommand(): void {
const changelogPath = getChangelogPath();
const allEntries = parseChangelog(changelogPath);

View file

@ -1,92 +0,0 @@
import { describe, expect, test } from "vitest";
import { fuzzyFilter, fuzzyMatch } from "../src/utils/fuzzy.js";
describe("fuzzyMatch", () => {
test("empty query matches everything with score 0", () => {
const result = fuzzyMatch("", "anything");
expect(result.matches).toBe(true);
expect(result.score).toBe(0);
});
test("query longer than text does not match", () => {
const result = fuzzyMatch("longquery", "short");
expect(result.matches).toBe(false);
});
test("exact match has good score", () => {
const result = fuzzyMatch("test", "test");
expect(result.matches).toBe(true);
expect(result.score).toBeLessThan(0); // Should be negative due to consecutive bonuses
});
test("characters must appear in order", () => {
const matchInOrder = fuzzyMatch("abc", "aXbXc");
expect(matchInOrder.matches).toBe(true);
const matchOutOfOrder = fuzzyMatch("abc", "cba");
expect(matchOutOfOrder.matches).toBe(false);
});
test("case insensitive matching", () => {
const result = fuzzyMatch("ABC", "abc");
expect(result.matches).toBe(true);
const result2 = fuzzyMatch("abc", "ABC");
expect(result2.matches).toBe(true);
});
test("consecutive matches score better than scattered matches", () => {
const consecutive = fuzzyMatch("foo", "foobar");
const scattered = fuzzyMatch("foo", "f_o_o_bar");
expect(consecutive.matches).toBe(true);
expect(scattered.matches).toBe(true);
expect(consecutive.score).toBeLessThan(scattered.score);
});
test("word boundary matches score better", () => {
const atBoundary = fuzzyMatch("fb", "foo-bar");
const notAtBoundary = fuzzyMatch("fb", "afbx");
expect(atBoundary.matches).toBe(true);
expect(notAtBoundary.matches).toBe(true);
expect(atBoundary.score).toBeLessThan(notAtBoundary.score);
});
});
describe("fuzzyFilter", () => {
test("empty query returns all items unchanged", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "", (x) => x);
expect(result).toEqual(items);
});
test("filters out non-matching items", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "an", (x) => x);
expect(result).toContain("banana");
expect(result).not.toContain("apple");
expect(result).not.toContain("cherry");
});
test("sorts results by match quality", () => {
const items = ["a_p_p", "app", "application"];
const result = fuzzyFilter(items, "app", (x) => x);
// "app" should be first (exact consecutive match at start)
expect(result[0]).toBe("app");
});
test("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) => item.name);
expect(result.length).toBe(2);
expect(result.map((r) => r.name)).toContain("foo");
expect(result.map((r) => r.name)).toContain("foobar");
});
});

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;

View file

@ -1,5 +1,8 @@
// Fuzzy search. Matches if all query characters appear in order (not necessarily consecutive).
// Lower score = better match.
/**
* Fuzzy matching utilities.
* Matches if all query characters appear in order (not necessarily consecutive).
* Lower score = better match.
*/
export interface FuzzyMatch {
matches: boolean;
@ -25,26 +28,26 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
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]!);
const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
// Reward consecutive character matches (e.g., typing "foo" matches "foobar" better than "f_o_o")
// Reward consecutive matches
if (lastMatchIndex === i - 1) {
consecutiveMatches++;
score -= consecutiveMatches * 5;
} else {
consecutiveMatches = 0;
// Penalize gaps between matched characters
// Penalize gaps
if (lastMatchIndex >= 0) {
score += (i - lastMatchIndex - 1) * 2;
}
}
// Reward matches at word boundaries (start of words are more likely intentional targets)
// Reward word boundary matches
if (isWordBoundary) {
score -= 10;
}
// Slight penalty for matches later in the string (prefer earlier matches)
// Slight penalty for later matches
score += i * 0.1;
lastMatchIndex = i;
@ -52,7 +55,6 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
}
}
// Not all query characters were found in order
if (queryIndex < queryLower.length) {
return { matches: false, score: 0 };
}
@ -60,14 +62,15 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
return { matches: true, score };
}
// Filter and sort items by fuzzy match quality (best matches first)
// Supports space-separated tokens: all tokens must match, sorted by match count then 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;
}
// Split query into tokens
const tokens = query
.trim()
.split(/\s+/)
@ -84,7 +87,6 @@ export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) =>
let totalScore = 0;
let allMatch = true;
// Check each token against the text - ALL must match
for (const token of tokens) {
const match = fuzzyMatch(token, text);
if (match.matches) {
@ -95,14 +97,11 @@ export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) =>
}
}
// Only include if all tokens match
if (allMatch) {
results.push({ item, totalScore });
}
}
// Sort by score (asc, lower is better)
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"));
});
});