qwen-code/packages/web-shell/client/components/ChatEditor.tsx
Shaojin Wen 68ff698cd2
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.
2026-07-03 14:56:06 +00:00

1661 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
forwardRef,
memo,
useImperativeHandle,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import type { CSSProperties, ReactNode, RefObject } from 'react';
import { DAEMON_APPROVAL_MODES } from '@qwen-code/webui/daemon-react-sdk';
import type { CommandInfo } from '../adapters/types';
import type { UseDaemonFollowupSuggestionReturn } from '@qwen-code/webui/daemon-react-sdk';
import type { CommandDisplayCategoryOrder } from '../utils/commandDisplay';
import type { SkillInfo } from '../completions/slashCompletion';
import { useI18n } from '../i18n';
import {
useWebShellCustomization,
type WebShellComposerInput,
type WebShellComposerTag,
} from '../customization';
import {
useComposerCore,
type EditorHandle,
type SlashMenuState,
getComposerTagDisplay,
getComposerTagLabel,
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';
export type ComposerToolbarAction =
| 'approvalMode'
| 'model'
| 'commands'
| 'files'
| 'widthMode'
| 'voice';
const ACTIVE_TOOLBAR_ACTIONS = [
'approvalMode',
'model',
'widthMode',
'voice',
] as const satisfies readonly ComposerToolbarAction[];
const ACTIVE_TOOLBAR_ACTION_SET = new Set<ComposerToolbarAction>(
ACTIVE_TOOLBAR_ACTIONS,
);
interface ChatEditorProps {
onSubmit: (
text: string,
images?: import('../adapters/promptTypes').PromptImage[],
) => boolean | void;
onCycleMode?: () => void;
onToggleShortcuts?: () => void;
onCancel?: () => void;
isRunning?: boolean;
isPreparing?: boolean;
/** First Esc armed a cancel — the send button shows an "Esc to stop" hint. */
cancelArmed?: boolean;
disabled?: boolean;
placeholderText?: string;
commands: CommandInfo[];
skills?: SkillInfo[];
slashCommandCategoryOrder?: CommandDisplayCategoryOrder;
queuedMessages?: string[];
onPopQueuedMessages?: () => boolean;
onClearQueuedMessages?: () => boolean;
currentMode?: string;
currentModel?: string;
chatWidthMode?: '1000' | 'wide';
showChatWidthToggle?: boolean;
chatWidthToggleMin?: number;
visibleToolbarActions?: readonly ComposerToolbarAction[];
availableModels?: Array<{ id: string; label?: string }>;
onSelectMode?: (mode: string) => void;
onSelectModel?: (model: string) => void;
onChatWidthModeChange?: (mode: '1000' | 'wide') => void;
onFocusFooter?: () => boolean;
dialogOpen?: boolean;
followupState?: UseDaemonFollowupSuggestionReturn['followupState'];
onAcceptFollowup?: UseDaemonFollowupSuggestionReturn['onAcceptFollowup'];
onDismissFollowup?: UseDaemonFollowupSuggestionReturn['onDismissFollowup'];
sessionName?: string;
composerInput?: WebShellComposerInput;
composerInputVersion?: number;
}
const CHAT_EDITOR_THEME = {
'&': {
fontSize: '14px',
background: 'transparent',
border: 'none',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-scroller': {
maxHeight: 'var(--chat-editor-input-max-height, 300px)',
overflowX: 'hidden',
overflowY: 'auto',
},
'.cm-content': {
padding: '0',
fontFamily: 'var(--font-sans, system-ui, sans-serif)',
color: 'var(--chat-editor-text-primary, #e0e0e0)',
caretColor: 'var(--chat-editor-accent-color, #4a9eff)',
fontSize: '14px',
lineHeight: '1.6',
},
'.cm-line': {
padding: '0',
},
'.cm-placeholder': {
color: 'var(--chat-editor-text-dimmed, #666)',
},
'.cm-followup-ghost': {
color: 'var(--chat-editor-text-dimmed, #666)',
opacity: '0.72',
pointerEvents: 'none',
userSelect: 'none',
},
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground': {
backgroundColor: 'var(--chat-editor-selection-bg) !important',
},
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
backgroundColor: 'var(--chat-editor-selection-bg) !important',
},
'&.cm-focused .cm-content ::selection': {
backgroundColor: 'var(--chat-editor-selection-bg)',
color: 'var(--chat-editor-selection-color)',
},
'.cm-content ::selection': {
backgroundColor: 'var(--chat-editor-selection-bg)',
color: 'var(--chat-editor-selection-color)',
},
'.cm-cursor': {
borderLeftColor: 'var(--chat-editor-accent-color, #4a9eff)',
borderLeftWidth: '2px',
},
};
function isTouchLikeDevice(): boolean {
if (typeof window === 'undefined') return false;
return (
(typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0) ||
(typeof window.matchMedia === 'function' &&
window.matchMedia('(hover: none), (pointer: coarse)').matches)
);
}
const SLASH_PANEL_THEME_VARS = [
'--chat-editor-accent-color',
'--accent',
'--background',
'--chat-editor-bg-tertiary',
'--chat-editor-border-color',
'--foreground',
'--font-mono',
'--font-sans',
'--muted-foreground',
'--chat-editor-text-primary',
'--chat-editor-text-secondary',
] as const;
function SendIcon() {
return (
<svg
className={styles.sendIcon}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M10 15.5v-11M5.5 9 10 4.5 14.5 9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function StopIcon() {
return <span className={styles.stopIcon} aria-hidden="true" />;
}
function LoadingIcon() {
return <span className={styles.loadingIcon} aria-hidden="true" />;
}
function QuickActionsIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
{[7, 12, 17].flatMap((y) =>
[7, 12, 17].map((x) => (
<circle
key={`${x}-${y}`}
cx={x}
cy={y}
r="1.35"
fill="currentColor"
/>
)),
)}
</svg>
);
}
function attachComposerGlow(glowRootEl: HTMLElement, inputEl: HTMLElement) {
let glowRaf: number | undefined;
let pulseRaf: number | undefined;
let pulseDecayTimer: number | undefined;
let typingTimer: number | undefined;
let glowCurrent = 0;
let pulseCurrent = 0;
const apply = (on: number, pulse: number) => {
glowRootEl.style.setProperty('--dac-glow-on', on.toFixed(4));
glowRootEl.style.setProperty('--dac-glow-pulse', pulse.toFixed(4));
};
const animateGlow = (target: number) => {
if (glowRaf !== undefined) window.cancelAnimationFrame(glowRaf);
const start = glowCurrent;
const diff = target - start;
if (Math.abs(diff) < 0.001) {
glowCurrent = target;
apply(target, pulseCurrent);
return;
}
const t0 = performance.now();
const tick = (now: number) => {
const t = Math.min((now - t0) / 220, 1);
glowCurrent = start + diff * (1 - (1 - t) ** 2);
apply(glowCurrent, pulseCurrent);
glowRaf = t < 1 ? window.requestAnimationFrame(tick) : undefined;
};
glowRaf = window.requestAnimationFrame(tick);
};
const animatePulseDecay = () => {
if (pulseRaf !== undefined) window.cancelAnimationFrame(pulseRaf);
const start = pulseCurrent;
const t0 = performance.now();
const tick = (now: number) => {
const t = Math.min((now - t0) / 300, 1);
pulseCurrent = start * (1 - t);
apply(glowCurrent, pulseCurrent);
pulseRaf = t < 1 ? window.requestAnimationFrame(tick) : undefined;
};
pulseRaf = window.requestAnimationFrame(tick);
};
const setTyping = (on: boolean) => {
if (on) glowRootEl.setAttribute('data-dac-typing', '');
else glowRootEl.removeAttribute('data-dac-typing');
};
const onFocus = () => animateGlow(1);
const onBlur = () => {
animateGlow(0);
setTyping(false);
if (typingTimer !== undefined) window.clearTimeout(typingTimer);
};
const onKeydown = () => {
if (pulseRaf !== undefined) window.cancelAnimationFrame(pulseRaf);
if (pulseDecayTimer !== undefined) window.clearTimeout(pulseDecayTimer);
pulseCurrent = 1;
apply(glowCurrent, 1);
pulseDecayTimer = window.setTimeout(animatePulseDecay, 100);
setTyping(true);
if (typingTimer !== undefined) window.clearTimeout(typingTimer);
typingTimer = window.setTimeout(() => setTyping(false), 650);
};
inputEl.addEventListener('focus', onFocus);
inputEl.addEventListener('blur', onBlur);
inputEl.addEventListener('keydown', onKeydown);
if (document.activeElement === inputEl) animateGlow(1);
return () => {
if (glowRaf !== undefined) window.cancelAnimationFrame(glowRaf);
if (pulseRaf !== undefined) window.cancelAnimationFrame(pulseRaf);
if (pulseDecayTimer !== undefined) window.clearTimeout(pulseDecayTimer);
if (typingTimer !== undefined) window.clearTimeout(typingTimer);
inputEl.removeEventListener('focus', onFocus);
inputEl.removeEventListener('blur', onBlur);
inputEl.removeEventListener('keydown', onKeydown);
apply(0, 0);
setTyping(false);
};
}
function WidthModeIcon({ mode }: { mode: '1000' | 'wide' }) {
if (mode === 'wide') {
return (
<svg viewBox="0 0 1024 1024" aria-hidden="true">
<path
d="M550.012 486.537a8.16 8.16 0 0 1 8.17-8.17h305.36l-111.88-111.89c-3.19-3.19-3.19-8.4 0-11.59l25.08-25.08c3.19-3.19 8.4-3.19 11.59 0l168.61 168.6c3.19 3.19 3.19 8.4 0 11.59l-164.47 168.67c-3.19 3.19-8.4 3.19-11.59 0l-25.61-25.61c-3.19-3.19-3.19-8.4 0-11.59l106.58-110.78-303.62 0.11c-4.52 0-8.23-3.71-8.23-8.23v-36.03z"
fill="currentColor"
transform="translate(-483.41 0)"
/>
<path
d="M473.532 524.327a8.16 8.16 0 0 1-8.17 8.17h-305.36l111.88 111.88c3.19 3.19 3.19 8.4 0 11.59l-25.09 25.09c-3.19 3.19-8.4 3.19-11.59 0l-168.6-168.61c-3.19-3.19-3.19-8.4 0-11.59l164.47-168.67c3.19-3.19 8.4-3.19 11.59 0l25.61 25.61c3.19 3.19 3.19 8.4 0 11.59l-106.59 110.78 303.62-0.11c4.52 0 8.23 3.71 8.23 8.23v36.04z"
fill="currentColor"
transform="translate(483.41 0)"
/>
</svg>
);
}
return (
<svg viewBox="0 0 1024 1024" aria-hidden="true">
<path
d="M473.532 524.327a8.16 8.16 0 0 1-8.17 8.17h-305.36l111.88 111.88c3.19 3.19 3.19 8.4 0 11.59l-25.09 25.09c-3.19 3.19-8.4 3.19-11.59 0l-168.6-168.61c-3.19-3.19-3.19-8.4 0-11.59l164.47-168.67c3.19-3.19 8.4-3.19 11.59 0l25.61 25.61c3.19 3.19 3.19 8.4 0 11.59l-106.59 110.78 303.62-0.11c4.52 0 8.23 3.71 8.23 8.23v36.04zM550.012 486.537a8.16 8.16 0 0 1 8.17-8.17h305.36l-111.88-111.89c-3.19-3.19-3.19-8.4 0-11.59l25.08-25.08c3.19-3.19 8.4-3.19 11.59 0l168.61 168.6c3.19 3.19 3.19 8.4 0 11.59l-164.47 168.67c-3.19 3.19-8.4 3.19-11.59 0l-25.61-25.61c-3.19-3.19-3.19-8.4 0-11.59l106.58-110.78-303.62 0.11c-4.52 0-8.23-3.71-8.23-8.23v-36.03z"
fill="currentColor"
/>
</svg>
);
}
function ChevronDownIcon() {
return <span className={styles.chevronDown} aria-hidden="true" />;
}
function ModelIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 3.5 19.4 7.8v8.4L12 20.5l-7.4-4.3V7.8L12 3.5Z"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path
d="m8.2 9.7 3.8 2.2 3.8-2.2M12 11.9v4.4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
interface DropdownItem {
id: string;
label: string;
description?: string;
icon?: ReactNode;
}
interface QuickActionItem {
id: string;
label: string;
action:
| {
type: 'run';
command: string;
}
| {
type: 'insert';
text: string;
}
| {
type: 'shell';
}
| {
type: 'key';
item: QuickKeyItem;
};
}
function getQuickActionCommandName(action: QuickActionItem): string | null {
const text =
action.action.type === 'run'
? action.action.command
: action.action.type === 'insert'
? action.action.text
: '';
const match = text.trimStart().match(/^\/([^\s]+)/);
return match?.[1] ?? null;
}
interface QuickKeyItem {
id: string;
label: string;
descriptionKey: string;
event: KeyboardEventInit & { key: string };
}
const QUICK_KEY_ITEMS: QuickKeyItem[] = [
{
id: 'tab',
label: 'Tab',
descriptionKey: 'quickKeys.tab',
event: { key: 'Tab', code: 'Tab' },
},
{
id: 'escape',
label: 'Esc',
descriptionKey: 'quickKeys.escape',
event: { key: 'Escape', code: 'Escape' },
},
{
id: 'arrow-up',
label: '↑',
descriptionKey: 'quickKeys.history',
event: { key: 'ArrowUp', code: 'ArrowUp' },
},
{
id: 'arrow-down',
label: '↓',
descriptionKey: 'quickKeys.history',
event: { key: 'ArrowDown', code: 'ArrowDown' },
},
{
id: 'arrow-left',
label: '←',
descriptionKey: 'quickKeys.cursor',
event: { key: 'ArrowLeft', code: 'ArrowLeft' },
},
{
id: 'arrow-right',
label: '→',
descriptionKey: 'quickKeys.cursor',
event: { key: 'ArrowRight', code: 'ArrowRight' },
},
];
function CheckIcon() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path
d="m3 8.3 3.1 3.1L13 4.6"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function getModeLabel(modeId: string, t: (key: string) => string): string {
const labels: Record<string, string> = {
plan: t('mode.label.plan'),
default: t('mode.label.default'),
'auto-edit': t('mode.label.auto-edit'),
auto: t('mode.label.auto'),
yolo: t('mode.label.yolo'),
};
return labels[modeId] ?? modeId;
}
function getModeListLabel(modeId: string, t: (key: string) => string): string {
const labels: Record<string, string> = {
plan: t('mode.listLabel.plan'),
default: t('mode.listLabel.default'),
'auto-edit': t('mode.listLabel.auto-edit'),
auto: t('mode.listLabel.auto'),
yolo: t('mode.listLabel.yolo'),
};
return labels[modeId] ?? getModeLabel(modeId, t);
}
function ToolbarDropdown({
open,
items,
activeId,
onClose,
onSelect,
anchorRef,
showCheck = false,
maxHeight,
}: {
open: boolean;
items: DropdownItem[];
activeId: string;
onClose: () => void;
onSelect: (id: string) => void;
anchorRef: React.RefObject<HTMLButtonElement | null>;
showCheck?: boolean;
maxHeight?: number;
}) {
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onPointerOutside = (event: Event) => {
if (event instanceof MouseEvent && event.button !== 0) return;
if (event.defaultPrevented) return;
const dropdown = dropdownRef.current;
const anchor = anchorRef.current;
const target = event.target;
if (
dropdown &&
target instanceof Node &&
!dropdown.contains(target) &&
anchor &&
!anchor.contains(target)
) {
onClose();
}
};
window.addEventListener('mousedown', onPointerOutside);
return () => window.removeEventListener('mousedown', onPointerOutside);
}, [open, onClose, anchorRef]);
useEffect(() => {
if (!open) return;
const onEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener('keydown', onEscape);
return () => window.removeEventListener('keydown', onEscape);
}, [open, onClose]);
if (!open) return null;
const hasRichItems = items.some((item) => item.description || item.icon);
const hasCheckItems = hasRichItems || showCheck;
return (
<div
ref={dropdownRef}
className={`${styles.dropdown} ${
hasRichItems
? styles.dropdownRich
: showCheck
? styles.dropdownCheck
: ''
}`}
style={maxHeight ? { maxHeight, overflowY: 'auto' } : undefined}
>
{items.map((item) => (
<button
key={item.id}
type="button"
className={`${styles.dropdownItem} ${
item.id === activeId ? styles.dropdownItemActive : ''
}`}
onMouseDown={(e) => {
e.preventDefault();
onSelect(item.id);
}}
>
{hasCheckItems ? (
<>
{hasRichItems && (
<span className={styles.dropdownItemIcon}>{item.icon}</span>
)}
<span className={styles.dropdownItemContent}>
<span className={styles.dropdownItemLabel}>{item.label}</span>
{item.description && (
<span className={styles.dropdownItemDesc}>
{item.description}
</span>
)}
</span>
<span className={styles.dropdownItemCheck}>
{item.id === activeId ? <CheckIcon /> : null}
</span>
</>
) : (
item.label
)}
</button>
))}
</div>
);
}
function SlashCommandPanel({
menu,
anchorRef,
panelRef,
onSelect,
onAccept,
}: {
menu: SlashMenuState;
anchorRef: RefObject<HTMLElement | null>;
panelRef: RefObject<HTMLDivElement | null>;
onSelect: (index: number) => boolean;
onAccept: (index?: number) => boolean;
}) {
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [anchorRect, setAnchorRect] = useState<{
left: number;
bottom: number;
width: number;
} | null>(null);
const [themeVars, setThemeVars] = useState<CSSProperties>({});
const [hoverDetail, setHoverDetail] = useState<{
label: string;
detail: string;
left: number;
top?: number;
bottom?: number;
maxHeight: number;
} | null>(null);
const detailRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
itemRefs.current[menu.selectedIndex]?.scrollIntoView({
block: 'nearest',
});
}, [menu.items, menu.selectedIndex]);
useLayoutEffect(() => {
const anchor = anchorRef.current;
if (!anchor) return undefined;
const update = () => {
const rect = anchor.getBoundingClientRect();
const computedStyle = getComputedStyle(anchor);
const nextThemeVars = Object.fromEntries(
SLASH_PANEL_THEME_VARS.map((name) => [
name,
computedStyle.getPropertyValue(name),
]),
) as CSSProperties;
setAnchorRect({
left: Math.max(12, Math.min(rect.left + 16, window.innerWidth - 252)),
bottom: window.innerHeight - rect.top + 8,
width: rect.width,
});
setThemeVars(nextThemeVars);
};
update();
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(anchor);
window.addEventListener('resize', update);
window.addEventListener('scroll', update, true);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update, true);
};
}, [anchorRef, menu.items]);
useEffect(() => {
setHoverDetail(null);
}, [menu.items]);
const measureText = (text: string) => Array.from(text).length;
const maxLabelLength = Math.max(
...menu.items.map((item) => measureText(item.label)),
0,
);
const maxDetailLength = Math.max(
...menu.items.map((item) => measureText(item.detail ?? '')),
0,
);
const hasDetailColumn = maxDetailLength > 0;
const panelStyle = {
'--slash-command-col': `${Math.min(
Math.max(maxLabelLength + 1, 10),
24,
)}ch`,
'--slash-desc-col': hasDetailColumn
? `${Math.min(Math.max(maxDetailLength + 1, 18), 36)}ch`
: '0px',
'--slash-column-gap': hasDetailColumn ? '2ch' : '0px',
} as CSSProperties;
if (!anchorRect) return null;
const rowPlans = planSlashSectionRows(menu.items, menu.kind);
const positionedPanelStyle = {
...panelStyle,
...themeVars,
left: anchorRect.left,
bottom: anchorRect.bottom,
'--slash-anchor-width': `${anchorRect.width}px`,
} as CSSProperties;
return createPortal(
<div ref={panelRef} className={styles.slashPortalLayer} style={themeVars}>
<div
className={styles.slashPanel}
style={positionedPanelStyle}
role="listbox"
onMouseDown={(event) => event.preventDefault()}
onMouseLeave={(event) => {
const nextTarget = event.relatedTarget;
if (
nextTarget instanceof Node &&
detailRef.current?.contains(nextTarget)
) {
return;
}
setHoverDetail(null);
}}
>
<div className={styles.slashPanelBody}>
<div
className={styles.slashList}
onScroll={() => setHoverDetail(null)}
>
{menu.items.map((item, index) => {
const plan = rowPlans[index];
return (
<div key={`${item.id}:${index}`} className={styles.slashEntry}>
{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;
}}
type="button"
role="option"
aria-selected={index === menu.selectedIndex}
className={`${styles.slashItem} ${
index === menu.selectedIndex ? styles.slashItemActive : ''
}`}
onMouseEnter={(event) => {
onSelect(index);
if (!item.detail) {
setHoverDetail(null);
return;
}
const row = event.currentTarget;
const gap = 8;
const detailMaxHeight = 180;
const rowRect = row.getBoundingClientRect();
const detailWidth = 320;
const left = Math.min(
rowRect.left + Math.min(220, rowRect.width * 0.34),
window.innerWidth - detailWidth - 12,
);
const spaceBelow =
window.innerHeight - rowRect.bottom - gap - 12;
const spaceAbove = rowRect.top - gap - 12;
const showBelow =
spaceBelow >= 96 || spaceBelow >= spaceAbove;
const maxHeight = Math.max(
72,
Math.min(
detailMaxHeight,
showBelow ? spaceBelow : spaceAbove,
),
);
setHoverDetail({
label: item.label,
detail: item.detail,
left: Math.max(12, left),
...(showBelow
? { top: rowRect.bottom + gap }
: {
bottom: window.innerHeight - rowRect.top + gap,
}),
maxHeight,
});
}}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
onAccept(index);
}}
>
<span className={styles.slashCommand}>{item.label}</span>
{item.detail && (
<span className={styles.slashDescription}>
{item.detail}
</span>
)}
</button>
</div>
);
})}
</div>
</div>
</div>
{hoverDetail && (
<div
ref={detailRef}
className={styles.slashDetail}
style={{
...themeVars,
left: hoverDetail.left,
top: hoverDetail.top,
bottom: hoverDetail.bottom,
maxHeight: hoverDetail.maxHeight,
}}
>
<div className={styles.slashDetailCommand}>{hoverDetail.label}</div>
<div className={styles.slashDetailText}>{hoverDetail.detail}</div>
</div>
)}
</div>,
document.body,
);
}
function QuickActionsPanel({
actions,
onRun,
onPressKey,
}: {
actions: readonly QuickActionItem[];
onRun: (action: QuickActionItem) => void;
onPressKey: (item: QuickKeyItem) => void;
}) {
const { t } = useI18n();
return (
<div
className={styles.quickActionsPanel}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className={styles.quickActionsHeader}>{t('quickActions.title')}</div>
<div className={styles.quickActionsLayout}>
<div className={styles.quickActionsGrid}>
{actions.map((action) => (
<button
key={action.id}
type="button"
className={styles.quickAction}
onClick={() => onRun(action)}
>
<span className={styles.quickActionLabel}>{action.label}</span>
</button>
))}
</div>
<div className={styles.quickKeysGrid}>
{QUICK_KEY_ITEMS.map((item) => (
<button
key={item.id}
type="button"
className={styles.quickKey}
title={t(item.descriptionKey)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onPressKey(item)}
>
<span className={styles.quickKeyLabel}>{item.label}</span>
</button>
))}
</div>
</div>
</div>
);
}
export const ChatEditor = memo(
forwardRef<EditorHandle, ChatEditorProps>(function ChatEditor(props, ref) {
const {
onSubmit,
onCycleMode,
onToggleShortcuts,
onCancel,
isRunning = false,
isPreparing = false,
cancelArmed = false,
disabled = false,
placeholderText = 'Type a message...',
commands,
skills = [],
slashCommandCategoryOrder,
queuedMessages = [],
onPopQueuedMessages,
currentMode = 'default',
currentModel = '',
chatWidthMode = '1000',
showChatWidthToggle = true,
chatWidthToggleMin,
visibleToolbarActions,
availableModels = [],
onSelectMode,
onSelectModel,
onChatWidthModeChange,
onFocusFooter,
dialogOpen = false,
followupState,
onAcceptFollowup,
onDismissFollowup,
sessionName,
composerInput,
composerInputVersion,
} = props;
const core = useComposerCore({
onSubmit,
onCycleMode,
onToggleShortcuts,
disabled,
placeholderText,
commands,
skills,
slashCommandCategoryOrder,
queuedMessages,
onPopQueuedMessages,
currentMode,
onFocusFooter,
dialogOpen,
followupState,
onAcceptFollowup,
onDismissFollowup,
sessionName,
composerInput,
composerInputVersion,
editorTheme: CHAT_EDITOR_THEME,
});
const { t } = useI18n();
const {
renderComposerToolbarStart: ToolbarStart,
renderComposerToolbarEnd: ToolbarEnd,
renderComposerToolbarRight: ToolbarRight,
} = useWebShellCustomization();
useImperativeHandle(ref, () => core.handle, [core.handle]);
const [modeDropdownOpen, setModeDropdownOpen] = useState(false);
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
const [quickActionsOpen, setQuickActionsOpen] = useState(false);
const [showQuickActions, setShowQuickActions] = useState(isTouchLikeDevice);
const containerRef = useRef<HTMLDivElement>(null);
const slashPanelRef = useRef<HTMLDivElement>(null);
const modeBtnRef = useRef<HTMLButtonElement>(null);
const modelBtnRef = useRef<HTMLButtonElement>(null);
const [widthToggleFits, setWidthToggleFits] = useState(false);
const slashMenu = core.slashMenu;
const closeSlashMenu = core.closeSlashMenu;
const editorViewRef = core.viewRef;
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const media = window.matchMedia('(hover: none), (pointer: coarse)');
const update = () => setShowQuickActions(isTouchLikeDevice());
update();
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, []);
useEffect(() => {
if (!showQuickActions) setQuickActionsOpen(false);
}, [showQuickActions]);
useEffect(() => {
if (!slashMenu) return;
const onPointerOutside = (event: Event) => {
const target = event.target;
const container = containerRef.current;
if (
target instanceof Node &&
container &&
!container.contains(target) &&
!slashPanelRef.current?.contains(target)
) {
closeSlashMenu();
}
};
window.addEventListener('mousedown', onPointerOutside);
window.addEventListener('touchstart', onPointerOutside);
return () => {
window.removeEventListener('mousedown', onPointerOutside);
window.removeEventListener('touchstart', onPointerOutside);
};
}, [slashMenu, closeSlashMenu]);
useEffect(() => {
const glowRoot = containerRef.current;
const inputEl = editorViewRef.current?.contentDOM;
if (!glowRoot || !inputEl) return undefined;
return attachComposerGlow(glowRoot, inputEl);
}, [editorViewRef]);
useEffect(() => {
const container = containerRef.current;
const minWidth = chatWidthToggleMin;
if (!container || minWidth === undefined) {
setWidthToggleFits(false);
return;
}
const update = () => {
setWidthToggleFits(
container.getBoundingClientRect().width >= minWidth - 50,
);
};
update();
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, [chatWidthToggleMin]);
const modeItems = useMemo<DropdownItem[]>(
() =>
DAEMON_APPROVAL_MODES.map((id) => ({
id,
label: getModeListLabel(id, t),
description: t(`mode.desc.${id}`),
icon: <ModeIcon mode={id} />,
})),
[t],
);
const visibleActionSet = useMemo(() => {
if (!visibleToolbarActions) return null;
const activeActions = visibleToolbarActions.filter((action) =>
ACTIVE_TOOLBAR_ACTION_SET.has(action),
);
return new Set(activeActions);
}, [visibleToolbarActions]);
const showToolbarAction = (action: ComposerToolbarAction) => {
if (!visibleActionSet) return true;
return visibleActionSet.has(action);
};
const commandNames = useMemo(
() =>
new Set(commands.map((command) => command.name.replace(/^\/+/, ''))),
[commands],
);
const hasCommand = useCallback(
(name: string) => commandNames.has(name),
[commandNames],
);
const quickActions = useMemo(
() =>
(
[
{
id: 'new',
label: t('quickActions.new'),
action: { type: 'run', command: '/new' },
},
{
id: 'resume',
label: t('quickActions.resume'),
action: { type: 'run', command: '/resume' },
},
{
id: 'delete',
label: t('quickActions.delete'),
action: { type: 'run', command: '/delete' },
},
{
id: 'branch',
label: t('quickActions.branch'),
action: { type: 'run', command: '/branch' },
},
{
id: 'rewind',
label: t('quickActions.rewind'),
action: { type: 'run', command: '/rewind' },
},
{
id: 'history-search',
label: t('quickActions.historyQuestion'),
action: {
type: 'key',
item: {
id: 'ctrl-r',
label: 'Ctrl+R',
descriptionKey: 'quickKeys.searchHistory',
event: { key: 'r', code: 'KeyR', ctrlKey: true },
},
},
},
{
id: 'recap',
label: t('quickActions.recap'),
action: { type: 'run', command: '/recap' },
},
{
id: 'stats',
label: t('quickActions.stats'),
action: { type: 'run', command: '/stats' },
},
{
id: 'context',
label: t('quickActions.context'),
action: { type: 'run', command: '/context' },
},
{
id: 'status',
label: t('quickActions.status'),
action: { type: 'run', command: '/status' },
},
{
id: 'skills',
label: t('quickActions.skills'),
action: { type: 'run', command: '/skills detail' },
},
{
id: 'tools',
label: t('quickActions.tools'),
action: { type: 'run', command: '/tools desc' },
},
{
id: 'agents',
label: t('quickActions.agents'),
action: { type: 'run', command: '/agents' },
},
{
id: 'mcp',
label: t('quickActions.mcp'),
action: { type: 'run', command: '/mcp' },
},
{
id: 'memory',
label: t('quickActions.memory'),
action: { type: 'run', command: '/memory' },
},
{
id: 'theme',
label: t('quickActions.theme'),
action: { type: 'run', command: '/theme' },
},
{
id: 'shell',
label: core.shellMode
? t('quickActions.exitShellMode')
: t('quickActions.shellMode'),
action: { type: 'shell' },
},
{
id: 'goal',
label: t('quickActions.setGoal'),
action: { type: 'insert', text: '/goal ' },
},
] satisfies QuickActionItem[]
).filter((action) => {
const commandName = getQuickActionCommandName(action);
return !commandName || hasCommand(commandName);
}),
[core.shellMode, hasCommand, t],
);
const modelItems = useMemo<DropdownItem[]>(
() =>
availableModels.map((m) => ({
id: m.id,
label: getModelDisplayName(m.label || m.id),
})),
[availableModels],
);
const handleModeSelect = useCallback(
(modeId: string) => {
onSelectMode?.(modeId);
setModeDropdownOpen(false);
core.focus();
},
[onSelectMode, core],
);
const handleModelSelect = useCallback(
(modelId: string) => {
onSelectModel?.(modelId);
setModelDropdownOpen(false);
core.focus();
},
[onSelectModel, core],
);
const dispatchComposerKey = useCallback(
(event: QuickKeyItem['event']) => {
const view = core.viewRef.current;
if (!view) return;
view.focus();
view.contentDOM.dispatchEvent(
new KeyboardEvent('keydown', {
...event,
bubbles: true,
cancelable: true,
}),
);
},
[core],
);
const runQuickAction = useCallback(
(action: QuickActionItem) => {
setQuickActionsOpen(false);
setModeDropdownOpen(false);
setModelDropdownOpen(false);
core.closeSlashMenu();
if (action.action.type === 'insert') {
core.insertText(action.action.text, { mode: 'replace' });
return;
}
if (action.action.type === 'shell') {
core.toggleShellMode();
return;
}
if (action.action.type === 'key') {
dispatchComposerKey(action.action.item.event);
return;
}
onSubmit(action.action.command);
},
[core, dispatchComposerKey, onSubmit],
);
const pressQuickKey = useCallback(
(item: QuickKeyItem) => {
dispatchComposerKey(item.event);
if (item.id === 'ctrl-r') {
setQuickActionsOpen(false);
}
},
[dispatchComposerKey],
);
const {
searchMode,
searchQuery,
searchMatches,
searchActiveIndex,
searchInputRef,
searchUiRef,
closeSearch,
handleSearchKeyDown,
handleSearchInput,
handleSearchCompositionEnd,
} = core.searchState;
const renderComposerTagContent = (tag: WebShellComposerTag) => {
const tagLabel = getComposerTagLabel(tag);
const tagValue = getComposerTagValue(tag);
if (!tagLabel && !tagValue) {
return <span className={styles.tagLabel}>{tag.id}</span>;
}
return (
<>
{tagLabel && <span className={styles.tagLabel}>{tagLabel}</span>}
{tagValue && <span className={styles.tagValue}>{tagValue}</span>}
</>
);
};
// Mode display label
const modeLabel = getModeLabel(currentMode, t);
// Model display label
const modelLabel = getModelDisplayName(currentModel);
const showCancelButton = isRunning && !core.hasContent;
return (
<div className={styles.editorShell} data-composer>
<div
ref={containerRef}
className={styles.container}
data-dac-glow
onClick={() => {
setModeDropdownOpen(false);
setModelDropdownOpen(false);
setQuickActionsOpen(false);
core.focus();
}}
>
<div className={styles.dacAura} aria-hidden="true" />
<div className={styles.dacHalo} aria-hidden="true" />
{searchMode && (
<div
ref={searchUiRef}
className={styles.searchPanel}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className={styles.searchBar}>
<span className={styles.searchLabel}>
{t('editor.searchLabel')}
</span>
<input
ref={searchInputRef}
className={styles.searchInput}
value={searchQuery}
onChange={handleSearchInput}
onCompositionEnd={handleSearchCompositionEnd}
onKeyDown={handleSearchKeyDown}
placeholder={t('editor.searchPlaceholder')}
/>
</div>
{searchMatches.length > 0 && (
<div className={styles.searchResults}>
{searchMatches.map((match, matchIndex) => {
return (
<button
key={`${match}-${matchIndex}`}
type="button"
className={`${styles.searchResult} ${
matchIndex === searchActiveIndex
? styles.searchResultActive
: ''
}`}
onMouseDown={(event) => {
event.preventDefault();
core.replaceEditorText(match);
closeSearch(false);
}}
>
<span className={styles.searchResultMarker}>
{matchIndex === searchActiveIndex ? '' : ''}
</span>
<span className={styles.searchResultText}>{match}</span>
</button>
);
})}
</div>
)}
{searchMatches.length === 0 && (
<div className={styles.searchEmpty}>
{t('editor.noHistory')}
</div>
)}
</div>
)}
<div className={styles.content}>
{core.composerTags.length > 0 && (
<div className={styles.tags}>
{core.composerTags.map((tag) => (
<span key={tag.id} className={styles.tag}>
{renderComposerTagContent(tag)}
{tag.removable !== false && (
<button
type="button"
className={styles.tagRemove}
aria-label={`Remove ${getComposerTagDisplay(tag)}`}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation();
core.removeTopTag(tag.id);
core.viewRef.current?.focus();
}}
onKeyDown={(event) => {
if (
event.key !== 'Backspace' &&
event.key !== 'Delete'
) {
return;
}
event.preventDefault();
core.removeTopTag(tag.id);
core.viewRef.current?.focus();
}}
>
×
</button>
)}
</span>
))}
</div>
)}
{core.pastedImages.length > 0 && (
<div className={styles.images}>
{core.pastedImages.map((img, i) => (
<div key={i} className={styles.imageThumb}>
<img
src={`data:${img.media_type};base64,${img.data}`}
alt=""
/>
<button
className={styles.imageRemove}
onClick={(e) => {
e.stopPropagation();
core.removeImage(i);
}}
>
×
</button>
</div>
))}
</div>
)}
{core.slashMenu && (
<SlashCommandPanel
menu={core.slashMenu}
anchorRef={containerRef}
panelRef={slashPanelRef}
onSelect={core.selectSlashCompletion}
onAccept={core.acceptSlashCompletion}
/>
)}
<div className={styles.editorArea}>
{core.shellMode && (
<span className={styles.shellPrefix} aria-hidden="true">
!
</span>
)}
<div ref={core.containerRef} />
</div>
<div className={styles.toolbar}>
<div className={styles.toolbarLeading}>
{ToolbarStart && (
<div className={styles.toolbarStart}>
<ToolbarStart
disabled={disabled}
isRunning={isRunning}
currentMode={currentMode}
currentModel={currentModel}
sessionName={sessionName}
/>
</div>
)}
<div className={styles.toolbarLeft}>
{showToolbarAction('approvalMode') && (
<div className={styles.dropdownWrapper}>
<ToolbarDropdown
open={modeDropdownOpen}
items={modeItems}
activeId={currentMode}
onClose={() => setModeDropdownOpen(false)}
onSelect={handleModeSelect}
anchorRef={modeBtnRef}
/>
<button
ref={modeBtnRef}
className={styles.toolBtn}
onClick={(e) => {
e.stopPropagation();
core.closeSlashMenu();
setQuickActionsOpen(false);
setModeDropdownOpen((v) => !v);
setModelDropdownOpen(false);
}}
aria-label={t('status.mode')}
>
<span className={styles.toolBtnModeIcon}>
<ModeIcon mode={currentMode} />
</span>
<span className={styles.toolBtnText}>{modeLabel}</span>
<span className={styles.toolBtnArrow}>
<ChevronDownIcon />
</span>
</button>
</div>
)}
{showToolbarAction('model') && (
<div className={styles.dropdownWrapper}>
<ToolbarDropdown
open={modelDropdownOpen}
items={modelItems}
activeId={currentModel}
onClose={() => setModelDropdownOpen(false)}
onSelect={handleModelSelect}
anchorRef={modelBtnRef}
showCheck
maxHeight={300}
/>
<button
ref={modelBtnRef}
className={styles.toolBtn}
onClick={(e) => {
e.stopPropagation();
core.closeSlashMenu();
setQuickActionsOpen(false);
setModelDropdownOpen((v) => !v);
setModeDropdownOpen(false);
}}
aria-label={t('model.select')}
>
<span className={styles.toolBtnModelIcon}>
<ModelIcon />
</span>
<span className={styles.toolBtnText}>{modelLabel}</span>
<span className={styles.toolBtnArrow}>
<ChevronDownIcon />
</span>
</button>
</div>
)}
{ToolbarEnd && (
<div className={styles.toolbarEnd}>
<ToolbarEnd
disabled={disabled}
isRunning={isRunning}
currentMode={currentMode}
currentModel={currentModel}
sessionName={sessionName}
/>
</div>
)}
</div>
</div>
<div className={styles.toolbarRight}>
{showQuickActions && quickActions.length > 0 && (
<button
className={`${styles.toolBtn} ${styles.quickActionsBtn}`}
onClick={(e) => {
e.stopPropagation();
core.closeSlashMenu();
setModeDropdownOpen(false);
setModelDropdownOpen(false);
setQuickActionsOpen((value) => !value);
}}
aria-expanded={quickActionsOpen}
aria-label={t('quickActions.open')}
title={t('quickActions.open')}
data-tooltip={t('quickActions.open')}
>
<span className={styles.toolBtnIcon}>
<QuickActionsIcon />
</span>
</button>
)}
{ToolbarRight && (
<div className={styles.toolbarRightCustom}>
<ToolbarRight
disabled={disabled}
isRunning={isRunning}
currentMode={currentMode}
currentModel={currentModel}
sessionName={sessionName}
/>
</div>
)}
{showChatWidthToggle &&
widthToggleFits &&
showToolbarAction('widthMode') && (
<button
className={`${styles.toolBtn} ${styles.widthModeBtn}`}
onClick={(e) => {
e.stopPropagation();
onChatWidthModeChange?.(
chatWidthMode === 'wide' ? '1000' : 'wide',
);
}}
disabled={!onChatWidthModeChange}
aria-label={
chatWidthMode === 'wide'
? t('settings.option.ui.chatWidth.1000')
: t('settings.option.ui.chatWidth.wide')
}
title={
chatWidthMode === 'wide'
? t('settings.option.ui.chatWidth.1000')
: t('settings.option.ui.chatWidth.wide')
}
data-tooltip={
chatWidthMode === 'wide'
? t('settings.option.ui.chatWidth.1000')
: t('settings.option.ui.chatWidth.wide')
}
>
<span className={styles.toolBtnIcon}>
<WidthModeIcon mode={chatWidthMode} />
</span>
</button>
)}
{showToolbarAction('voice') && (
<VoiceButton
disabled={disabled}
onInsert={(text) => {
const existing = core.getText();
const sep = existing && !/\s$/.test(existing) ? ' ' : '';
core.insertText(`${sep}${text} `);
core.focus();
}}
/>
)}
<button
className={
isPreparing || showCancelButton
? `${styles.sendBtn} ${styles.sendBtnRunning}${
cancelArmed ? ` ${styles.sendBtnArmed}` : ''
}`
: styles.sendBtn
}
disabled={
isPreparing
? true
: showCancelButton
? !onCancel
: core.disabled || !core.hasContent
}
onClick={(e) => {
e.stopPropagation();
if (isPreparing) {
return;
}
if (showCancelButton) {
onCancel?.();
return;
}
core.submitText();
}}
aria-label={
isPreparing
? t('common.loading')
: showCancelButton
? cancelArmed
? t('stream.cancelArmed')
: t('stream.cancel')
: t('editor.send')
}
title={
isRunning && cancelArmed
? t('stream.cancelArmed')
: undefined
}
>
{isPreparing ? (
<LoadingIcon />
) : showCancelButton ? (
cancelArmed ? (
<span className={styles.escLabel} aria-hidden="true">
Esc
</span>
) : (
<StopIcon />
)
) : (
<SendIcon />
)}
</button>
<span
role="status"
aria-live="polite"
className={styles.srOnly}
>
{isRunning && cancelArmed ? t('stream.cancelArmed') : ''}
</span>
</div>
</div>
</div>
</div>
{showQuickActions && quickActionsOpen && quickActions.length > 0 && (
<QuickActionsPanel
actions={quickActions}
onRun={runQuickAction}
onPressKey={pressQuickKey}
/>
)}
</div>
);
}),
);