Skip to content

Commit a48d656

Browse files
committed
fix(vscode): filter the slash command menu by what was typed
The menu matched a query against command descriptions as a loose subsequence, so the letters of "research" were found scattered through almost every description and the list came back unfiltered. Typing then looked like it did nothing but move a highlight that never left the first row. Matching is now ranked, with every way of matching a command name ordered ahead of a description match, and it stays forgiving about skipped letters and dropped separators. The selection also resets when the query changes, since a reordered list left it pointing at an unrelated command.
1 parent 44da7d8 commit a48d656

4 files changed

Lines changed: 150 additions & 21 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
NO_MATCH,
5+
rankSlashCommands,
6+
scoreCommand,
7+
} from "../webview-ui/src/components/inputarea/hooks/slash-command-match";
8+
9+
function command(name: string, description = ""): { name: string; description: string; aliases: string[] } {
10+
return { name, description, aliases: [] };
11+
}
12+
13+
const RESEARCH_SKILL = command("skill:research-writing", "Write research reports.");
14+
const CUSTOM_THEME = command(
15+
"custom-theme",
16+
// Its letters spell "research" in order, which is exactly what the old
17+
// subsequence match over descriptions matched on.
18+
"Create or edit a pythinker-code custom color theme — a JSON file of color tokens, then reload the chat.",
19+
);
20+
21+
function rank(commands: readonly ReturnType<typeof command>[], query: string): string[] {
22+
return rankSlashCommands(commands, query).map((entry) => entry.name);
23+
}
24+
25+
describe("scoreCommand", () => {
26+
it("does not match a command whose description merely contains the query's letters", () => {
27+
// The old subsequence match over descriptions let "research" through on
28+
// "Create or edit ...", which made the menu look unfiltered.
29+
expect(scoreCommand(CUSTOM_THEME, "research")).toBe(NO_MATCH);
30+
});
31+
32+
it("finds a namespaced skill by the part the user actually types", () => {
33+
expect(rank([CUSTOM_THEME, RESEARCH_SKILL], "research")).toEqual(["skill:research-writing"]);
34+
});
35+
36+
it("ranks a name prefix above a name that only contains the query", () => {
37+
expect(rank([command("sub-skill"), command("skills")], "skill")).toEqual(["skills", "sub-skill"]);
38+
});
39+
40+
it("stays forgiving about dropped separators and skipped letters", () => {
41+
expect(Number.isFinite(scoreCommand(RESEARCH_SKILL, "researchwriting"))).toBe(true);
42+
expect(Number.isFinite(scoreCommand(RESEARCH_SKILL, "reswrit"))).toBe(true);
43+
});
44+
45+
it("still falls back to the description when nothing matches the name", () => {
46+
expect(Number.isFinite(scoreCommand(CUSTOM_THEME, "color"))).toBe(true);
47+
});
48+
49+
it("ranks every name match above a description match", () => {
50+
expect(rank([CUSTOM_THEME, command("theme-picker", "Nothing to see.")], "theme")).toEqual([
51+
"theme-picker",
52+
"custom-theme",
53+
]);
54+
});
55+
});

apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@ function highlightMatch(text: string, query: string): React.ReactNode {
1717

1818
const lowerText = text.toLowerCase();
1919
const lowerQuery = query.toLowerCase();
20+
21+
// The common case is a contiguous match; highlighting it as one run reads far
22+
// better than scattering bold letters across the whole name.
23+
const at = lowerText.indexOf(lowerQuery);
24+
if (at !== -1) {
25+
return (
26+
<>
27+
{text.slice(0, at)}
28+
<span className="text-foreground font-semibold">{text.slice(at, at + query.length)}</span>
29+
{text.slice(at + query.length)}
30+
</>
31+
);
32+
}
33+
2034
const parts: React.ReactNode[] = [];
2135
let lastIdx = 0;
2236
let qi = 0;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { SlashCommandInfo } from "shared/legacy-sdk";
2+
3+
export const NO_MATCH = Number.POSITIVE_INFINITY;
4+
5+
function isSubsequence(text: string, query: string): boolean {
6+
let qi = 0;
7+
for (let i = 0; i < text.length && qi < query.length; i++) {
8+
if (text[i] === query[qi]) {
9+
qi++;
10+
}
11+
}
12+
return qi === query.length;
13+
}
14+
15+
/** Separators are noise while typing: `researchwriting` should still find `skill:research-writing`. */
16+
function letters(text: string): string {
17+
return text.toLowerCase().replaceAll(/[^a-z0-9]/g, "");
18+
}
19+
20+
function matchesAnyWordPrefix(text: string, query: string): boolean {
21+
return text
22+
.toLowerCase()
23+
.split(/[^a-z0-9]+/)
24+
.some((word) => word.startsWith(query));
25+
}
26+
27+
/**
28+
* Lower is a better match, `NO_MATCH` means the command is filtered out. Every
29+
* way of matching the name outranks the description: the name is what the user
30+
* is typing, and matching a long description loosely lets nearly every command
31+
* through — which is what made the menu look unfiltered.
32+
*/
33+
export function scoreCommand(command: SlashCommandInfo, query: string): number {
34+
const name = command.name.toLowerCase();
35+
const q = query.toLowerCase();
36+
if (name.startsWith(q)) {
37+
return 0;
38+
}
39+
// A namespaced skill (`skill:research-writing`) should also match on the part
40+
// the user is actually thinking of, not only on its full prefix.
41+
if (matchesAnyWordPrefix(name, q)) {
42+
return 1;
43+
}
44+
if (name.includes(q)) {
45+
return 2;
46+
}
47+
// Forgiving tier: skipped letters and dropped separators still match.
48+
if (isSubsequence(letters(name), letters(q))) {
49+
return 3;
50+
}
51+
const description = command.description;
52+
if (matchesAnyWordPrefix(description, q)) {
53+
return 4;
54+
}
55+
return description.toLowerCase().includes(q) ? 5 : NO_MATCH;
56+
}
57+
58+
/** Commands that match `query`, best match first. An empty query keeps the original order. */
59+
export function rankSlashCommands(
60+
commands: readonly SlashCommandInfo[],
61+
query: string,
62+
): SlashCommandInfo[] {
63+
if (!query) {
64+
return [...commands];
65+
}
66+
return commands
67+
.map((command) => ({ command, score: scoreCommand(command, query) }))
68+
.filter((entry) => entry.score !== NO_MATCH)
69+
.toSorted((left, right) => left.score - right.score)
70+
.map((entry) => entry.command);
71+
}

apps/vscode/webview-ui/src/components/inputarea/hooks/useSlashMenu.ts

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,14 @@
1-
import { useMemo, useState, useCallback } from "react";
1+
import { useCallback, useEffect, useMemo, useState } from "react";
22
import { useSettingsStore } from "@/stores";
33
import type { SlashCommandInfo } from "shared/legacy-sdk";
4+
import { rankSlashCommands } from "./slash-command-match";
45

56
interface ActiveToken {
67
trigger: "/" | "@";
78
start: number;
89
query: string;
910
}
1011

11-
function fuzzyMatch(text: string, query: string): boolean {
12-
if (!query) {
13-
return true;
14-
}
15-
const lowerText = text.toLowerCase();
16-
const lowerQuery = query.toLowerCase();
17-
let qi = 0;
18-
for (let i = 0; i < lowerText.length && qi < lowerQuery.length; i++) {
19-
if (lowerText[i] === lowerQuery[qi]) {
20-
qi++;
21-
}
22-
}
23-
return qi === lowerQuery.length;
24-
}
25-
2612
export function findActiveToken(text: string, cursorPos: number): ActiveToken | null {
2713
const beforeCursor = text.slice(0, cursorPos);
2814
const lastSpace = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n"), beforeCursor.lastIndexOf("\t"), -1);
@@ -56,17 +42,20 @@ export function useSlashMenu(activeToken: ActiveToken | null, onSelectCommand: (
5642
if (!showSlashMenu) {
5743
return [];
5844
}
59-
const q = activeToken.query;
60-
if (!q) {
61-
return slashCommands;
62-
}
63-
return slashCommands.filter((cmd) => fuzzyMatch(cmd.name, q) || fuzzyMatch(cmd.description, q));
45+
return rankSlashCommands(slashCommands, activeToken.query);
6446
}, [showSlashMenu, activeToken?.query, slashCommands]);
6547

6648
const resetSlashMenu = useCallback(() => {
6749
setSelectedIndex(0);
6850
}, []);
6951

52+
// Every keystroke reorders the list, so a selection carried over from the
53+
// previous query points at an unrelated command — or past the end of the list.
54+
const query = showSlashMenu ? activeToken.query : "";
55+
useEffect(() => {
56+
setSelectedIndex(0);
57+
}, [query]);
58+
7059
const handleSlashMenuKey = useCallback(
7160
(e: React.KeyboardEvent): boolean => {
7261
if (!showSlashMenu || filteredCommands.length === 0) {

0 commit comments

Comments
 (0)