Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

91 changes: 91 additions & 0 deletions packages/web-shell/client/completions/slashCompletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,97 @@ describe('getSlashCommandCompletionResult', () => {
]);
});

it('fuzzy-ranks top-level commands for an abbreviated query', () => {
Comment thread
wenshao marked this conversation as resolved.
Resolved
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[] = [
{
Expand Down
80 changes: 72 additions & 8 deletions packages/web-shell/client/completions/slashCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Comment thread
wenshao marked this conversation as resolved.
Resolved
return commands.filter((command) =>
command.name.toLowerCase().includes(lp),
);
}
}

export function getSlashCommandCompletionResult(
text: string,
cursor: number,
Expand Down Expand Up @@ -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} `;
Expand All @@ -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 }
: {}),
Expand Down
30 changes: 24 additions & 6 deletions packages/web-shell/client/components/ChatEditor.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
wenshao marked this conversation as resolved.
Resolved
--slash-command-col: 20ch;
--slash-desc-col: 32ch;
Expand Down Expand Up @@ -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;
Expand Down
29 changes: 19 additions & 10 deletions packages/web-shell/client/components/ChatEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
74 changes: 74 additions & 0 deletions packages/web-shell/client/utils/slashSectionPlan.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading