From 959d2993ab3a5d76fed209df2c1b2b691a49eb33 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 19:14:46 -0500 Subject: [PATCH] feat(desktop): rank the slash menu by the skills you actually use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/` popover listed skills alphabetically, so on a 200-skill install the ones invoked daily sat below a wall of skills that shipped with Hermes and were never opened — /research-paper-writing (never used) outranked /research (60 invocations). Sort the Skills section most-used first, A-Z within a tie, and on a bare `/` drop bundled skills with no recorded activity. Typing a query keeps every match: a search that hides a result is broken, so a typed `/re` only reorders. --- .../hooks/use-slash-completions.test.tsx | 59 +++++++++++++++++++ .../composer/hooks/use-slash-completions.ts | 44 +++++++++++--- .../src/lib/desktop-slash-commands.test.ts | 54 +++++++++++++++++ .../desktop/src/lib/desktop-slash-commands.ts | 52 ++++++++++++++++ .../desktop/src/lib/slash-completion-cache.ts | 10 ++++ 5 files changed, 212 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx index eebed60ce4df..a1e87ca53f26 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx @@ -18,6 +18,29 @@ const CATALOG = { ] } +// A catalog shaped like a real install: a couple of skills the user lives in, +// a bundled one they have never opened, and one of their own they haven't +// either. +const RANKED_CATALOG = { + categories: [{ name: 'Session', pairs: [['/new', 'Start a new session']] }], + pairs: [ + ['/new', 'Start a new session'], + ['/docx', 'Edit Word documents'], + ['/research', 'Look it up before answering'], + ['/research-paper-writing', 'Write an academic paper'], + ['/work', 'Kick off a task in a fresh worktree'] + ], + skills: { + '/docx': { usage: 0, origin: 'local' }, + '/research': { usage: 60, origin: 'local' }, + '/research-paper-writing': { usage: 0, origin: 'bundled' }, + '/work': { usage: 172, origin: 'local' } + } +} + +const commandsOf = (items: readonly Unstable_TriggerItem[]) => + items.map(item => (item.metadata as { command?: string })?.command) + function harness(gateway: HermesGateway) { const api: { search?: (query: string) => readonly Unstable_TriggerItem[] } = {} @@ -95,4 +118,40 @@ describe('useSlashCompletions', () => { expect(inline.map(item => (item.metadata as { command?: string })?.command)).toEqual(['/work']) }) + + // An alphabetical `/` menu buries the skills someone runs daily under the + // ones that shipped with Hermes and were never opened. + it('orders skills by use and hides never-used built-ins on a bare slash', async () => { + const request = vi.fn().mockResolvedValue(RANKED_CATALOG) + const api = harness({ request } as unknown as HermesGateway) + + const skills = commandsOf((await completions(api, '')).filter(isSkillItem)) + + expect(skills).toEqual(['/work', '/research', '/docx']) + }) + + // Typing is a search, and a search that hides a match is broken — the + // never-used built-in still shows, just below the one she actually uses. + it('ranks a typed query by use without hiding anything', async () => { + const request = vi.fn().mockImplementation((method: string) => + Promise.resolve( + method === 'commands.catalog' + ? RANKED_CATALOG + : { + items: [ + { text: '/research-paper-writing', display: '/research-paper-writing', meta: 'Write a paper' }, + { text: '/research', display: '/research', meta: 'Look it up' } + ] + } + ) + ) + + const api = harness({ request } as unknown as HermesGateway) + + // Warm the catalog first: the popover always opens on a bare `/` before a + // query is typed, which is where the usage map comes from. + await completions(api, '') + + expect(commandsOf(await completions(api, 'research'))).toEqual(['/research', '/research-paper-writing']) + }) }) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts index 0f023a1d3f1c..32f0038fe2e5 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts @@ -11,9 +11,15 @@ import { type DesktopThemeCommandOption, filterDesktopCommandsCatalog, isDesktopSlashExtensionCommand, - isDesktopSlashSuggestion + isDesktopSlashSuggestion, + rankSkillCommands } from '@/lib/desktop-slash-commands' -import { $slashCompletionsEpoch, cachedSlashCompletion, hasCachedSlashCompletion } from '@/lib/slash-completion-cache' +import { + $slashCompletionsEpoch, + cachedSlashCompletion, + hasCachedSlashCompletion, + peekCachedSlashCompletion +} from '@/lib/slash-completion-cache' import { normalize } from '@/lib/text' import { $sessions } from '@/store/session' @@ -145,7 +151,7 @@ export function useSlashCompletions(options: { // backend didn't categorize. const sections = catalog.categories?.length ? catalog.categories : [{ name: '', pairs: catalog.pairs ?? [] }] - const items = sections.flatMap(section => + const items = sections.flatMap(section => section.pairs.map(([command, meta]) => ({ text: command, display: command, @@ -161,13 +167,19 @@ export function useSlashCompletions(options: { // Re-add the leftovers under one Skills header (which also gives them // the skill pill accent and makes them offerable mid-message). const categorized = new Set(items.map(item => item.text.toLowerCase())) + const skillRows: CompletionEntry[] = [] for (const [command, meta] of catalog.pairs ?? []) { if (!categorized.has(command.toLowerCase()) && isDesktopSlashExtensionCommand(command)) { - items.push({ text: command, display: command, group: 'Skills', meta }) + skillRows.push({ text: command, display: command, group: 'Skills', meta }) } } + // Browsing, not searching: rank the skills the user actually reaches + // for to the top and drop never-used built-ins entirely. Typing a + // query takes the other branch, where nothing is hidden. + items.push(...rankSkillCommands(skillRows, catalog.skills, { pruneUnusedBuiltins: true })) + return { items, query } } @@ -210,9 +222,27 @@ export function useSlashCompletions(options: { // Skills (stable within a group, preserving backend relevance order). const groupOrder = ['Commands', 'Skills', 'Options'] - const items = isArgCompletion - ? decorated - : [...decorated].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group)) + if (isArgCompletion) { + return { items: decorated, query } + } + + // Rank the matched skills by use — `/re` should lead with the /research + // the user lives in, not the /research-paper-writing they've never + // opened. Nothing is pruned here: a typed query is a search, and a + // search that hides a match is broken. Usage rides along on the catalog + // response, which the popover has already fetched by the time anyone + // types; if it somehow hasn't, order falls back to the backend's. + const catalogSkills = peekCachedSlashCompletion('catalog')?.skills + + const ranked = [ + ...decorated.filter(item => item.group !== 'Skills'), + ...rankSkillCommands( + decorated.filter(item => item.group === 'Skills'), + catalogSkills + ) + ] + + const items = [...ranked].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group)) return { items, query } } catch { diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index 1fc70dcb5ad5..1821453d957e 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -10,6 +10,7 @@ import { isDesktopSlashSuggestion, isModelPickerCommand, isPickerCommand, + rankSkillCommands, resolveDesktopCommand } from './desktop-slash-commands' @@ -292,3 +293,56 @@ describe('desktop slash command curation', () => { expect(resolveDesktopCommand('/gif-search')).toBeNull() }) }) + +describe('rankSkillCommands', () => { + const rows = [ + { text: '/research' }, + { text: '/research-paper-writing' }, + { text: '/work' }, + { text: '/ship-it' }, + { text: '/manim-video' }, + { text: '/docx' } + ] + + const skills = { + '/research': { usage: 60, origin: 'local' as const }, + '/research-paper-writing': { usage: 0, origin: 'bundled' as const }, + '/work': { usage: 172, origin: 'local' as const }, + '/manim-video': { usage: 0, origin: 'bundled' as const }, + '/docx': { usage: 0, origin: 'local' as const } + } + + it('puts the most-used skill first and breaks ties alphabetically', () => { + expect(rankSkillCommands(rows, skills).map(row => row.text)).toEqual([ + '/work', + '/research', + '/docx', + '/manim-video', + '/research-paper-writing', + '/ship-it' + ]) + }) + + it('drops never-used built-ins when browsing, keeping everything else', () => { + const browsing = rankSkillCommands(rows, skills, { pruneUnusedBuiltins: true }).map(row => row.text) + + expect(browsing).toEqual(['/work', '/research', '/docx', '/ship-it']) + // A user's own unused skill survives — only shipped-and-ignored goes. + expect(browsing).toContain('/docx') + // Unclassified rows (quick commands, skills newer than the map) survive too. + expect(browsing).toContain('/ship-it') + }) + + it('leaves the backend order untouched when the catalog carries no usage', () => { + expect(rankSkillCommands(rows, undefined, { pruneUnusedBuiltins: true })).toEqual(rows) + }) + + it('ranks an alias by the canonical command it resolves to', () => { + const ranked = rankSkillCommands([{ text: '/sessions' }, { text: '/research' }], { + '/research': { usage: 5, origin: 'local' }, + '/resume': { usage: 900, origin: 'local' } + }) + + expect(ranked.map(row => row.text)).toEqual(['/sessions', '/research']) + }) +}) diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index 62f2e0d5bd73..d84146332b6a 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -7,9 +7,24 @@ export interface CommandsCatalogLike { categories?: CommandsCatalogSection[] pairs?: [string, string][] skill_count?: number + skills?: SkillCatalogMap warning?: string } +/** + * Per-skill ranking data from `commands.catalog`, keyed by slash command. + * Absent on older backends — every helper below degrades to "no ranking, + * hide nothing". + */ +export interface SkillCatalogEntry { + /** Where the skill came from; matches `/api/skills` provenance ('agent' = 'local'). */ + origin?: 'bundled' | 'hub' | 'local' + /** Observed activity (use + view + patch) — the same number Capabilities shows. */ + usage?: number +} + +export type SkillCatalogMap = Record + export interface DesktopSlashCompletion { display: string meta: string @@ -518,6 +533,43 @@ export function desktopSkinSlashCompletions( return commands.filter(item => item.text.slice('/skin '.length).toLowerCase().startsWith(prefix)) } +/** + * Order skill rows by how much the user actually uses them, most-used first, + * A–Z within a tie. A `/` menu sorted alphabetically buries the handful of + * skills someone reaches for daily under a hundred they have never opened. + * + * `pruneUnusedBuiltins` additionally drops bundled skills with no recorded + * activity — the ones that ship with Hermes and were never asked for. It is + * for BROWSING (a bare `/`) only: typing a query is a search, and a search + * must never hide a match. + * + * Older backends send no `skills` map; then nothing is reordered or dropped. + */ +export function rankSkillCommands( + rows: readonly T[], + skills: SkillCatalogMap | undefined, + { pruneUnusedBuiltins = false }: { pruneUnusedBuiltins?: boolean } = {} +): T[] { + if (!skills) { + return [...rows] + } + + const entryOf = (row: T): SkillCatalogEntry | undefined => skills[canonicalDesktopSlashCommand(row.text)] + const usageOf = (row: T): number => entryOf(row)?.usage ?? 0 + + const kept = pruneUnusedBuiltins + ? rows.filter(row => { + const entry = entryOf(row) + + // Unknown to the map (a quick command, a newer skill the catalog + // hasn't classified) stays — only a confirmed never-used built-in goes. + return !entry || entry.origin !== 'bundled' || (entry.usage ?? 0) > 0 + }) + : [...rows] + + return kept.sort((a, b) => usageOf(b) - usageOf(a) || a.text.localeCompare(b.text)) +} + export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): CommandsCatalogLike { const categories = catalog.categories ?.map(section => ({ diff --git a/apps/desktop/src/lib/slash-completion-cache.ts b/apps/desktop/src/lib/slash-completion-cache.ts index a25a0267607b..1be66cdbdadd 100644 --- a/apps/desktop/src/lib/slash-completion-cache.ts +++ b/apps/desktop/src/lib/slash-completion-cache.ts @@ -37,6 +37,16 @@ export function hasCachedSlashCompletion(key: string): boolean { return state?.data !== undefined && Date.now() - state.dataUpdatedAt < SLASH_COMPLETIONS_TTL_MS } +/** + * Read a cached completion response without fetching. For data that improves a + * response but must not cost a round trip to get — the catalog's per-skill + * usage map, which refines the ordering of a typed query but is not worth + * delaying that query for. + */ +export function peekCachedSlashCompletion(key: string): T | undefined { + return hasCachedSlashCompletion(key) ? queryClient.getQueryData([SLASH_COMPLETIONS_KEY, key]) : undefined +} + /** * Bumped on every invalidation. The composer's completion adapter de-dupes by * query, so an unchanged `/` would never re-ask on its own — it watches this