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} +
+ + )}