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
243 changes: 243 additions & 0 deletions components/ChatInput.slash-filter.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createJiti } from "jiti";

const jiti = createJiti(import.meta.url, {
jsx: { runtime: "automatic" },
tsconfigPaths: true,
});
const {
filterSlashCommands,
slashMatchRank,
getCleanSlashName,
buildSlashCommandLayout,
} = await jiti.import("./ChatInput.tsx");

test("getCleanSlashName strips skill: prefix but preserves builtin, prompt, and extension names", () => {
assert.equal(getCleanSlashName({ name: "skill:copy-text", source: "skill" }), "copy-text");
assert.equal(getCleanSlashName({ name: "copy", source: "builtin" }), "copy");
assert.equal(getCleanSlashName({ name: "auto-copy", source: "prompt" }), "auto-copy");
assert.equal(getCleanSlashName({ name: "weather-check", source: "extension" }), "weather-check");
assert.equal(getCleanSlashName({ name: "mcp", source: "extension" }), "mcp");
});

test("slashMatchRank orders exact, prefix, token, and substring matches", () => {
const exact = { name: "copy", source: "builtin" };
const prefix = { name: "copy-session", source: "builtin" };
const tokenHyphen = { name: "skill:deep-copy", source: "skill" };
const tokenUnderscore = { name: "skill:deep_copy", source: "skill" };
const substring = { name: "photocopy", source: "builtin" };
const descriptionOnly = {
name: "skill:better-writing",
description: "Polish UI product copy, verb-first buttons, and error recovery",
source: "skill",
};

assert.equal(slashMatchRank(exact, "copy"), 0);
assert.equal(slashMatchRank(prefix, "copy"), 1);
assert.equal(slashMatchRank(tokenHyphen, "copy"), 2);
assert.equal(slashMatchRank(tokenUnderscore, "copy"), 2);
assert.equal(slashMatchRank(substring, "copy"), 3);
assert.equal(slashMatchRank(descriptionOnly, "copy"), 4);
});

test("slashMatchRank supports matching skills with or without explicit skill: prefix", () => {
const skill = { name: "skill:better-writing", source: "skill" };

// Matching without prefix
assert.equal(slashMatchRank(skill, "better-writing"), 0);
assert.equal(slashMatchRank(skill, "better"), 1);
assert.equal(slashMatchRank(skill, "writing"), 2);

// Matching with explicit skill: prefix
assert.equal(slashMatchRank(skill, "skill:better-writing"), 0);
assert.equal(slashMatchRank(skill, "skill:better"), 1);
});

test("filterSlashCommands suppresses noisy description-only matches when name matches exist across all sources", () => {
const commands = [
{
name: "copy",
description: "Copy the last assistant message",
source: "builtin",
},
{
name: "skill:better-writing",
description: "Polish UI product copy, verb-first buttons, contextual error recovery, and forward-pointing empty states",
source: "skill",
},
{
name: "skill:create-agents-md",
description: "Create, update, or audit project AGENTS.md from repository evidence without guessing or copying outdated templates",
source: "skill",
},
{
name: "skill:pen-design",
description: "Design high-fidelity UI screens and copy layout systems in pen.dev using the 5-doc system",
source: "skill",
},
{
name: "custom-prompt",
description: "A prompt template that mentions copying files",
source: "prompt",
},
{
name: "ext-tool",
description: "Extension that produces a copy of the report",
source: "extension",
},
];

const results = filterSlashCommands(commands, "copy");

// Only the exact/name match /copy should be returned; all description mentions across skills, prompts, and extensions are suppressed
assert.deepEqual(
results.map((c) => c.name),
["copy"],
);
});

test("filterSlashCommands preserves source priority order: builtin -> extension -> prompt -> skill on equal rank", () => {
const commands = [
{ name: "skill:sync", source: "skill" },
{ name: "sync", source: "prompt" },
{ name: "sync", source: "builtin" },
{ name: "sync", source: "extension" },
];

const results = filterSlashCommands(commands, "sync");

// All have exact match (rank 0); must sort strictly by SLASH_SOURCE_ORDER
assert.deepEqual(
results.map((c) => `${c.source}:${c.name}`),
[
"builtin:sync",
"extension:sync",
"prompt:sync",
"skill:skill:sync",
],
);
});

test("filterSlashCommands falls back to description matches across all sources when no names match", () => {
const commands = [
{
name: "copy",
description: "Copy the last assistant message",
source: "builtin",
},
{
name: "weather",
description: "Fetch assistant weather report",
source: "extension",
},
{
name: "daily-brief",
description: "Prepare assistant morning brief",
source: "prompt",
},
{
name: "skill:code-review",
description: "Assistant review for pull requests",
source: "skill",
},
{
name: "skill:unrelated",
description: "Something else entirely",
source: "skill",
},
];

const results = filterSlashCommands(commands, "assistant");

// No command named "assistant"; fallback activates for all matching descriptions in source order
assert.deepEqual(
results.map((c) => `${c.source}:${c.name}`),
[
"builtin:copy",
"extension:weather",
"prompt:daily-brief",
"skill:skill:code-review",
],
);
});

test("filterSlashCommands returns all commands in source order when query is empty", () => {
const commands = [
{ name: "skill:pen-design", source: "skill" },
{ name: "reload", source: "builtin" },
{ name: "mcp", source: "extension" },
{ name: "generate-report", source: "prompt" },
{ name: "skill:better-writing", source: "skill" },
{ name: "copy", source: "builtin" },
];

const results = filterSlashCommands(commands, "");

// Must group by source order and alphabetize within source
assert.deepEqual(
results.map((c) => `${c.source}:${c.name}`),
[
"builtin:copy",
"builtin:reload",
"extension:mcp",
"prompt:generate-report",
"skill:skill:better-writing",
"skill:skill:pen-design",
],
);
});

test("filterSlashCommands integrates with buildSlashCommandLayout and dormancy partitioning", () => {
const commands = [
{ name: "copy", source: "builtin" },
{ name: "skill:copy-active", source: "skill" },
{ name: "skill:copy-dormant", source: "skill" },
{ name: "copy-prompt", source: "prompt" },
{ name: "copy-ext", source: "extension" },
];

const filtered = filterSlashCommands(commands, "copy");
const layout = buildSlashCommandLayout(filtered, {
"copy-dormant": true,
"copy-active": false,
});

// Verify group sources present and in order
assert.deepEqual(
layout.groups.map((g) => g.source),
["builtin", "extension", "prompt", "skill"],
);

// In skill group, active skill must precede dormant skill
const skillGroup = layout.groups.find((g) => g.source === "skill");
assert.deepEqual(
skillGroup.items.map((i) => i.command.name),
["skill:copy-active", "skill:copy-dormant"],
);

// Layout commands array matches flattened groups
assert.deepEqual(
layout.commands.map((c) => c.name),
["copy", "copy-ext", "copy-prompt", "skill:copy-active", "skill:copy-dormant"],
);
});

test("filterSlashCommands handles uppercase queries, missing descriptions, and regex special characters safely", () => {
const commands = [
{ name: "copy", source: "builtin" }, // description undefined
{ name: "skill:deep-copy", source: "skill", description: undefined },
{ name: "mcp", source: "extension", description: null },
];

// Uppercase query
const upperResults = filterSlashCommands(commands, "COPY");
assert.deepEqual(
upperResults.map((c) => c.name),
["copy", "skill:deep-copy"],
);

// Special regex characters should not crash or throw
const regexResults = filterSlashCommands(commands, "copy[a-z]*+?\\");
assert.deepEqual(regexResults, []);
});
86 changes: 66 additions & 20 deletions components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,18 +376,75 @@ const SLASH_SOURCE_ORDER: Record<SlashCommandSource, number> = {
skill: 3,
};

function slashMatchRank(command: SlashCommandPaletteItem, query: string, t: (key: string) => string): number {
const name = command.name.toLowerCase();
export function getCleanSlashName(command: SlashCommandPaletteItem): string {
return command.source === "skill" && command.name.startsWith("skill:")
? command.name.slice("skill:".length).toLowerCase()
: command.name.toLowerCase();
}

export function slashMatchRank(
command: SlashCommandPaletteItem,
query: string,
t: (key: string) => string = (k) => k,
): number {
const cleanName = getCleanSlashName(command);
const rawName = command.name.toLowerCase();
const q = query.toLowerCase();

if (cleanName === q || rawName === q) return 0;
if (cleanName.startsWith(q) || rawName.startsWith(q)) return 1;
if (cleanName.includes(`-${q}`) || cleanName.includes(`_${q}`) || rawName.includes(`-${q}`) || rawName.includes(`_${q}`)) return 2;
if (cleanName.includes(q) || rawName.includes(q)) return 3;

const description = getSlashDescription(command, t).toLowerCase();
if (name === query) return 0;
if (name.startsWith(query)) return 1;
if (name.includes(query)) return 2;
if (description.includes(query)) return 3;
return 4;
if (description.includes(q)) return 4;
return 5;
}

function getSlashDescription(command: SlashCommandPaletteItem, t: (key: string) => string): string {
return command.source === "builtin" ? t(command.description) : command.description ?? "";
if (command.source === "builtin") {
return command.description ? (t(command.description) ?? "") : "";
}
return command.description ?? "";
}

export function filterSlashCommands(
commands: SlashCommandPaletteItem[],
query: string,
t: (key: string) => string = (k) => k,
): SlashCommandPaletteItem[] {
if (!query) {
return [...commands].sort((a, b) => {
return SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source]
|| TEXT_COLLATOR.compare(a.name, b.name);
});
}

const normalizedQuery = query.toLowerCase();

const hasNameMatch = commands.some((command) => {
const cleanName = getCleanSlashName(command);
const rawName = command.name.toLowerCase();
return cleanName.includes(normalizedQuery) || rawName.includes(normalizedQuery);
});

return [...commands]
.filter((command) => {
const cleanName = getCleanSlashName(command);
const rawName = command.name.toLowerCase();
const matchesName = cleanName.includes(normalizedQuery) || rawName.includes(normalizedQuery);

if (hasNameMatch) return matchesName;

const description = getSlashDescription(command, t).toLowerCase();
return matchesName || description.includes(normalizedQuery);
})
.sort((a, b) => {
const rankDelta = slashMatchRank(a, normalizedQuery, t) - slashMatchRank(b, normalizedQuery, t);
if (rankDelta !== 0) return rankDelta;
return SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source]
|| TEXT_COLLATOR.compare(a.name, b.name);
});
}

// Skill slash commands are named "skill:<skillName>"; look the skill up in the
Expand Down Expand Up @@ -1103,18 +1160,7 @@ export const ChatInput = memo(forwardRef<ChatInputHandle, Props>(function ChatIn
? BUILTIN_SLASH_COMMANDS.filter((command) => command.availableWhileStreaming)
: BUILTIN_SLASH_COMMANDS;
const commands = [...builtinCommands, ...(slashCommands ?? [])];
return [...commands]
.filter((command) => {
const name = command.name.toLowerCase();
const description = getSlashDescription(command, t).toLowerCase();
return name.includes(slashQuery) || description.includes(slashQuery);
})
.sort((a, b) => {
const rankDelta = slashMatchRank(a, slashQuery, t) - slashMatchRank(b, slashQuery, t);
if (rankDelta !== 0) return rankDelta;
return SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source]
|| TEXT_COLLATOR.compare(a.name, b.name);
});
return filterSlashCommands(commands, slashQuery, t);
})();

const {
Expand Down
Loading