feat(web-shell): improve slash command discovery (taller menu, group counts, fuzzy search) (#6267)

* 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.
This commit is contained in:
Shaojin Wen 2026-07-03 22:56:06 +08:00 committed by GitHub
parent 8123c6bff9
commit 68ff698cd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 334 additions and 24 deletions

1
package-lock.json generated
View file

@ -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",

View file

@ -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[] = [
{

View file

@ -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<readonly string[]>; byName: Map<string, CommandInfo> }
>();
function getCommandFzf(commands: CommandInfo[]) {
let entry = commandFzfCache.get(commands);
if (!entry) {
const names: string[] = [];
const byName = new Map<string, CommandInfo>();
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 }
: {}),

View file

@ -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;

View file

@ -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 (
<div key={`${item.id}:${index}`} className={styles.slashEntry}>
{showSection && <div className={styles.slashSection} />}
{plan.showHeader && (
<>
{plan.showDivider && (
<div className={styles.slashSection} />
)}
<div className={styles.slashSectionHeader}>
<span>{item.section}</span>
{plan.count > 0 ? (
<span className={styles.slashSectionCount}>
{plan.count}
</span>
) : null}
</div>
</>
)}
<button
ref={(node) => {
itemRefs.current[index] = node;

View file

@ -0,0 +1,74 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { planSlashSectionRows } from './slashSectionPlan';
describe('planSlashSectionRows', () => {
it('shows a header at each group boundary', () => {
const plans = planSlashSectionRows(
[
{ section: 'Custom commands' },
{ section: 'Custom commands' },
{ section: 'Skill commands' },
{ section: 'System commands' },
],
'command',
);
expect(plans.map((p) => p.showHeader)).toEqual([true, false, true, true]);
});
it('shows a header but no divider on the first row', () => {
const plans = planSlashSectionRows(
[{ section: 'Skill commands' }, { section: 'System commands' }],
'command',
);
expect(plans[0]).toMatchObject({ showHeader: true, showDivider: false });
expect(plans[1]).toMatchObject({ showHeader: true, showDivider: true });
});
it('does not repeat headers for adjacent duplicate sections', () => {
const plans = planSlashSectionRows(
[
{ section: 'System commands' },
{ section: 'System commands' },
{ section: 'System commands' },
],
'command',
);
expect(plans.filter((p) => p.showHeader)).toHaveLength(1);
});
it('reports the number of rows in each section on the header row', () => {
const plans = planSlashSectionRows(
[
{ section: 'Skill commands' },
{ section: 'Skill commands' },
{ section: 'System commands' },
],
'command',
);
expect(plans[0].count).toBe(2);
expect(plans[1].count).toBe(0);
expect(plans[2].count).toBe(1);
});
it('never groups subcommand menus', () => {
const plans = planSlashSectionRows(
[{ section: 'Skill commands' }, { section: 'System commands' }],
'subcommand',
);
expect(plans.every((p) => !p.showHeader && p.count === 0)).toBe(true);
});
it('shows no headers when items carry no section (search results)', () => {
const plans = planSlashSectionRows(
[{ section: undefined }, { section: undefined }],
'command',
);
expect(plans.every((p) => !p.showHeader)).toBe(true);
});
});

View file

@ -0,0 +1,52 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/** Per-row rendering plan for the slash command panel's section grouping. */
export interface SlashSectionRowPlan {
/** Render the category header above this row (group boundary). */
showHeader: boolean;
/** Render the divider above the header (every boundary except the first row). */
showDivider: boolean;
/** Number of rows in this row's section; 0 when no header is shown. */
count: number;
}
/**
* Decide, for each item, whether it starts a new section (and so needs a header,
* a divider, and the group count). Extracted as a pure function so the section
* boundary logic headers at group boundaries, a header but no divider on the
* first row, and no redundant headers for adjacent duplicate sections is unit
* testable without rendering the panel.
*
* Only 'command' menus are grouped; subcommand menus render flat (no headers).
*/
export function planSlashSectionRows(
items: ReadonlyArray<{ section?: string }>,
kind: 'command' | 'subcommand',
): SlashSectionRowPlan[] {
const counts = new Map<string, number>();
if (kind === 'command') {
for (const item of items) {
if (item.section) {
counts.set(item.section, (counts.get(item.section) ?? 0) + 1);
}
}
}
let lastSection: string | undefined;
return items.map((item, index) => {
const section = item.section;
const showHeader =
kind === 'command' && section !== undefined && section !== lastSection;
const showDivider = showHeader && index > 0;
lastSection = section ?? lastSection;
return {
showHeader,
showDivider,
count: showHeader && section ? (counts.get(section) ?? 0) : 0,
};
});
}

View file

@ -29,6 +29,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",