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}

    +

    + +

    +

    + +