Shows loaded skills in slash autocomplete with clear type badges Stabilizes keyboard navigation across autocomplete menus Fixes skill link rendering and skill autocomplete scrolling
473 lines
19 KiB
TypeScript
473 lines
19 KiB
TypeScript
import React from 'react';
|
|
import { cn, fuzzyMatch } from '@/lib/utils';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useSessionMessages } from '@/sync/sync-context';
|
|
import { useCommandsStore } from '@/stores/useCommandsStore';
|
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
type CommandSource = 'openchamber' | 'opencode' | 'skill';
|
|
|
|
export interface CommandInfo {
|
|
id: string;
|
|
name: string;
|
|
source: CommandSource;
|
|
description?: string;
|
|
agent?: string;
|
|
model?: string;
|
|
isBuiltIn?: boolean;
|
|
isOpenChamber?: boolean;
|
|
isSkill?: boolean;
|
|
scope?: string;
|
|
}
|
|
|
|
export interface CommandAutocompleteHandle {
|
|
handleKeyDown: (key: string) => void;
|
|
}
|
|
|
|
type AutocompleteTab = 'commands' | 'agents' | 'files';
|
|
|
|
const BASE_BADGE_CLASS = "text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0";
|
|
const TYPE_BADGE_CLASS = cn(
|
|
BASE_BADGE_CLASS,
|
|
"bg-[color-mix(in_srgb,var(--primary-base)_12%,transparent)] text-[color-mix(in_srgb,var(--primary-base)_70%,transparent)] border-[color-mix(in_srgb,var(--primary-base)_24%,transparent)]"
|
|
);
|
|
const USER_BADGE_CLASS = cn(
|
|
BASE_BADGE_CLASS,
|
|
"bg-[color-mix(in_srgb,var(--status-success)_12%,transparent)] text-[color-mix(in_srgb,var(--status-success)_70%,transparent)] border-[color-mix(in_srgb,var(--status-success)_24%,transparent)]"
|
|
);
|
|
const PROJECT_BADGE_CLASS = cn(
|
|
BASE_BADGE_CLASS,
|
|
"bg-[color-mix(in_srgb,var(--status-info)_12%,transparent)] text-[color-mix(in_srgb,var(--status-info)_70%,transparent)] border-[color-mix(in_srgb,var(--status-info)_24%,transparent)]"
|
|
);
|
|
const NEUTRAL_BADGE_CLASS = cn(
|
|
BASE_BADGE_CLASS,
|
|
"bg-[var(--surface-muted)] text-muted-foreground border-[var(--interactive-border)]/60"
|
|
);
|
|
|
|
interface CommandAutocompleteProps {
|
|
searchQuery: string;
|
|
onCommandSelect: (command: CommandInfo, options?: { dismissKeyboard?: boolean }) => void;
|
|
onClose: () => void;
|
|
showTabs?: boolean;
|
|
activeTab?: AutocompleteTab;
|
|
onTabSelect?: (tab: AutocompleteTab) => void;
|
|
style?: React.CSSProperties;
|
|
}
|
|
|
|
export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, CommandAutocompleteProps>(({
|
|
searchQuery,
|
|
onCommandSelect,
|
|
onClose,
|
|
showTabs,
|
|
activeTab = 'commands',
|
|
onTabSelect,
|
|
style,
|
|
}, ref) => {
|
|
const { t } = useI18n();
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
|
const sessionMessages = useSessionMessages(currentSessionId ?? '');
|
|
const hasMessagesInCurrentSession = sessionMessages.length > 0;
|
|
const hasSession = Boolean(currentSessionId);
|
|
const hasNewSessionDraft = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
|
const canStartSessionCommand = hasSession || hasNewSessionDraft;
|
|
|
|
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
|
|
const [loading, setLoading] = React.useState(false);
|
|
const commandsWithMetadata = useCommandsStore((s) => s.commands);
|
|
const refreshCommands = useCommandsStore((s) => s.loadCommands);
|
|
const skills = useSkillsStore((s) => s.skills);
|
|
const refreshSkills = useSkillsStore((s) => s.loadSkills);
|
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
const selectedIndexRef = React.useRef(0);
|
|
const keyboardNavigationRef = React.useRef(false);
|
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
|
const ignoreClickRef = React.useRef(false);
|
|
const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
|
|
const pointerMovedRef = React.useRef(false);
|
|
const ignoreTabClickRef = React.useRef(false);
|
|
|
|
React.useEffect(() => {
|
|
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
|
const target = event.target as Node | null;
|
|
if (!target || !containerRef.current) {
|
|
return;
|
|
}
|
|
if (containerRef.current.contains(target)) {
|
|
return;
|
|
}
|
|
onClose();
|
|
};
|
|
|
|
document.addEventListener('pointerdown', handlePointerDown, true);
|
|
return () => {
|
|
document.removeEventListener('pointerdown', handlePointerDown, true);
|
|
};
|
|
}, [onClose]);
|
|
|
|
React.useEffect(() => {
|
|
// Force refresh to get latest project context when mounting
|
|
void refreshCommands();
|
|
void refreshSkills();
|
|
}, [refreshCommands, refreshSkills]);
|
|
|
|
React.useEffect(() => {
|
|
const loadCommands = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const skillNames = new Set(skills.map((skill) => skill.name));
|
|
const customCommands: CommandInfo[] = commandsWithMetadata.map((cmd, index) => ({
|
|
id: `opencode:${cmd.scope ?? 'global'}:${cmd.name}:${cmd.agent ?? ''}:${cmd.model ?? ''}:${index}`,
|
|
name: cmd.name,
|
|
source: 'opencode',
|
|
description: cmd.description,
|
|
agent: cmd.agent ?? undefined,
|
|
model: cmd.model ?? undefined,
|
|
isBuiltIn: cmd.name === 'init' || cmd.name === 'review',
|
|
isSkill: cmd.source === 'skill' || skillNames.has(cmd.name),
|
|
scope: cmd.scope,
|
|
}));
|
|
const skillCommands: CommandInfo[] = skills.map((skill, index) => ({
|
|
id: `skill:${skill.scope}:${skill.source ?? 'opencode'}:${skill.name}:${index}`,
|
|
name: skill.name,
|
|
source: 'skill',
|
|
description: skill.description,
|
|
isSkill: true,
|
|
scope: skill.scope,
|
|
}));
|
|
|
|
const builtInCommands: CommandInfo[] = [
|
|
...(hasSession && !hasMessagesInCurrentSession
|
|
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
|
|
: []
|
|
),
|
|
...(hasSession // Show when session exists, not when hasMessages
|
|
? [
|
|
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.undoDescription'), isBuiltIn: true },
|
|
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.redoDescription'), isBuiltIn: true },
|
|
{ id: 'openchamber:timeline', name: 'timeline', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.timelineDescription'), isBuiltIn: true },
|
|
]
|
|
: []
|
|
),
|
|
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
|
|
...(hasSession
|
|
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
|
|
: []
|
|
),
|
|
...(canStartSessionCommand
|
|
? [{ id: 'openchamber:workspace-review', name: 'workspace-review', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.workspaceReviewDescription'), isOpenChamber: true }]
|
|
: []
|
|
),
|
|
];
|
|
const allCommands = [...builtInCommands, ...customCommands, ...skillCommands];
|
|
|
|
const allowInitCommand = !hasMessagesInCurrentSession;
|
|
const filtered = (searchQuery
|
|
? allCommands.filter(cmd =>
|
|
fuzzyMatch(cmd.name, searchQuery) ||
|
|
(cmd.description && fuzzyMatch(cmd.description, searchQuery))
|
|
)
|
|
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
|
|
|
filtered.sort((a, b) => {
|
|
const aStartsWith = a.name.toLowerCase().startsWith(searchQuery.toLowerCase());
|
|
const bStartsWith = b.name.toLowerCase().startsWith(searchQuery.toLowerCase());
|
|
if (aStartsWith && !bStartsWith) return -1;
|
|
if (!aStartsWith && bStartsWith) return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
setCommands(filtered);
|
|
} catch {
|
|
|
|
const allowInitCommand = !hasMessagesInCurrentSession;
|
|
const builtInCommands: CommandInfo[] = [
|
|
...(hasSession && !hasMessagesInCurrentSession
|
|
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
|
|
: []
|
|
),
|
|
...(hasSession // Show when session exists, not when hasMessages
|
|
? [
|
|
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.undoDescription'), isBuiltIn: true },
|
|
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.redoDescription'), isBuiltIn: true },
|
|
{ id: 'openchamber:timeline', name: 'timeline', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.timelineDescription'), isBuiltIn: true },
|
|
]
|
|
: []
|
|
),
|
|
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
|
|
...(hasSession
|
|
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
|
|
: []
|
|
),
|
|
...(canStartSessionCommand
|
|
? [{ id: 'openchamber:workspace-review', name: 'workspace-review', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.workspaceReviewDescription'), isOpenChamber: true }]
|
|
: []
|
|
),
|
|
];
|
|
|
|
const filtered = (searchQuery
|
|
? builtInCommands.filter(cmd =>
|
|
fuzzyMatch(cmd.name, searchQuery) ||
|
|
(cmd.description && fuzzyMatch(cmd.description, searchQuery))
|
|
)
|
|
: builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
|
|
|
setCommands(filtered);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadCommands();
|
|
}, [searchQuery, hasMessagesInCurrentSession, hasSession, canStartSessionCommand, commandsWithMetadata, skills, t]);
|
|
|
|
React.useEffect(() => {
|
|
setSelectedIndex(0);
|
|
}, [commands]);
|
|
|
|
React.useEffect(() => {
|
|
selectedIndexRef.current = selectedIndex;
|
|
}, [selectedIndex]);
|
|
|
|
React.useEffect(() => {
|
|
itemRefs.current[selectedIndex]?.scrollIntoView({
|
|
block: 'nearest'
|
|
});
|
|
}, [selectedIndex]);
|
|
|
|
React.useImperativeHandle(ref, () => ({
|
|
handleKeyDown: (key: string) => {
|
|
const total = commands.length;
|
|
if (key === 'Escape') {
|
|
onClose();
|
|
return;
|
|
}
|
|
|
|
if (total === 0) {
|
|
return;
|
|
}
|
|
|
|
if (key === 'ArrowDown') {
|
|
keyboardNavigationRef.current = true;
|
|
setSelectedIndex((prev) => (prev + 1) % total);
|
|
return;
|
|
}
|
|
|
|
if (key === 'ArrowUp') {
|
|
keyboardNavigationRef.current = true;
|
|
setSelectedIndex((prev) => (prev - 1 + total) % total);
|
|
return;
|
|
}
|
|
|
|
if (key === 'Enter' || key === 'Tab') {
|
|
const safeIndex = ((selectedIndexRef.current % total) + total) % total;
|
|
const command = commands[safeIndex];
|
|
if (command) {
|
|
onCommandSelect(command);
|
|
}
|
|
}
|
|
}
|
|
}), [commands, onClose, onCommandSelect]);
|
|
|
|
const getCommandIcon = (command: CommandInfo) => {
|
|
|
|
switch (command.name) {
|
|
case 'init':
|
|
return <Icon name="file" className="h-3.5 w-3.5 text-green-500" />;
|
|
case 'undo':
|
|
return <Icon name="arrow-go-back" className="h-3.5 w-3.5 text-orange-500" />;
|
|
case 'redo':
|
|
return <Icon name="arrow-go-forward" className="h-3.5 w-3.5 text-orange-500" />;
|
|
case 'timeline':
|
|
return <Icon name="time" className="h-3.5 w-3.5" />;
|
|
case 'compact':
|
|
return <Icon name="scissors" className="h-3.5 w-3.5 text-purple-500" />;
|
|
case 'review':
|
|
return <Icon name="search-eye" className="h-3.5 w-3.5 text-blue-500" />;
|
|
case 'test':
|
|
case 'build':
|
|
case 'run':
|
|
return <Icon name="terminal-box" className="h-3.5 w-3.5 text-cyan-500" />;
|
|
default:
|
|
if (command.isBuiltIn) {
|
|
return <Icon name="flashlight" className="h-3.5 w-3.5 text-yellow-500" />;
|
|
}
|
|
return <Icon name="command" className="h-3.5 w-3.5 text-muted-foreground" />;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
|
style={style}
|
|
>
|
|
{showTabs ? (
|
|
<div className="px-2 pt-2 pb-1 border-b border-border/60">
|
|
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
|
|
{([
|
|
{ id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
|
|
{ id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
|
|
{ id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
|
|
]).map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
className={cn(
|
|
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
|
|
activeTab === tab.id
|
|
? 'bg-interactive-selection text-interactive-selection-foreground shadow-none'
|
|
: 'text-muted-foreground hover:bg-interactive-hover/50'
|
|
)}
|
|
onPointerDown={(event) => {
|
|
if (event.pointerType !== 'touch') {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
ignoreTabClickRef.current = true;
|
|
onTabSelect?.(tab.id);
|
|
}}
|
|
onClick={() => {
|
|
if (ignoreTabClickRef.current) {
|
|
ignoreTabClickRef.current = false;
|
|
return;
|
|
}
|
|
onTabSelect?.(tab.id);
|
|
}}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-4">
|
|
<Icon name="refresh" className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{commands.map((command, index) => {
|
|
const isSystem = command.isBuiltIn;
|
|
const isOpenChamberBadge = command.isOpenChamber;
|
|
return (
|
|
<div
|
|
key={command.id}
|
|
ref={(el) => { itemRefs.current[index] = el; }}
|
|
className={cn(
|
|
"flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg",
|
|
index === selectedIndex && "bg-interactive-selection"
|
|
)}
|
|
onPointerDown={(event) => {
|
|
if (event.pointerType !== 'touch') {
|
|
return;
|
|
}
|
|
pointerStartRef.current = { x: event.clientX, y: event.clientY };
|
|
pointerMovedRef.current = false;
|
|
}}
|
|
onPointerMove={(event) => {
|
|
if (event.pointerType !== 'touch' || !pointerStartRef.current) {
|
|
return;
|
|
}
|
|
const dx = event.clientX - pointerStartRef.current.x;
|
|
const dy = event.clientY - pointerStartRef.current.y;
|
|
if (Math.hypot(dx, dy) > 6) {
|
|
pointerMovedRef.current = true;
|
|
}
|
|
}}
|
|
onPointerUp={(event) => {
|
|
if (event.pointerType !== 'touch') {
|
|
return;
|
|
}
|
|
const didMove = pointerMovedRef.current;
|
|
pointerStartRef.current = null;
|
|
pointerMovedRef.current = false;
|
|
if (didMove) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
ignoreClickRef.current = true;
|
|
onCommandSelect(command, { dismissKeyboard: true });
|
|
}}
|
|
onPointerCancel={() => {
|
|
pointerStartRef.current = null;
|
|
pointerMovedRef.current = false;
|
|
}}
|
|
onClick={() => {
|
|
if (ignoreClickRef.current) {
|
|
ignoreClickRef.current = false;
|
|
return;
|
|
}
|
|
onCommandSelect(command);
|
|
}}
|
|
onMouseMove={() => {
|
|
keyboardNavigationRef.current = false;
|
|
setSelectedIndex(index);
|
|
}}
|
|
>
|
|
<div className="mt-0.5">
|
|
{getCommandIcon(command)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="typography-ui-label font-medium">/{command.name}</span>
|
|
{command.isSkill ? (
|
|
<span className={TYPE_BADGE_CLASS}>
|
|
{t('chat.commandAutocomplete.badge.skill')}
|
|
</span>
|
|
) : (
|
|
<span className={TYPE_BADGE_CLASS}>
|
|
{t('chat.commandAutocomplete.badge.command')}
|
|
</span>
|
|
)}
|
|
{isOpenChamberBadge ? (
|
|
<span className={NEUTRAL_BADGE_CLASS}>
|
|
OpenChamber
|
|
</span>
|
|
) : isSystem ? (
|
|
<span className={NEUTRAL_BADGE_CLASS}>
|
|
{t('chat.commandAutocomplete.badge.system')}
|
|
</span>
|
|
) : command.scope ? (
|
|
<span className={command.scope === 'project' ? PROJECT_BADGE_CLASS : USER_BADGE_CLASS}>
|
|
{command.scope}
|
|
</span>
|
|
) : null}
|
|
{command.agent && (
|
|
<span className={NEUTRAL_BADGE_CLASS}>
|
|
{command.agent}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{command.description && (
|
|
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
|
{command.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{commands.length === 0 && (
|
|
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
|
{t('chat.commandAutocomplete.empty')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</ScrollableOverlay>
|
|
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
|
{t('chat.autocomplete.keyboardHint')}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
CommandAutocomplete.displayName = 'CommandAutocomplete';
|