diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7ae4d93f..c29cdb90 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 47b71885..93e7295f 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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) diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index 58a9c731..e086c952 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -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 `/.claude/skills/` | | `enablePiUser` | `true` | Load from `~/.pi/agent/skills/` | | `enablePiProject` | `true` | Load from `/.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: `. + +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: diff --git a/packages/coding-agent/src/cli/list-models.ts b/packages/coding-agent/src/cli/list-models.ts index 4e070004..a24f7b66 100644 --- a/packages/coding-agent/src/cli/list-models.ts +++ b/packages/coding-agent/src/cli/list-models.ts @@ -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") diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 0f7b51c3..f21d6319 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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; } diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 6fa1baaf..bef29c4e 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -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"; diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index 8bb7fe9d..343c7379 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -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"; diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 681742a4..b017fd6d 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -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; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 03bdd01b..37fb496b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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(); + // 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 { 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 { + 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); diff --git a/packages/coding-agent/test/fuzzy.test.ts b/packages/coding-agent/test/fuzzy.test.ts deleted file mode 100644 index c41ff3c4..00000000 --- a/packages/coding-agent/test/fuzzy.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a72139c8..7b2c121b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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)) diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index fa5c264c..f5381cc4 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -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; diff --git a/packages/coding-agent/src/utils/fuzzy.ts b/packages/tui/src/fuzzy.ts similarity index 67% rename from packages/coding-agent/src/utils/fuzzy.ts rename to packages/tui/src/fuzzy.ts index f37c25e1..8b6eec5d 100644 --- a/packages/coding-agent/src/utils/fuzzy.ts +++ b/packages/tui/src/fuzzy.ts @@ -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(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(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(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); } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 7e87d71a..b1b3cdff 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -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, diff --git a/packages/tui/test/fuzzy.test.ts b/packages/tui/test/fuzzy.test.ts new file mode 100644 index 00000000..1f6b86fc --- /dev/null +++ b/packages/tui/test/fuzzy.test.ts @@ -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")); + }); +});