2025-12-07 19:32:53 +02:00
import React from 'react' ;
2026-03-31 18:47:00 +03:00
import { RiCommandLine , RiFileLine , RiFlashlightLine , RiRefreshLine , RiScissorsLine , RiTerminalBoxLine , RiArrowGoBackLine , RiArrowGoForwardLine } from '@remixicon/react' ;
2026-01-08 20:49:22 +02:00
import { cn , fuzzyMatch } from '@/lib/utils' ;
2026-03-31 18:47:00 +03:00
import { useSessionUIStore } from '@/sync/session-ui-store' ;
import { useSessionMessages } from '@/sync/sync-context' ;
2026-01-08 20:19:33 +02:00
import { useCommandsStore } from '@/stores/useCommandsStore' ;
2026-02-18 20:08:42 +02:00
import { useSkillsStore } from '@/stores/useSkillsStore' ;
2025-12-07 19:32:53 +02:00
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay' ;
2026-04-22 22:08:34 +03:00
type CommandSource = 'openchamber' | 'opencode' ;
export interface CommandInfo {
id : string ;
2025-12-07 19:32:53 +02:00
name : string ;
2026-04-22 22:08:34 +03:00
source : CommandSource ;
2025-12-07 19:32:53 +02:00
description? : string ;
agent? : string ;
model? : string ;
isBuiltIn? : boolean ;
2026-04-22 21:56:35 +03:00
isOpenChamber? : boolean ;
2026-02-18 20:08:42 +02:00
isSkill? : boolean ;
2026-01-08 20:19:33 +02:00
scope? : string ;
2025-12-07 19:32:53 +02:00
}
export interface CommandAutocompleteHandle {
handleKeyDown : ( key : string ) => void ;
}
2026-02-04 01:14:10 -08:00
type AutocompleteTab = 'commands' | 'agents' | 'files' ;
2025-12-07 19:32:53 +02:00
interface CommandAutocompleteProps {
searchQuery : string ;
2026-02-04 01:14:10 -08:00
onCommandSelect : ( command : CommandInfo , options ?: { dismissKeyboard? : boolean }) => void ;
2025-12-07 19:32:53 +02:00
onClose : () => void ;
2026-02-04 01:14:10 -08:00
showTabs? : boolean ;
activeTab? : AutocompleteTab ;
onTabSelect ?: ( tab : AutocompleteTab ) => void ;
2026-02-23 05:04:25 +07:00
style? : React.CSSProperties ;
2025-12-07 19:32:53 +02:00
}
export const CommandAutocomplete = React . forwardRef < CommandAutocompleteHandle , CommandAutocompleteProps >(({
searchQuery ,
onCommandSelect ,
2026-02-04 01:14:10 -08:00
onClose ,
showTabs ,
activeTab = 'commands' ,
2026-02-23 05:04:25 +07:00
onTabSelect ,
style ,
2025-12-07 19:32:53 +02:00
}, ref ) => {
2026-03-31 18:47:00 +03:00
const currentSessionId = useSessionUIStore (( state ) => state . currentSessionId );
const sessionMessages = useSessionMessages ( currentSessionId ?? '' );
const hasMessagesInCurrentSession = sessionMessages . length > 0 ;
2026-01-02 18:33:35 -05:00
const hasSession = Boolean ( currentSessionId );
2025-12-07 19:32:53 +02:00
const [ commands , setCommands ] = React . useState < CommandInfo [] >([]);
const [ loading , setLoading ] = React . useState ( false );
2026-01-08 20:19:33 +02:00
const { commands : commandsWithMetadata , loadCommands : refreshCommands } = useCommandsStore ();
2026-02-18 20:08:42 +02:00
const { skills , loadSkills : refreshSkills } = useSkillsStore ();
2025-12-07 19:32:53 +02:00
const [ selectedIndex , setSelectedIndex ] = React . useState ( 0 );
const itemRefs = React . useRef < ( HTMLDivElement | null )[] > ([]);
const containerRef = React . useRef < HTMLDivElement | null >( null );
2026-02-04 01:14:10 -08:00
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 );
2025-12-07 19:32:53 +02:00
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 ]);
2026-01-08 20:19:33 +02:00
React . useEffect (() => {
// Force refresh to get latest project context when mounting
void refreshCommands ();
2026-02-18 20:08:42 +02:00
void refreshSkills ();
}, [ refreshCommands , refreshSkills ]);
2026-01-08 20:19:33 +02:00
2025-12-07 19:32:53 +02:00
React . useEffect (() => {
const loadCommands = async () => {
setLoading ( true );
try {
2026-02-18 20:08:42 +02:00
const skillNames = new Set ( skills . map (( skill ) => skill . name ));
2026-04-22 22:08:34 +03:00
const customCommands : CommandInfo [] = commandsWithMetadata . map (( cmd , index ) => ({
id : `opencode: ${ cmd . scope ?? 'global' } : ${ cmd . name } : ${ cmd . agent ?? '' } : ${ cmd . model ?? '' } : ${ index } ` ,
2025-12-07 19:32:53 +02:00
name : cmd.name ,
2026-04-22 22:08:34 +03:00
source : 'opencode' ,
2025-12-07 19:32:53 +02:00
description : cmd.description ,
2026-01-08 20:19:33 +02:00
agent : cmd.agent ?? undefined ,
model : cmd.model ?? undefined ,
isBuiltIn : cmd.name === 'init' || cmd . name === 'review' ,
2026-02-18 20:08:42 +02:00
isSkill : skillNames.has ( cmd . name ),
2026-01-08 20:19:33 +02:00
scope : cmd.scope ,
2025-12-07 19:32:53 +02:00
}));
const builtInCommands : CommandInfo [] = [
2026-01-02 18:33:35 -05:00
...( hasSession && ! hasMessagesInCurrentSession
2026-04-22 22:08:34 +03:00
? [{ id : 'openchamber:init' , name : 'init' , source : 'openchamber' as const , description : 'Create/update AGENTS.md file' , isBuiltIn : true }]
2026-01-02 18:33:35 -05:00
: []
),
...( hasSession // Show when session exists, not when hasMessages
? [
2026-04-22 22:08:34 +03:00
{ id : 'openchamber:undo' , name : 'undo' , source : 'openchamber' as const , description : 'Undo the last message' , isBuiltIn : true },
{ id : 'openchamber:redo' , name : 'redo' , source : 'openchamber' as const , description : 'Redo previously undone messages' , isBuiltIn : true },
2026-01-02 18:33:35 -05:00
]
: []
),
2026-04-22 22:08:34 +03:00
{ id : 'openchamber:compact' , name : 'compact' , source : 'openchamber' as const , description : 'Compress session history using AI to reduce context size' , isBuiltIn : true },
2026-04-22 21:56:35 +03:00
...( hasSession
2026-04-22 22:08:34 +03:00
? [{ id : 'openchamber:summary' , name : 'summary' , source : 'openchamber' as const , description : 'Non-destructive session summary. Optional topic hint after the command.' , isOpenChamber : true }]
2026-04-22 21:56:35 +03:00
: []
),
2025-12-07 19:32:53 +02:00
];
2026-04-22 22:08:34 +03:00
const allCommands = [... builtInCommands , ... customCommands ];
2025-12-07 19:32:53 +02:00
const allowInitCommand = ! hasMessagesInCurrentSession ;
const filtered = ( searchQuery
? allCommands . filter ( cmd =>
2026-01-08 20:49:22 +02:00
fuzzyMatch ( cmd . name , searchQuery ) ||
( cmd . description && fuzzyMatch ( cmd . description , searchQuery ))
2025-12-07 19:32:53 +02:00
)
: 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 [] = [
2026-01-02 18:33:35 -05:00
...( hasSession && ! hasMessagesInCurrentSession
2026-04-22 22:08:34 +03:00
? [{ id : 'openchamber:init' , name : 'init' , source : 'openchamber' as const , description : 'Create/update AGENTS.md file' , isBuiltIn : true }]
2026-01-02 18:33:35 -05:00
: []
),
...( hasSession // Show when session exists, not when hasMessages
? [
2026-04-22 22:08:34 +03:00
{ id : 'openchamber:undo' , name : 'undo' , source : 'openchamber' as const , description : 'Undo the last message' , isBuiltIn : true },
{ id : 'openchamber:redo' , name : 'redo' , source : 'openchamber' as const , description : 'Redo previously undone messages' , isBuiltIn : true },
2026-01-02 18:33:35 -05:00
]
: []
),
2026-04-22 22:08:34 +03:00
{ id : 'openchamber:compact' , name : 'compact' , source : 'openchamber' as const , description : 'Compress session history using AI to reduce context size' , isBuiltIn : true },
2026-04-22 21:56:35 +03:00
...( hasSession
2026-04-22 22:08:34 +03:00
? [{ id : 'openchamber:summary' , name : 'summary' , source : 'openchamber' as const , description : 'Non-destructive session summary. Optional topic hint after the command.' , isOpenChamber : true }]
2026-04-22 21:56:35 +03:00
: []
),
2025-12-07 19:32:53 +02:00
];
const filtered = ( searchQuery
? builtInCommands . filter ( cmd =>
2026-01-08 20:49:22 +02:00
fuzzyMatch ( cmd . name , searchQuery ) ||
( cmd . description && fuzzyMatch ( cmd . description , searchQuery ))
2025-12-07 19:32:53 +02:00
)
: builtInCommands ). filter ( cmd => allowInitCommand || cmd . name !== 'init' );
setCommands ( filtered );
} finally {
setLoading ( false );
}
};
loadCommands ();
2026-02-18 20:08:42 +02:00
}, [ searchQuery , hasMessagesInCurrentSession , hasSession , commandsWithMetadata , skills ]);
2025-12-07 19:32:53 +02:00
React . useEffect (() => {
setSelectedIndex ( 0 );
}, [ commands ]);
React . useEffect (() => {
itemRefs . current [ selectedIndex ] ? . scrollIntoView ({
behavior : 'smooth' ,
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' ) {
setSelectedIndex (( prev ) => ( prev + 1 ) % total );
return ;
}
if ( key === 'ArrowUp' ) {
setSelectedIndex (( prev ) => ( prev - 1 + total ) % total );
return ;
}
if ( key === 'Enter' || key === 'Tab' ) {
const safeIndex = (( selectedIndex % total ) + total ) % total ;
const command = commands [ safeIndex ];
if ( command ) {
onCommandSelect ( command );
}
}
}
}), [ commands , selectedIndex , onClose , onCommandSelect ]);
const getCommandIcon = ( command : CommandInfo ) => {
switch ( command . name ) {
case 'init' :
return < RiFileLine className = "h-3.5 w-3.5 text-green-500" />;
2026-01-02 18:33:35 -05:00
case 'undo' :
return < RiArrowGoBackLine className = "h-3.5 w-3.5 text-orange-500" />;
case 'redo' :
return < RiArrowGoForwardLine className = "h-3.5 w-3.5 text-orange-500" />;
2026-01-08 21:10:38 +02:00
case 'compact' :
2025-12-07 19:32:53 +02:00
return < RiScissorsLine className = "h-3.5 w-3.5 text-purple-500" />;
case 'test' :
case 'build' :
case 'run' :
return < RiTerminalBoxLine className = "h-3.5 w-3.5 text-cyan-500" />;
default :
if ( command . isBuiltIn ) {
return < RiFlashlightLine className = "h-3.5 w-3.5 text-yellow-500" />;
}
return < RiCommandLine className = "h-3.5 w-3.5 text-muted-foreground" />;
}
};
return (
< div
ref = { containerRef }
2026-02-25 14:32:23 +02:00
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"
2026-02-23 05:04:25 +07:00
style = { style }
2025-12-07 19:32:53 +02:00
>
2026-02-04 01:14:10 -08:00
{ 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 : 'Commands' },
{ id : 'agents' as const , label : 'Agents' },
{ id : 'files' as const , label : '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
2026-02-25 14:32:23 +02:00
? 'bg-interactive-selection text-interactive-selection-foreground shadow-none'
2026-02-04 01:14:10 -08:00
: '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" >
2025-12-07 19:32:53 +02:00
{ loading ? (
< div className = "flex items-center justify-center py-4" >
< RiRefreshLine className = "h-4 w-4 animate-spin text-muted-foreground" />
</ div >
) : (
< div >
2026-01-08 20:19:33 +02:00
{ commands . map (( command , index ) => {
const isSystem = command . isBuiltIn ;
2026-04-22 21:56:35 +03:00
const isOpenChamberBadge = command . isOpenChamber ;
2026-01-08 20:19:33 +02:00
const isProject = command . scope === 'project' ;
return (
< div
2026-04-22 22:08:34 +03:00
key = { command . id }
2026-01-08 20:19:33 +02:00
ref = {( el ) => { itemRefs . current [ index ] = el ; }}
className = { cn (
"flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg" ,
2026-02-01 18:29:34 +02:00
index === selectedIndex && "bg-interactive-selection"
2026-01-08 20:19:33 +02:00
)}
2026-02-04 01:14:10 -08:00
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 );
}}
2026-01-08 20:19:33 +02:00
onMouseEnter = {() => setSelectedIndex ( index )}
>
< div className = "mt-0.5" >
{ getCommandIcon ( command )}
2025-12-07 19:32:53 +02:00
</ div >
2026-01-08 20:19:33 +02:00
< div className = "flex-1 min-w-0" >
< div className = "flex items-center gap-2" >
< span className = "typography-ui-label font-medium" > / { command . name }</ span >
2026-02-18 20:08:42 +02:00
{ command . isSkill ? (
< span className = "text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-info-background)] text-[var(--status-info)] border-[var(--status-info-border)] px-1.5 py-1 rounded border flex-shrink-0" >
skill
</ span >
) : null }
2026-04-22 21:56:35 +03:00
{ isOpenChamberBadge ? (
< span
className = "text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0"
style = {{
backgroundColor : 'color-mix(in srgb, var(--primary-base) 14%, transparent)' ,
color : 'var(--primary-base)' ,
borderColor : 'color-mix(in srgb, var(--primary-base) 28%, transparent)' ,
}}
>
openchamber
</ span >
) : isSystem ? (
2026-01-08 20:19:33 +02:00
< span className = "text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border-[var(--status-warning-border)] px-1.5 py-1 rounded border flex-shrink-0" >
system
</ span >
) : command . scope ? (
< span className = { cn (
"text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0" ,
isProject
? "bg-[var(--status-info-background)] text-[var(--status-info)] border-[var(--status-info-border)]"
: "bg-[var(--status-success-background)] text-[var(--status-success)] border-[var(--status-success-border)]"
)}>
{ command . scope }
</ span >
) : null }
{ command . agent && (
< span className = "text-[10px] leading-none font-bold tracking-tight bg-[var(--surface-subtle)] text-[var(--surface-foreground)] border-[var(--interactive-border)] px-1.5 py-1 rounded border flex-shrink-0" >
{ command . agent }
</ span >
)}
2025-12-07 19:32:53 +02:00
</ div >
2026-01-08 20:19:33 +02:00
{ command . description && (
< div className = "typography-meta text-muted-foreground mt-0.5 truncate" >
{ command . description }
</ div >
)}
</ div >
2025-12-07 19:32:53 +02:00
</ div >
2026-01-08 20:19:33 +02:00
);
})}
2025-12-07 19:32:53 +02:00
{ commands . length === 0 && (
< div className = "px-3 py-2 typography-ui-label text-muted-foreground" >
No commands found
</ div >
)}
</ div >
)}
</ ScrollableOverlay >
< div className = "px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground" >
↑↓ navigate • Enter select • Esc close
</ div >
</ div >
);
});
CommandAutocomplete . displayName = 'CommandAutocomplete' ;