mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +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
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
93
packages/tui/test/fuzzy.test.ts
Normal file
93
packages/tui/test/fuzzy.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue