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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] getCommandFzf silently deduplicates commands by name (if (byName.has(command.name)) continue), keeping only the first occurrence — but no test exercises this path. A test with duplicate command names would lock in the dedup contract and catch regressions if upstream mergeCommands behavior ever changes.

it('deduplicates commands with the same name for fuzzy search', () => {
  const commands: CommandInfo[] = [
    { name: 'model', description: 'First', source: 'builtin-command' },
    { name: 'model', description: 'Duplicate', source: 'skill-dir-command' },
    { name: 'memory', description: 'Manage memory', source: 'builtin-command' },
  ];
  const result = getSlashCommandCompletionResult(
    '/model', 6, commands, [], 'en', getTranslator('en'),
  );
  expect(result?.items).toHaveLength(1);
  expect(result?.items[0]?.detail).toBe('First');
});

— qwen3.7-max via Qwen Code /review

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.
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.
--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', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] All existing tests use either all-section-defined or all-section-undefined items. A mixed test — e.g. [{section: 'A'}, {section: undefined}, {section: 'B'}] — would pin the lastSection = section ?? lastSection behavior (undefined items don't break group boundaries) and catch regressions if the ?? is ever changed to a plain =.

it('handles mixed section and undefined items correctly', () => {
  const plans = planSlashSectionRows(
    [
      { section: 'Custom commands' },
      { section: undefined },
      { section: 'System commands' },
    ],
    'command',
  );
  expect(plans[0]).toMatchObject({ showHeader: true, showDivider: false, count: 1 });
  expect(plans[1]).toMatchObject({ showHeader: false, showDivider: false, count: 0 });
  expect(plans[2]).toMatchObject({ showHeader: true, showDivider: true, count: 1 });
});

— qwen3.7-max via Qwen Code /review

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