Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/app/src/features/chat/components/input/slash-command-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -124,11 +125,19 @@ function SlashCommandPopup({
<Icon className="size-3.5 shrink-0" aria-hidden />
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="max-w-[60%] shrink-0 truncate">
{slashCommandLabel(item.command)}
<HighlightedMatch
text={slashCommandLabel(item.command)}
ranges={item.match?.field === "name" ? item.match.ranges : undefined}
/>
</span>
{item.description ? (
<span className="text-muted-foreground ms-auto min-w-0 flex-1 truncate">
{item.description}
<HighlightedMatch
text={item.description}
ranges={
item.match?.field === "description" ? item.match.ranges : undefined
}
/>
</span>
) : null}
</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ 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 = {
command: AgentCommand;
title: string;
description?: string;
keywords: string[];
match?: SkillMatch | null;
};

export type SlashCommandState =
Expand Down Expand Up @@ -36,7 +39,7 @@ export function createSlashCommandSuggestionItems(
}));
}

export function filterSlashCommandItems(
function rankCommandItems(
items: ReadonlyArray<SlashCommandItem>,
query: string,
): SlashCommandItem[] {
Expand All @@ -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<SlashCommandItem>,
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 {
Expand Down
63 changes: 36 additions & 27 deletions apps/app/src/features/packages/skills-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { SkillItem } from "@getpie/contract/skills";
import {
Empty,
EmptyDescription,
Expand All @@ -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();
Expand All @@ -25,17 +26,7 @@ export function SkillsPanel(): ReactElement {
meta: { errorMode: "inline" },
});

const items = useMemo(() => {
const all: ReadonlyArray<SkillItem> = 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 <Loader />;

Expand Down Expand Up @@ -71,19 +62,27 @@ export function SkillsPanel(): ReactElement {
<section className="flex flex-col gap-4">
<h2 className="text-base font-medium">Installed</h2>
{items.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Sparkles aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>No skills yet</EmptyTitle>
<EmptyDescription>
Drop a <code className="text-foreground">SKILL.md</code> folder into{" "}
<code className="text-foreground">~/.pi/agent/skills</code> or install a package
that ships skills.
</EmptyDescription>
</EmptyHeader>
</Empty>
query.trim().length > 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>No matching skills</EmptyTitle>
</EmptyHeader>
</Empty>
) : (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Sparkles aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>No skills yet</EmptyTitle>
<EmptyDescription>
Drop a <code className="text-foreground">SKILL.md</code> folder into{" "}
<code className="text-foreground">~/.pi/agent/skills</code> or install a package
that ships skills.
</EmptyDescription>
</EmptyHeader>
</Empty>
)
) : (
<ul className="grid grid-cols-1 gap-3 md:grid-cols-2">
{items.map((item) => (
Expand All @@ -95,8 +94,18 @@ export function SkillsPanel(): ReactElement {
{skillInitial(item.name)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.name}</p>
<p className="text-muted-foreground line-clamp-1 text-xs">{item.description}</p>
<p className="truncate text-sm font-medium">
<HighlightedMatch
text={item.name}
ranges={item.match?.field === "name" ? item.match.ranges : undefined}
/>
</p>
<p className="text-muted-foreground line-clamp-1 text-xs">
<HighlightedMatch
text={item.description}
ranges={item.match?.field === "description" ? item.match.ranges : undefined}
/>
</p>
</div>
<CheckIcon aria-hidden="true" className="text-pull-request-open size-4 shrink-0" />
</li>
Expand Down
37 changes: 37 additions & 0 deletions apps/app/src/lib/highlighted-match.tsx
Original file line number Diff line number Diff line change
@@ -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(
<span className="text-muted-foreground" key={`gap-${cursor}`}>
{props.text.slice(cursor, range.start)}
</span>,
);
}
nodes.push(
<mark key={range.start} className="text-foreground bg-transparent font-semibold">
{props.text.slice(range.start, range.end)}
</mark>,
);
cursor = range.end;
}
if (cursor < props.text.length) {
nodes.push(
<span className="text-muted-foreground" key={`gap-${cursor}`}>
{props.text.slice(cursor)}
</span>,
);
}
return <span>{nodes}</span>;
}
74 changes: 74 additions & 0 deletions apps/app/src/lib/skill-search.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
]);
});
});
Loading
Loading