From 68ff698cd21287f0c036abdca26a394cc82e35e5 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 3 Jul 2026 22:56:06 +0800 Subject: [PATCH] feat(web-shell): improve slash command discovery (taller menu, group counts, fuzzy search) (#6267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web-shell): show more slash commands with category headers The slash-command menu capped its visible height at exactly four rows, so with 40+ merged commands users had to scroll a thin list to find anything, and the built-in custom/skill/system grouping was only a faint 1px divider with no label. Raise the cap to min(12 rows, 40vh) and render the category name as a visible header at each group boundary (custom / skill / system), keeping the divider between groups. Sub-command menus are ungrouped and unchanged. * feat(web-shell): fuzzy-match slash commands and show per-group counts Typing in the slash menu now fuzzy-ranks commands with the same fzf engine the TUI uses, so abbreviated input like "mdl" finds "model" and "arf" finds "agent-reproduce-feature" — substring matching alone could not. An empty query still browses the category-ordered list; a non-empty query switches to a flat relevance-ranked list (headers are dropped since results interleave categories). Each category header also shows how many commands the group holds (e.g. "Skill commands 28"), so the volume hidden below the fold is visible at a glance. The fzf index is built once per command set (keyed on the array identity) and falls back to substring filtering if construction fails. * refactor(web-shell): address slash menu review feedback - Extract the section header/divider boundary logic into a pure `planSlashSectionRows` helper and unit-test it (headers at group boundaries, first row header without a divider, no repeated headers for adjacent duplicate sections, per-group counts). This also moves the section-count computation past the `!anchorRect` early return so it no longer runs on first render. - Simplify `--slash-panel-max-height` to a round `min(460px, 45vh)` instead of a `12 * rowHeight` formula that ignored header/divider overhead and so showed only ~9-10 rows; the panel now shows ~12-13 rows. - Log a warning when fzf fuzzy search throws before falling back to substring matching, so a silent failure is diagnosable. - Add a completion test for the zero-match case returning null. --- package-lock.json | 1 + .../completions/slashCompletion.test.ts | 91 +++++++++++++++++++ .../client/completions/slashCompletion.ts | 80 ++++++++++++++-- .../client/components/ChatEditor.module.css | 30 ++++-- .../client/components/ChatEditor.tsx | 29 ++++-- .../client/utils/slashSectionPlan.test.ts | 74 +++++++++++++++ .../client/utils/slashSectionPlan.ts | 52 +++++++++++ packages/web-shell/package.json | 1 + 8 files changed, 334 insertions(+), 24 deletions(-) create mode 100644 packages/web-shell/client/utils/slashSectionPlan.test.ts create mode 100644 packages/web-shell/client/utils/slashSectionPlan.ts diff --git a/package-lock.json b/package-lock.json index d961e8500b..eff18f4475 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27343,6 +27343,7 @@ "@codemirror/view": "^6.35.0", "@tanstack/react-virtual": "^3.13.26", "codemirror": "^6.0.0", + "fzf": "^0.5.2", "katex": "^0.16.47", "mermaid": "^11.15.0", "react-markdown": "^9.0.0", diff --git a/packages/web-shell/client/completions/slashCompletion.test.ts b/packages/web-shell/client/completions/slashCompletion.test.ts index d26c4d9e76..afd733962b 100644 --- a/packages/web-shell/client/completions/slashCompletion.test.ts +++ b/packages/web-shell/client/completions/slashCompletion.test.ts @@ -178,6 +178,97 @@ describe('getSlashCommandCompletionResult', () => { ]); }); + it('fuzzy-ranks top-level commands for an abbreviated query', () => { + const commands: CommandInfo[] = [ + { name: 'model', description: 'Switch model', source: 'builtin-command' }, + { + name: 'memory', + description: 'Manage memory', + source: 'builtin-command', + }, + { + name: 'agent-reproduce-feature', + description: 'Reproduce a feature', + source: 'skill-dir-command', + }, + ]; + + const mdl = getSlashCommandCompletionResult( + '/mdl', + 4, + commands, + [], + 'en', + getTranslator('en'), + ); + expect(mdl?.items[0]?.label).toBe('/model'); + + const arf = getSlashCommandCompletionResult( + '/arf', + 4, + commands, + [], + 'en', + getTranslator('en'), + ); + expect(arf?.items.map((item) => item.label)).toContain( + '/agent-reproduce-feature', + ); + }); + + it('drops section headers while searching but keeps them while browsing', () => { + const commands: CommandInfo[] = [ + { + name: 'clear', + description: 'Clear the screen', + source: 'builtin-command', + }, + { + name: 'demo:ping', + description: 'Project command', + source: 'skill-dir-command', + }, + ]; + + const browsing = getSlashCommandCompletionResult( + '/', + 1, + commands, + [], + 'en', + getTranslator('en'), + ); + expect(browsing?.items.every((item) => Boolean(item.section))).toBe(true); + + const searching = getSlashCommandCompletionResult( + '/c', + 2, + commands, + [], + 'en', + getTranslator('en'), + ); + expect(searching?.items.map((item) => item.label)).toEqual(['/clear']); + expect(searching?.items.every((item) => item.section === undefined)).toBe( + true, + ); + }); + + it('returns null when fuzzy search matches nothing', () => { + const commands: CommandInfo[] = [ + { name: 'clear', description: 'Clear', source: 'builtin-command' }, + ]; + const result = getSlashCommandCompletionResult( + '/zzzzzzz', + 8, + commands, + [], + 'en', + getTranslator('en'), + ); + expect(result).toBeNull(); + }); + it('honors panel category order and filtered commands', () => { const commands: CommandInfo[] = [ { diff --git a/packages/web-shell/client/completions/slashCompletion.ts b/packages/web-shell/client/completions/slashCompletion.ts index fbf3977016..c0419a36e3 100644 --- a/packages/web-shell/client/completions/slashCompletion.ts +++ b/packages/web-shell/client/completions/slashCompletion.ts @@ -4,6 +4,7 @@ import type { CompletionResult, CompletionSection, } from '@codemirror/autocomplete'; +import { Fzf } from 'fzf'; import type { CommandInfo } from '../adapters/types'; import type { WebShellLanguage } from '../i18n'; import { @@ -332,6 +333,62 @@ function getLineBounds(text: string, cursor: number) { }; } +// The visible slash menu (React SlashCommandPanel) drives its top-level command +// list through getSlashCommandCompletionResult. For a non-empty query we rank +// with fzf — the same fuzzy engine the TUI uses — so abbreviated input like +// "mdl" surfaces "model" and "arf" surfaces "agent-reproduce-feature". Building +// the index is keyed on the commands array identity, so it happens once per +// command set rather than on every keystroke. (slashCompletionSource, the +// CodeMirror source below, is not wired into the live editor and still does +// substring filtering.) +const commandFzfCache = new WeakMap< + readonly CommandInfo[], + { fzf: Fzf; byName: Map } +>(); + +function getCommandFzf(commands: CommandInfo[]) { + let entry = commandFzfCache.get(commands); + if (!entry) { + const names: string[] = []; + const byName = new Map(); + for (const command of commands) { + if (byName.has(command.name)) continue; + names.push(command.name); + byName.set(command.name, command); + } + entry = { + fzf: new Fzf(names, { fuzzy: 'v2', casing: 'case-insensitive' }), + byName, + }; + commandFzfCache.set(commands, entry); + } + return entry; +} + +function fuzzyRankCommands( + commands: CommandInfo[], + query: string, +): CommandInfo[] { + try { + const { fzf, byName } = getCommandFzf(commands); + const matches: CommandInfo[] = []; + for (const result of fzf.find(query)) { + const command = byName.get(result.item); + if (command) matches.push(command); + } + return matches; + } catch (error) { + console.warn( + '[web-shell] slash fuzzy search failed, falling back to substring match:', + error, + ); + const lp = query.toLowerCase(); + return commands.filter((command) => + command.name.toLowerCase().includes(lp), + ); + } +} + export function getSlashCommandCompletionResult( text: string, cursor: number, @@ -423,13 +480,15 @@ export function getSlashCommandCompletionResult( if (!match) return null; const prefix = match[1]; - const lp = prefix.toLowerCase(); - const filteredCommands = commands - .filter((command) => { - if (!prefix) return true; - return command.name.toLowerCase().includes(lp); - }) - .sort((a, b) => compareSlashCommands(a, b, lp, categoryOrder)); + // Empty query: browse the full list grouped and ordered by category. + // Non-empty query: fuzzy-rank by relevance (best match first), matching the + // TUI, so partial or abbreviated input surfaces the command the user means. + const isBrowsing = prefix.length === 0; + const filteredCommands = isBrowsing + ? [...commands].sort((a, b) => + compareSlashCommands(a, b, '', categoryOrder), + ) + : fuzzyRankCommands(commands, prefix); const items = filteredCommands.map((command): SlashCommandCompletionItem => { const apply = `/${command.name} `; @@ -441,7 +500,12 @@ export function getSlashCommandCompletionResult( detail: command.description || undefined, apply, category, - section: translate(COMMAND_SECTION_KEYS[category]), + // Section headers only make sense while browsing the category-ordered + // list; a relevance-ranked result set interleaves categories, so headers + // would appear before nearly every row. Drop them during search. + ...(isBrowsing + ? { section: translate(COMMAND_SECTION_KEYS[category]) } + : {}), ...(showCommandInfo && command.description ? { type: 'command-info' as const } : {}), diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index 0891125d39..7e87343ab6 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -181,12 +181,9 @@ .slashPanel { position: fixed; z-index: var(--web-shell-popover-z-index, 1000); - --slash-panel-max-height: min( - calc( - (22px + 10px) + (22px + 10px) + (22px + 10px) + (22px + 10px) + 6px + 12px - ), - 50vh - ); + /* Round cap sized for ~12 command rows plus their section headers/dividers, + bounded by 45vh so it stays proportional on short viewports. */ + --slash-panel-max-height: min(460px, 45vh); --slash-anchor-width: 620px; --slash-command-col: 20ch; --slash-desc-col: 32ch; @@ -262,6 +259,27 @@ background: var(--chat-editor-border-color); } +.slashSectionHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 4px 8px 2px; + color: var(--muted-foreground); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.02em; + user-select: none; +} + +.slashSectionCount { + flex: 0 0 auto; + color: var(--muted-foreground); + font-weight: 400; + font-variant-numeric: tabular-nums; + opacity: 0.7; +} + .slashItem { position: relative; display: grid; diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 32f579e5dd..89c7bfd0cd 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -31,6 +31,7 @@ import { getComposerTagValue, } from '../hooks/useComposerCore'; import { ModeIcon } from './ModeIcon'; +import { planSlashSectionRows } from '../utils/slashSectionPlan'; import { getModelDisplayName } from '../utils/modelDisplay'; import { VoiceButton } from '../voice/VoiceButton'; import styles from './ChatEditor.module.css'; @@ -682,10 +683,10 @@ function SlashCommandPanel({ '--slash-column-gap': hasDetailColumn ? '2ch' : '0px', } as CSSProperties; - let lastSection: string | undefined; - if (!anchorRect) return null; + const rowPlans = planSlashSectionRows(menu.items, menu.kind); + const positionedPanelStyle = { ...panelStyle, ...themeVars, @@ -718,16 +719,24 @@ function SlashCommandPanel({ onScroll={() => setHoverDetail(null)} > {menu.items.map((item, index) => { - const section = item.section; - const showSection = - menu.kind === 'command' && - index > 0 && - section !== undefined && - section !== lastSection; - lastSection = section ?? lastSection; + const plan = rowPlans[index]; return (
- {showSection &&
} + {plan.showHeader && ( + <> + {plan.showDivider && ( +
+ )} +
+ {item.section} + {plan.count > 0 ? ( + + {plan.count} + + ) : null} +
+ + )}