Skip to content

Commit b0488e2

Browse files
juliusmarmingecodex
andcommitted
Improve provider slash command search and dedupe Claude commands
Co-authored-by: codex <[email protected]>
1 parent e89f114 commit b0488e2

6 files changed

Lines changed: 263 additions & 28 deletions

File tree

‎apps/server/src/provider/Layers/ClaudeProvider.ts‎

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -352,23 +352,64 @@ function nonEmptyProbeString(value: string): string | undefined {
352352
function parseClaudeInitializationCommands(
353353
commands: ReadonlyArray<ClaudeSlashCommand> | undefined,
354354
): ReadonlyArray<ServerProviderSlashCommand> {
355-
return (commands ?? []).flatMap((command) => {
355+
return dedupeSlashCommands(
356+
(commands ?? []).flatMap((command) => {
357+
const name = nonEmptyProbeString(command.name);
358+
if (!name) {
359+
return [];
360+
}
361+
362+
const description = nonEmptyProbeString(command.description);
363+
const argumentHint = nonEmptyProbeString(command.argumentHint);
364+
365+
return [
366+
{
367+
name,
368+
...(description ? { description } : {}),
369+
...(argumentHint ? { input: { hint: argumentHint } } : {}),
370+
} satisfies ServerProviderSlashCommand,
371+
];
372+
}),
373+
);
374+
}
375+
376+
function dedupeSlashCommands(
377+
commands: ReadonlyArray<ServerProviderSlashCommand>,
378+
): ReadonlyArray<ServerProviderSlashCommand> {
379+
const commandsByName = new Map<string, ServerProviderSlashCommand>();
380+
381+
for (const command of commands) {
356382
const name = nonEmptyProbeString(command.name);
357383
if (!name) {
358-
return [];
384+
continue;
359385
}
360386

361-
const description = nonEmptyProbeString(command.description);
362-
const argumentHint = nonEmptyProbeString(command.argumentHint);
363-
364-
return [
365-
{
387+
const key = name.toLowerCase();
388+
const existing = commandsByName.get(key);
389+
if (!existing) {
390+
commandsByName.set(key, {
391+
...command,
366392
name,
367-
...(description ? { description } : {}),
368-
...(argumentHint ? { input: { hint: argumentHint } } : {}),
369-
} satisfies ServerProviderSlashCommand,
370-
];
371-
});
393+
});
394+
continue;
395+
}
396+
397+
commandsByName.set(key, {
398+
...existing,
399+
...(existing.description
400+
? {}
401+
: command.description
402+
? { description: command.description }
403+
: {}),
404+
...(existing.input?.hint
405+
? {}
406+
: command.input?.hint
407+
? { input: { hint: command.input.hint } }
408+
: {}),
409+
});
410+
}
411+
412+
return [...commandsByName.values()];
372413
}
373414

374415
/**
@@ -534,6 +575,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
534575
Effect.orElseSucceed(() => undefined),
535576
)
536577
: undefined) ?? [];
578+
const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
537579

538580
// ── Auth check + subscription detection ────────────────────────────
539581

@@ -569,7 +611,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
569611
enabled: claudeSettings.enabled,
570612
checkedAt,
571613
models,
572-
slashCommands,
614+
slashCommands: dedupedSlashCommands,
573615
probe: {
574616
installed: true,
575617
version: parsedVersion,
@@ -589,7 +631,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
589631
enabled: claudeSettings.enabled,
590632
checkedAt,
591633
models,
592-
slashCommands,
634+
slashCommands: dedupedSlashCommands,
593635
probe: {
594636
installed: true,
595637
version: parsedVersion,
@@ -607,7 +649,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
607649
enabled: claudeSettings.enabled,
608650
checkedAt,
609651
models,
610-
slashCommands,
652+
slashCommands: dedupedSlashCommands,
611653
probe: {
612654
installed: true,
613655
version: parsedVersion,

‎apps/server/src/provider/Layers/ProviderRegistry.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,47 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
972972
),
973973
);
974974

975+
it.effect("deduplicates probed claude slash commands by name", () =>
976+
Effect.gen(function* () {
977+
const status = yield* checkClaudeProviderStatus(
978+
() => Effect.succeed("maxplan"),
979+
() =>
980+
Effect.succeed([
981+
{
982+
name: "ui",
983+
description: "Explore and refine UI",
984+
},
985+
{
986+
name: "ui",
987+
input: { hint: "component-or-screen" },
988+
},
989+
]),
990+
);
991+
992+
assert.deepStrictEqual(status.slashCommands, [
993+
{
994+
name: "ui",
995+
description: "Explore and refine UI",
996+
input: { hint: "component-or-screen" },
997+
},
998+
]);
999+
}).pipe(
1000+
Effect.provide(
1001+
mockSpawnerLayer((args) => {
1002+
const joined = args.join(" ");
1003+
if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
1004+
if (joined === "auth status")
1005+
return {
1006+
stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
1007+
stderr: "",
1008+
code: 0,
1009+
};
1010+
throw new Error(`Unexpected args: ${joined}`);
1011+
}),
1012+
),
1013+
),
1014+
);
1015+
9751016
it.effect("returns an api key label for claude api key auth", () =>
9761017
Effect.gen(function* () {
9771018
const status = yield* checkClaudeProviderStatus();

‎apps/web/src/components/chat/ChatComposer.tsx‎

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions";
6969
import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
7070
import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
7171
import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
72+
import { searchSlashCommandItems } from "./composerSlashCommandSearch";
7273
import {
7374
getComposerProviderState,
7475
renderProviderTraitsMenuContent,
@@ -748,14 +749,7 @@ export const ChatComposer = memo(
748749
if (!query) {
749750
return slashCommandItems;
750751
}
751-
return slashCommandItems.filter(
752-
(item) =>
753-
item.label.slice(1).toLowerCase().includes(query) ||
754-
item.description.toLowerCase().includes(query) ||
755-
(item.type === "slash-command"
756-
? item.command.includes(query)
757-
: item.command.name.toLowerCase().includes(query)),
758-
);
752+
return searchSlashCommandItems(slashCommandItems, query);
759753
}
760754
if (composerTrigger.kind === "skill") {
761755
return searchProviderSkills(
@@ -868,7 +862,7 @@ export const ChatComposer = memo(
868862
// ------------------------------------------------------------------
869863
const setPromptFromTraits = useCallback(
870864
(nextPrompt: string) => {
871-
if (nextPrompt === prompt) {
865+
if (nextPrompt === promptRef.current) {
872866
scheduleComposerFocus();
873867
return;
874868
}
@@ -879,7 +873,7 @@ export const ChatComposer = memo(
879873
setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length));
880874
scheduleComposerFocus();
881875
},
882-
[composerDraftTarget, prompt, promptRef, scheduleComposerFocus, setComposerDraftPrompt],
876+
[composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt],
883877
);
884878

885879
const providerTraitsMenuContent = renderProviderTraitsMenuContent({
@@ -1763,6 +1757,10 @@ export const ChatComposer = memo(
17631757
resolvedTheme={resolvedTheme}
17641758
isLoading={isComposerMenuLoading}
17651759
triggerKind={composerTriggerKind}
1760+
groupSlashCommandSections={
1761+
composerTrigger?.kind === "slash-command" &&
1762+
composerTrigger.query.trim().length === 0
1763+
}
17661764
emptyStateText={composerMenuEmptyState}
17671765
activeItemId={activeComposerMenuItem?.id ?? null}
17681766
onHighlightedItemChange={onComposerMenuItemHighlighted}

‎apps/web/src/components/chat/ComposerCommandMenu.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,12 @@ function SkillGlyph(props: { className?: string }) {
9090
function groupCommandItems(
9191
items: ComposerCommandItem[],
9292
triggerKind: ComposerTriggerKind | null,
93+
groupSlashCommandSections: boolean,
9394
): ComposerCommandGroup[] {
9495
if (triggerKind === "skill") {
9596
return items.length > 0 ? [{ id: "skills", label: "Skills", items }] : [];
9697
}
97-
if (triggerKind !== "slash-command") {
98+
if (triggerKind !== "slash-command" || !groupSlashCommandSections) {
9899
return [{ id: "default", label: null, items }];
99100
}
100101

@@ -116,15 +117,17 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
116117
resolvedTheme: "light" | "dark";
117118
isLoading: boolean;
118119
triggerKind: ComposerTriggerKind | null;
120+
groupSlashCommandSections?: boolean;
119121
emptyStateText?: string;
120122
activeItemId: string | null;
121123
onHighlightedItemChange: (itemId: string | null) => void;
122124
onSelect: (item: ComposerCommandItem) => void;
123125
}) {
124126
const listRef = useRef<HTMLDivElement>(null);
125127
const groups = useMemo(
126-
() => groupCommandItems(props.items, props.triggerKind),
127-
[props.items, props.triggerKind],
128+
() =>
129+
groupCommandItems(props.items, props.triggerKind, props.groupSlashCommandSections ?? true),
130+
[props.groupSlashCommandSections, props.items, props.triggerKind],
128131
);
129132

130133
useLayoutEffect(() => {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import type { ComposerCommandItem } from "./ComposerCommandMenu";
4+
import { searchSlashCommandItems } from "./composerSlashCommandSearch";
5+
6+
describe("searchSlashCommandItems", () => {
7+
it("moves exact provider command matches ahead of broader description matches", () => {
8+
const items = [
9+
{
10+
id: "slash:default",
11+
type: "slash-command",
12+
command: "default",
13+
label: "/default",
14+
description: "Switch this thread back to normal build mode",
15+
},
16+
{
17+
id: "provider-slash-command:claudeAgent:ui",
18+
type: "provider-slash-command",
19+
provider: "claudeAgent",
20+
command: { name: "ui" },
21+
label: "/ui",
22+
description: "Explore, build, and refine UI.",
23+
},
24+
{
25+
id: "provider-slash-command:claudeAgent:frontend-design",
26+
type: "provider-slash-command",
27+
provider: "claudeAgent",
28+
command: { name: "frontend-design" },
29+
label: "/frontend-design",
30+
description: "Create distinctive, production-grade frontend interfaces",
31+
},
32+
] satisfies Array<
33+
Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>
34+
>;
35+
36+
expect(searchSlashCommandItems(items, "ui").map((item) => item.id)).toEqual([
37+
"provider-slash-command:claudeAgent:ui",
38+
"slash:default",
39+
]);
40+
});
41+
42+
it("supports fuzzy provider command matches", () => {
43+
const items = [
44+
{
45+
id: "provider-slash-command:claudeAgent:gh-fix-ci",
46+
type: "provider-slash-command",
47+
provider: "claudeAgent",
48+
command: { name: "gh-fix-ci" },
49+
label: "/gh-fix-ci",
50+
description: "Fix failing GitHub Actions",
51+
},
52+
{
53+
id: "provider-slash-command:claudeAgent:github",
54+
type: "provider-slash-command",
55+
provider: "claudeAgent",
56+
command: { name: "github" },
57+
label: "/github",
58+
description: "General GitHub help",
59+
},
60+
] satisfies Array<
61+
Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>
62+
>;
63+
64+
expect(searchSlashCommandItems(items, "gfc").map((item) => item.id)).toEqual([
65+
"provider-slash-command:claudeAgent:gh-fix-ci",
66+
]);
67+
});
68+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {
2+
insertRankedSearchResult,
3+
normalizeSearchQuery,
4+
scoreQueryMatch,
5+
} from "@t3tools/shared/searchRanking";
6+
7+
import type { ComposerCommandItem } from "./ComposerCommandMenu";
8+
9+
function scoreSlashCommandItem(
10+
item: Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>,
11+
query: string,
12+
): number | null {
13+
const primaryValue =
14+
item.type === "slash-command" ? item.command.toLowerCase() : item.command.name.toLowerCase();
15+
const description = item.description.toLowerCase();
16+
17+
const scores = [
18+
scoreQueryMatch({
19+
value: primaryValue,
20+
query,
21+
exactBase: 0,
22+
prefixBase: 2,
23+
boundaryBase: 4,
24+
includesBase: 6,
25+
fuzzyBase: 100,
26+
boundaryMarkers: ["-", "_", "/"],
27+
}),
28+
scoreQueryMatch({
29+
value: description,
30+
query,
31+
exactBase: 20,
32+
prefixBase: 22,
33+
boundaryBase: 24,
34+
includesBase: 26,
35+
}),
36+
].filter((score): score is number => score !== null);
37+
38+
if (scores.length === 0) {
39+
return null;
40+
}
41+
42+
return Math.min(...scores);
43+
}
44+
45+
export function searchSlashCommandItems(
46+
items: ReadonlyArray<
47+
Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>
48+
>,
49+
query: string,
50+
): Array<Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>> {
51+
const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\/+/ });
52+
if (!normalizedQuery) {
53+
return [...items];
54+
}
55+
56+
const ranked: Array<{
57+
item: Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>;
58+
score: number;
59+
tieBreaker: string;
60+
}> = [];
61+
62+
for (const item of items) {
63+
const score = scoreSlashCommandItem(item, normalizedQuery);
64+
if (score === null) {
65+
continue;
66+
}
67+
68+
insertRankedSearchResult(
69+
ranked,
70+
{
71+
item,
72+
score,
73+
tieBreaker:
74+
item.type === "slash-command"
75+
? `0\u0000${item.command}`
76+
: `1\u0000${item.command.name}\u0000${item.provider}`,
77+
},
78+
Number.POSITIVE_INFINITY,
79+
);
80+
}
81+
82+
return ranked.map((entry) => entry.item);
83+
}

0 commit comments

Comments
 (0)