mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-25 00:44:31 +00:00
* feat(web-shell): add keyboard-nav and IME-safe filter hooks Two reusable hooks for list-style dialogs: - useListboxKeyboard: Arrow/Home/End/Enter navigation driven by an active index, with a "keyboard mode" flag to suppress hover. Yields modified-key combos (Cmd/Ctrl/Alt/Shift), Home/End in text inputs, and Enter on focused buttons/links to native handling. - useFilterInput: IME-composition-safe search state so a filtered list does not refire on every intermediate pinyin character (commits on compositionend). * feat(web-shell): DialogShell Escape/backdrop close and focus management Give every dialog shared, accessible dismissal and focus behaviour: - Escape closes (guarded during IME composition so it cancels the composition, not the dialog) - click on the backdrop closes - Tab is trapped within the panel, wrapping at both ends - focus moves into the dialog on open and is restored to the opener on close * feat(web-shell): overhaul list-dialog interaction and accessibility Unify interaction across the model, theme, approval, resume, tools, delete, release and rewind dialogs: - keyboard navigation via useListboxKeyboard, with a roving highlight that opens on the current value and does not fight the mouse - consistent selection visuals: a single roving highlight plus a persistent "current" accent bar + checkmark; options are role=option divs (no stray focus ring) - IME-safe search via useFilterInput; fix Chinese-input jitter in the resume/delete/release search boxes - accessibility: role=listbox/option, aria-activedescendant, and aria-selected bound to the current value rather than the roving highlight - destructive dialogs keep Enter non-destructive where a confirm button is the commit (delete/release); rewind confirms on Enter like a single-select picker Refactors: - extract shared SessionRow used by resume/delete/release - rename resume-picker-* CSS primitives to picker-* (they are shared by all list dialogs, not resume-specific) Adds regression tests for the model duplicate-current fix, release hover selection, rewind Enter, the listbox/aria wiring, and the shared hooks. * fix(web-shell): address dialog interaction review feedback Follow-up fixes from upstream review: - DialogShell closes on completed backdrop clicks instead of mousedown, and its Tab trap now also catches the panel-focused fallback case - ToolsDialog now has full listbox semantics (ids, aria-activedescendant, aria-expanded) - Rewind keeps the roving cursor separate from the confirmed target; Enter only confirms, and the danger button executes the rewind - Model/Approval aria-selected now reflects the actual current value; model highlights stay in bounds when the model list shrinks - Home/End and modified arrow-key combos yield to native text navigation in search inputs; Escape yields to IME composition in DialogShell - Dead picker CSS and duplicate declarations removed; extra regression tests added for reviewer-raised edge cases * fix(web-shell): harden shared dialog keyboard and IME handling * fix(web-shell): tighten dialog shell focus, stacking, and backdrop behavior * fix(web-shell): align list dialog selection semantics and add coverage
273 lines
6.9 KiB
TypeScript
273 lines
6.9 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import type { CommandInfo } from '../../adapters/types';
|
|
import { useI18n } from '../../i18n';
|
|
import { useFilterInput } from '../../hooks/useFilterInput';
|
|
import styles from './HelpDialog.module.css';
|
|
|
|
type HelpTab = 'general' | 'commands' | 'custom-commands';
|
|
|
|
interface HelpDialogProps {
|
|
commands: readonly CommandInfo[];
|
|
}
|
|
|
|
const TABS: Array<{ id: HelpTab; labelKey: string }> = [
|
|
{ id: 'general', labelKey: 'help.tab.general' },
|
|
{ id: 'commands', labelKey: 'help.tab.commands' },
|
|
{ id: 'custom-commands', labelKey: 'help.tab.custom' },
|
|
];
|
|
|
|
const BUILT_IN_COMMANDS = new Set([
|
|
'about',
|
|
'agents',
|
|
'approval-mode',
|
|
'arena',
|
|
'auth',
|
|
'branch',
|
|
'btw',
|
|
'bug',
|
|
'clear',
|
|
'compress',
|
|
'context',
|
|
'copy',
|
|
'release',
|
|
'diff',
|
|
'directory',
|
|
'docs',
|
|
'doctor',
|
|
'dream',
|
|
'editor',
|
|
'export',
|
|
'extensions',
|
|
'forget',
|
|
'goal',
|
|
'help',
|
|
'hooks',
|
|
'ide',
|
|
'init',
|
|
'insight',
|
|
'language',
|
|
'lsp',
|
|
'mcp',
|
|
'memory',
|
|
'model',
|
|
'new',
|
|
'permissions',
|
|
'plan',
|
|
'quit',
|
|
'recap',
|
|
'remember',
|
|
'rename',
|
|
'reset',
|
|
'restore',
|
|
'resume',
|
|
'rewind',
|
|
'settings',
|
|
'setup-github',
|
|
'skills',
|
|
'stats',
|
|
'status',
|
|
'statusline',
|
|
'summary',
|
|
'tasks',
|
|
'terminal-setup',
|
|
'theme',
|
|
'tools',
|
|
'trust',
|
|
'vim',
|
|
]);
|
|
|
|
const GENERAL_SHORTCUTS: Array<[string, string]> = [
|
|
['@', 'help.shortcut.addContext'],
|
|
['!', 'help.shortcut.shell'],
|
|
['/', 'help.shortcut.commandMenu'],
|
|
['Tab', 'help.shortcut.completion'],
|
|
['Esc', 'help.shortcut.cancel'],
|
|
['Ctrl+J', 'help.shortcut.newline'],
|
|
['Ctrl+L', 'help.shortcut.clear'],
|
|
['Ctrl+Y', 'help.shortcut.retry'],
|
|
['Shift+Tab', 'help.shortcut.approvals'],
|
|
['Alt+Left/Right', 'help.shortcut.altWords'],
|
|
['Up/Down', 'help.shortcut.history'],
|
|
];
|
|
|
|
function commandSignature(command: CommandInfo): string {
|
|
return [`/${command.name}`, command.argumentHint].filter(Boolean).join(' ');
|
|
}
|
|
|
|
function isCustomCommand(command: CommandInfo): boolean {
|
|
return !BUILT_IN_COMMANDS.has(command.name);
|
|
}
|
|
|
|
function commandMeta(
|
|
command: CommandInfo,
|
|
t: ReturnType<typeof useI18n>['t'],
|
|
): string {
|
|
return isCustomCommand(command)
|
|
? t('help.commandMeta.custom')
|
|
: t('help.commandMeta.builtIn');
|
|
}
|
|
|
|
function filterCommands(
|
|
commands: readonly CommandInfo[],
|
|
tab: HelpTab,
|
|
query: string,
|
|
): CommandInfo[] {
|
|
const normalized = query.trim().toLowerCase();
|
|
return commands
|
|
.filter((command) => command.name && command.description !== undefined)
|
|
.filter((command) => {
|
|
if (tab === 'commands') return !isCustomCommand(command);
|
|
if (tab === 'custom-commands') return isCustomCommand(command);
|
|
return true;
|
|
})
|
|
.filter((command) => {
|
|
if (!normalized) return true;
|
|
return (
|
|
command.name.toLowerCase().includes(normalized) ||
|
|
(command.description ?? '').toLowerCase().includes(normalized) ||
|
|
(command.argumentHint ?? '').toLowerCase().includes(normalized)
|
|
);
|
|
})
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
function GeneralHelp() {
|
|
const { t } = useI18n();
|
|
return (
|
|
<div className={styles.general}>
|
|
<div className={styles.shortcuts}>
|
|
{GENERAL_SHORTCUTS.map(([key, description]) => (
|
|
<div className={styles.shortcut} key={key}>
|
|
<span className={styles.shortcutDesc}>{t(description)}</span>
|
|
<span className={styles.shortcutKey}>{key}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CommandsHelp({
|
|
commands,
|
|
tab,
|
|
query,
|
|
}: {
|
|
commands: readonly CommandInfo[];
|
|
tab: HelpTab;
|
|
query: string;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const [expandedCommand, setExpandedCommand] = useState<string | null>(null);
|
|
const visibleCommands = useMemo(
|
|
() => filterCommands(commands, tab, query),
|
|
[commands, query, tab],
|
|
);
|
|
|
|
if (visibleCommands.length === 0) {
|
|
return (
|
|
<div className={styles.empty}>
|
|
{tab === 'custom-commands' ? t('help.emptyCustom') : t('help.empty')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.commandList}>
|
|
{visibleCommands.map((command) => {
|
|
const expanded = expandedCommand === command.name;
|
|
return (
|
|
<article
|
|
className={`${styles.commandCard} ${
|
|
expanded ? styles.commandCardExpanded : ''
|
|
}`}
|
|
key={command.name}
|
|
>
|
|
<button
|
|
type="button"
|
|
className={styles.commandRow}
|
|
onClick={() => setExpandedCommand(expanded ? null : command.name)}
|
|
aria-expanded={expanded}
|
|
>
|
|
<span className={styles.commandName}>
|
|
{commandSignature(command)}
|
|
</span>
|
|
<span className={styles.commandTag}>
|
|
{commandMeta(command, t)}
|
|
</span>
|
|
<svg
|
|
className={`${styles.chevron} ${
|
|
expanded ? styles.chevronExpanded : ''
|
|
}`}
|
|
viewBox="0 0 16 16"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M6 4.5 9.5 8 6 11.5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{expanded && (
|
|
<div className={styles.commandDetail}>
|
|
{command.description && (
|
|
<div className={styles.commandDescription}>
|
|
{command.description}
|
|
</div>
|
|
)}
|
|
{!!command.subcommands?.length && (
|
|
<div className={styles.commandSubcommands}>
|
|
{t('help.subcommands')}: {command.subcommands.join(', ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function HelpDialog({ commands }: HelpDialogProps) {
|
|
const { t } = useI18n();
|
|
const [activeTab, setActiveTab] = useState<HelpTab>('general');
|
|
const { filterValue: query, inputProps } = useFilterInput();
|
|
const showSearch = activeTab !== 'general';
|
|
|
|
return (
|
|
<div className={styles.dialog}>
|
|
<div className={styles.toolbar}>
|
|
<div className={styles.tabs}>
|
|
{TABS.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
className={`${styles.tab} ${
|
|
tab.id === activeTab ? styles.tabActive : ''
|
|
}`}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
>
|
|
{t(tab.labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{showSearch && (
|
|
<input
|
|
className={styles.search}
|
|
{...inputProps}
|
|
placeholder={t('help.search')}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{activeTab === 'general' ? (
|
|
<GeneralHelp />
|
|
) : (
|
|
<CommandsHelp commands={commands} tab={activeTab} query={query} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|