diff --git a/apps/app/src/features/chat/components/input/slash-command-menu.tsx b/apps/app/src/features/chat/components/input/slash-command-menu.tsx
index df925bc96..8816683d1 100644
--- a/apps/app/src/features/chat/components/input/slash-command-menu.tsx
+++ b/apps/app/src/features/chat/components/input/slash-command-menu.tsx
@@ -10,6 +10,7 @@ import { BoxIcon, FileTextIcon, TerminalIcon } from "lucide-react";
import { Fragment, useEffect, useId } from "react";
import { useLatestRef } from "@/hooks/use-latest-ref";
+import { HighlightedMatch } from "@/lib/highlighted-match";
import {
allowSlashCommandSuggestion,
@@ -124,11 +125,19 @@ function SlashCommandPopup({
- {slashCommandLabel(item.command)}
+
{item.description ? (
- {item.description}
+
) : null}
diff --git a/apps/app/src/features/chat/components/input/slash-command-suggestions.test.ts b/apps/app/src/features/chat/components/input/slash-command-suggestions.test.ts
new file mode 100644
index 000000000..e87d9c79d
--- /dev/null
+++ b/apps/app/src/features/chat/components/input/slash-command-suggestions.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ createSlashCommandSuggestionItems,
+ filterSlashCommandItems,
+} from "./slash-command-suggestions";
+
+describe("filterSlashCommandItems", () => {
+ it("ranks slash skills with the skill matcher and keeps unmatched commands out", () => {
+ const filtered = filterSlashCommandItems(
+ createSlashCommandSuggestionItems([
+ { name: "skill:building-native-ui", description: "guide", source: "skill" },
+ { name: "explain", description: "Explain the selected code", source: "prompt" },
+ { name: "skill:ui", description: "Explore UI", source: "skill" },
+ ]),
+ "ui",
+ );
+ expect(filtered.map((item) => item.command.name)).toEqual([
+ "skill:ui",
+ "skill:building-native-ui",
+ ]);
+ expect(filtered[0]?.match?.ranges).toEqual([{ start: 0, end: 2 }]);
+ });
+});
diff --git a/apps/app/src/features/chat/components/input/slash-command-suggestions.ts b/apps/app/src/features/chat/components/input/slash-command-suggestions.ts
index 20fa6387d..531107295 100644
--- a/apps/app/src/features/chat/components/input/slash-command-suggestions.ts
+++ b/apps/app/src/features/chat/components/input/slash-command-suggestions.ts
@@ -2,6 +2,8 @@ import type { AgentCommand } from "@getpie/contract";
import { PluginKey } from "@tiptap/pm/state";
import type { Editor, Range } from "@tiptap/react";
+import { searchSkills, type SkillMatch } from "@/lib/skill-search";
+
export const slashCommandPluginKey = new PluginKey("chatSlashCommands");
export type SlashCommandItem = {
@@ -9,6 +11,7 @@ export type SlashCommandItem = {
title: string;
description?: string;
keywords: string[];
+ match?: SkillMatch | null;
};
export type SlashCommandState =
@@ -36,7 +39,7 @@ export function createSlashCommandSuggestionItems(
}));
}
-export function filterSlashCommandItems(
+function rankCommandItems(
items: ReadonlyArray,
query: string,
): SlashCommandItem[] {
@@ -49,24 +52,36 @@ export function filterSlashCommandItems(
item.keywords.some((keyword) => keyword.toLowerCase().includes(normalizedQuery)),
)
: [...items];
+ if (!normalizedQuery) return matched;
// oxlint-disable-next-line unicorn/no-array-sort -- matched is a fresh array
- const ranked = normalizedQuery
- ? matched.sort((a, b) => {
- const aTitle = a.title.slice(1).toLowerCase();
- const bTitle = b.title.slice(1).toLowerCase();
- if (aTitle === normalizedQuery && bTitle !== normalizedQuery) return -1;
- if (bTitle === normalizedQuery && aTitle !== normalizedQuery) return 1;
- if (aTitle.startsWith(normalizedQuery) && !bTitle.startsWith(normalizedQuery)) return -1;
- if (bTitle.startsWith(normalizedQuery) && !aTitle.startsWith(normalizedQuery)) return 1;
- return 0;
- })
- : matched;
+ return matched.sort((left, right) => {
+ const leftTitle = left.title.slice(1).toLowerCase();
+ const rightTitle = right.title.slice(1).toLowerCase();
+ if (leftTitle === normalizedQuery && rightTitle !== normalizedQuery) return -1;
+ if (rightTitle === normalizedQuery && leftTitle !== normalizedQuery) return 1;
+ if (leftTitle.startsWith(normalizedQuery) && !rightTitle.startsWith(normalizedQuery)) return -1;
+ if (rightTitle.startsWith(normalizedQuery) && !leftTitle.startsWith(normalizedQuery)) return 1;
+ return 0;
+ });
+}
+
+export function filterSlashCommandItems(
+ items: ReadonlyArray,
+ query: string,
+): SlashCommandItem[] {
+ const commands = items.filter((item) => item.command.source !== "skill");
+ const skills = items.filter((item) => item.command.source === "skill");
+ const rankedSkills = searchSkills(
+ skills.map((item) => ({
+ item,
+ name: slashCommandLabel(item.command),
+ description: item.description ?? "",
+ })),
+ query,
+ ).map((hit) => ({ ...hit.item, match: hit.match }));
- return [
- ...ranked.filter((item) => item.command.source !== "skill"),
- ...ranked.filter((item) => item.command.source === "skill"),
- ];
+ return [...rankCommandItems(commands, query), ...rankedSkills];
}
export function insertSlashCommand(editor: Editor, range: Range, item: SlashCommandItem): void {
diff --git a/apps/app/src/features/packages/skills-panel.tsx b/apps/app/src/features/packages/skills-panel.tsx
index fb1869472..5921253ca 100644
--- a/apps/app/src/features/packages/skills-panel.tsx
+++ b/apps/app/src/features/packages/skills-panel.tsx
@@ -1,4 +1,3 @@
-import type { SkillItem } from "@getpie/contract/skills";
import {
Empty,
EmptyDescription,
@@ -12,6 +11,8 @@ import { useMemo, useState, type ReactElement } from "react";
import Loader from "@/components/loader";
import { useLocalOrpc } from "@/lib/environment-orpc";
+import { HighlightedMatch } from "@/lib/highlighted-match";
+import { searchSkills } from "@/lib/skill-search";
function skillInitial(name: string): string {
return name.slice(0, 1).toUpperCase();
@@ -25,17 +26,7 @@ export function SkillsPanel(): ReactElement {
meta: { errorMode: "inline" },
});
- const items = useMemo(() => {
- const all: ReadonlyArray = list.data ?? [];
- const needle = query.trim().toLowerCase();
- if (needle.length === 0) return all;
- return all.filter(
- (item) =>
- item.name.toLowerCase().includes(needle) ||
- item.description.toLowerCase().includes(needle) ||
- item.source.toLowerCase().includes(needle),
- );
- }, [list.data, query]);
+ const items = useMemo(() => searchSkills(list.data ?? [], query), [list.data, query]);
if (list.isPending && list.data === undefined) return ;
@@ -71,19 +62,27 @@ export function SkillsPanel(): ReactElement {
Installed
{items.length === 0 ? (
-
-
-
-
-
- No skills yet
-
- Drop a SKILL.md folder into{" "}
- ~/.pi/agent/skills or install a package
- that ships skills.
-
-
-
+ query.trim().length > 0 ? (
+
+
+ No matching skills
+
+
+ ) : (
+
+
+
+
+
+ No skills yet
+
+ Drop a SKILL.md folder into{" "}
+ ~/.pi/agent/skills or install a package
+ that ships skills.
+
+
+
+ )
) : (
{items.map((item) => (
@@ -95,8 +94,18 @@ export function SkillsPanel(): ReactElement {
{skillInitial(item.name)}
-
{item.name}
-
{item.description}
+
+
+
+
+
+
diff --git a/apps/app/src/lib/highlighted-match.tsx b/apps/app/src/lib/highlighted-match.tsx
new file mode 100644
index 000000000..14195d310
--- /dev/null
+++ b/apps/app/src/lib/highlighted-match.tsx
@@ -0,0 +1,37 @@
+import type { ReactElement, ReactNode } from "react";
+
+import type { TextRange } from "./skill-search";
+
+export function HighlightedMatch(props: {
+ text: string;
+ ranges?: readonly TextRange[];
+}): ReactElement | string {
+ const ranges = props.ranges;
+ if (!ranges || ranges.length === 0) return props.text;
+
+ const nodes: ReactNode[] = [];
+ let cursor = 0;
+ for (const range of ranges) {
+ if (range.start > cursor) {
+ nodes.push(
+
+ {props.text.slice(cursor, range.start)}
+ ,
+ );
+ }
+ nodes.push(
+
+ {props.text.slice(range.start, range.end)}
+ ,
+ );
+ cursor = range.end;
+ }
+ if (cursor < props.text.length) {
+ nodes.push(
+
+ {props.text.slice(cursor)}
+ ,
+ );
+ }
+ return {nodes};
+}
diff --git a/apps/app/src/lib/skill-search.test.ts b/apps/app/src/lib/skill-search.test.ts
new file mode 100644
index 000000000..9c77c316f
--- /dev/null
+++ b/apps/app/src/lib/skill-search.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+
+import { searchSkills } from "./skill-search";
+
+function skill(name: string, description = "") {
+ return { name, description };
+}
+
+describe("searchSkills", () => {
+ it("keeps the original order for an empty query", () => {
+ const items = [skill("b"), skill("a")];
+ expect(searchSkills(items, " ").map((item) => item.name)).toEqual(["b", "a"]);
+ expect(searchSkills(items, "")[0]?.match).toBeNull();
+ });
+
+ it("ranks an exact name ahead of a boundary name and drops unrelated names", () => {
+ const result = searchSkills(
+ [
+ skill("agent-browser", "Browser automation"),
+ skill("building-native-ui", "Complete guide"),
+ skill("ui", "Explore UI"),
+ ],
+ "ui",
+ );
+ expect(result.map((item) => item.name)).toEqual(["ui", "building-native-ui"]);
+ expect(result[1]?.match).toEqual({
+ field: "name",
+ ranges: [{ start: "building-native-".length, end: "building-native-ui".length }],
+ });
+ });
+
+ it("matches a separator-insensitive phrase and skips the separator", () => {
+ expect(searchSkills([skill("agent-browser")], "agent browser")[0]?.match).toEqual({
+ field: "name",
+ ranges: [
+ { start: 0, end: 5 },
+ { start: 6, end: 13 },
+ ],
+ });
+ });
+
+ it("fuzzy-matches a name abbreviation and records those letters", () => {
+ const result = searchSkills(
+ [skill("github"), skill("agent-browser"), skill("gh-fix-ci")],
+ "gfc",
+ );
+ expect(result.map((item) => item.name)).toEqual(["gh-fix-ci"]);
+ expect(result[0]?.match?.ranges).toEqual([
+ { start: 0, end: 1 },
+ { start: 3, end: 4 },
+ { start: 7, end: 8 },
+ ]);
+ });
+
+ it("ranks a name match ahead of a description-only match", () => {
+ const result = searchSkills(
+ [skill("release-notes", "Prepare the changelog"), skill("ship", "Cut a release")],
+ "release",
+ );
+ expect(result.map((item) => item.name)).toEqual(["release-notes", "ship"]);
+ expect(result[0]?.match?.field).toBe("name");
+ expect(result[1]?.match).toEqual({
+ field: "description",
+ ranges: [{ start: "Cut a ".length, end: "Cut a release".length }],
+ });
+ });
+
+ it("matches a multi-word query across separators without highlighting them", () => {
+ expect(searchSkills([skill("gh-fix-ci")], "fix ci")[0]?.match?.ranges).toEqual([
+ { start: 3, end: 6 },
+ { start: 7, end: 9 },
+ ]);
+ });
+});
diff --git a/apps/app/src/lib/skill-search.ts b/apps/app/src/lib/skill-search.ts
new file mode 100644
index 000000000..9dd110e07
--- /dev/null
+++ b/apps/app/src/lib/skill-search.ts
@@ -0,0 +1,179 @@
+export type TextRange = { start: number; end: number };
+
+export type SkillMatch = {
+ field: "name" | "description";
+ ranges: TextRange[];
+};
+
+type NormChar = { index: number; char: string };
+type FieldMatch = { score: number; ranges: TextRange[] };
+
+const NAME_TIER = 0;
+const DESCRIPTION_TIER = 5;
+// Keeps a name tier ahead of the next tier after position and length penalties.
+const TIER_GAP = 256;
+
+function isSeparator(char: string): boolean {
+ return char === "-" || char === "_" || char === "/" || /\s/u.test(char);
+}
+
+function normalizeChars(value: string): NormChar[] {
+ const chars: NormChar[] = [];
+ let index = 0;
+ while (index < value.length) {
+ const char = value[index] ?? "";
+ if (!isSeparator(char)) {
+ chars.push({ index, char: char.toLowerCase() });
+ index += 1;
+ continue;
+ }
+ while (index < value.length && isSeparator(value[index] ?? "")) index += 1;
+ if (chars.length > 0 && index < value.length) chars.push({ index: -1, char: " " });
+ }
+ return chars;
+}
+
+function normalized(value: string): string {
+ return normalizeChars(value)
+ .map((char) => char.char)
+ .join("");
+}
+
+function mergeRanges(ranges: readonly TextRange[]): TextRange[] {
+ const merged: TextRange[] = [];
+ for (const range of ranges) {
+ const last = merged.at(-1);
+ if (last && last.end === range.start) last.end = range.end;
+ else merged.push({ ...range });
+ }
+ return merged;
+}
+
+function rangesFor(chars: readonly NormChar[], start: number, end: number): TextRange[] {
+ return mergeRanges(
+ chars
+ .slice(start, end)
+ .flatMap((char) => (char.index < 0 ? [] : [{ start: char.index, end: char.index + 1 }])),
+ );
+}
+
+function subsequenceIndexes(value: string, query: string): number[] | null {
+ const indexes: number[] = [];
+ let queryIndex = 0;
+ for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) {
+ if (value[valueIndex] !== query[queryIndex]) continue;
+ indexes.push(valueIndex);
+ queryIndex += 1;
+ if (queryIndex === query.length) return indexes;
+ }
+ return null;
+}
+
+function penalty(start: number, valueLength: number, queryLength: number): number {
+ return Math.min(64, start * 2) + Math.min(64, Math.max(0, valueLength - queryLength));
+}
+
+function tierScore(offset: number, tier: number, extra: number): number {
+ return (offset + tier) * TIER_GAP + extra;
+}
+
+function matchField(
+ value: string,
+ query: string,
+ offset: number,
+ fuzzy: boolean,
+): FieldMatch | null {
+ const chars = normalizeChars(value);
+ const text = chars.map((char) => char.char).join("");
+ if (text.length === 0 || query.length === 0) return null;
+
+ if (text === query) {
+ return { score: tierScore(offset, 0, 0), ranges: rangesFor(chars, 0, chars.length) };
+ }
+ if (text.startsWith(query)) {
+ return {
+ score: tierScore(offset, 1, penalty(0, text.length, query.length)),
+ ranges: rangesFor(chars, 0, query.length),
+ };
+ }
+ const boundary = text.indexOf(` ${query}`);
+ if (boundary !== -1) {
+ const start = boundary + 1;
+ return {
+ score: tierScore(offset, 2, penalty(start, text.length, query.length)),
+ ranges: rangesFor(chars, start, start + query.length),
+ };
+ }
+ const includes = text.indexOf(query);
+ if (includes !== -1) {
+ return {
+ score: tierScore(offset, 3, penalty(includes, text.length, query.length)),
+ ranges: rangesFor(chars, includes, includes + query.length),
+ };
+ }
+ if (!fuzzy) return null;
+
+ const indexes = subsequenceIndexes(text, query);
+ if (!indexes) return null;
+ const first = indexes[0] ?? 0;
+ const last = indexes.at(-1) ?? first;
+ let gap = 0;
+ for (let index = 1; index < indexes.length; index += 1) {
+ gap += (indexes[index] ?? 0) - (indexes[index - 1] ?? 0) - 1;
+ }
+ const fuzzyPenalty =
+ first * 2 +
+ gap * 3 +
+ (last - first + 1 - indexes.length) +
+ Math.min(64, text.length - query.length);
+ return {
+ score: tierScore(offset, 4, fuzzyPenalty),
+ ranges: rangesFor(
+ indexes.flatMap((index) => {
+ const char = chars[index];
+ return char ? [char] : [];
+ }),
+ 0,
+ indexes.length,
+ ),
+ };
+}
+
+function bestMatch(
+ name: string,
+ description: string,
+ query: string,
+): (SkillMatch & { score: number }) | null {
+ const needle = normalized(query);
+ if (needle.length === 0) return null;
+ const nameMatch = matchField(name, needle, NAME_TIER, true);
+ const descriptionMatch = matchField(description, needle, DESCRIPTION_TIER, false);
+ if (nameMatch && (!descriptionMatch || nameMatch.score <= descriptionMatch.score)) {
+ return { field: "name", ranges: nameMatch.ranges, score: nameMatch.score };
+ }
+ if (descriptionMatch) {
+ return { field: "description", ranges: descriptionMatch.ranges, score: descriptionMatch.score };
+ }
+ return null;
+}
+
+export function searchSkills(
+ items: readonly T[],
+ query: string,
+): Array {
+ if (normalized(query).length === 0) return items.map((item) => ({ ...item, match: null }));
+
+ return items
+ .map((item, index) => ({ item, index, match: bestMatch(item.name, item.description, query) }))
+ .filter(
+ (entry): entry is typeof entry & { match: SkillMatch & { score: number } } =>
+ entry.match !== null,
+ )
+ .sort(
+ (left, right) =>
+ left.match.score - right.match.score ||
+ left.item.name.localeCompare(right.item.name) ||
+ left.index - right.index,
+ )
+ .map(({ item, match }) => ({ ...item, match: { field: match.field, ranges: match.ranges } }));
+}