Replace the 'What changed recently' draft chip with /catch-up, a command whose hidden prompt branches on git state: reconstruct intent from an in-progress diff, check an open PR's review state, or summarize recent commits. Add /debug as a new draft chip and command: a guided root-cause investigation that captures the symptom, forms hypotheses, checks them against the code, and confirms the cause before proposing a fix. Both follow the /workspace-review pattern — visible + hidden magic prompts wired into command autocomplete, the submit handler, draft preset chips, and the Magic Prompts settings page, with i18n across all locales.
448 lines
19 KiB
TypeScript
448 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;
|
|
}
|
|
|
|
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;
|
|
style?: React.CSSProperties;
|
|
}
|
|
|
|
export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, CommandAutocompleteProps>(({
|
|
searchQuery,
|
|
onCommandSelect,
|
|
onClose,
|
|
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);
|
|
|
|
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 }]
|
|
: []
|
|
),
|
|
...(canStartSessionCommand
|
|
? [{ id: 'openchamber:plan-feature', name: 'plan-feature', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.featurePlanDescription'), isOpenChamber: true }]
|
|
: []
|
|
),
|
|
...(canStartSessionCommand
|
|
? [{ id: 'openchamber:catch-up', name: 'catch-up', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.catchUpDescription'), isOpenChamber: true }]
|
|
: []
|
|
),
|
|
...(canStartSessionCommand
|
|
? [{ id: 'openchamber:debug', name: 'debug', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.debugDescription'), 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 }]
|
|
: []
|
|
),
|
|
...(canStartSessionCommand
|
|
? [{ id: 'openchamber:plan-feature', name: 'plan-feature', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.featurePlanDescription'), isOpenChamber: true }]
|
|
: []
|
|
),
|
|
...(canStartSessionCommand
|
|
? [{ id: 'openchamber:catch-up', name: 'catch-up', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.catchUpDescription'), isOpenChamber: true }]
|
|
: []
|
|
),
|
|
...(canStartSessionCommand
|
|
? [{ id: 'openchamber:debug', name: 'debug', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.debugDescription'), 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}
|
|
>
|
|
<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';
|