11// ========================================
22// Command Combobox Component
33// ========================================
4- // Searchable dropdown for selecting slash commands
4+ // Searchable dropdown for selecting slash commands (commands + skills)
55
66import { useState , useRef , useEffect , useCallback , useMemo } from 'react' ;
77import { ChevronDown , Search } from 'lucide-react' ;
88import { cn } from '@/lib/utils' ;
99import { useCommands } from '@/hooks/useCommands' ;
10- import type { Command } from '@/lib/api' ;
10+ import { useSkills } from '@/hooks/useSkills' ;
11+
12+ export interface CommandSelectDetails {
13+ name : string ;
14+ argumentHint ?: string ;
15+ description ?: string ;
16+ source : 'command' | 'skill' ;
17+ }
18+
19+ interface UnifiedItem {
20+ name : string ;
21+ description : string ;
22+ group : string ;
23+ argumentHint ?: string ;
24+ source : 'command' | 'skill' ;
25+ }
1126
1227interface CommandComboboxProps {
1328 value : string ;
1429 onChange : ( value : string ) => void ;
30+ onSelectDetails ?: ( details : CommandSelectDetails ) => void ;
1531 placeholder ?: string ;
1632 className ?: string ;
1733}
1834
19- export function CommandCombobox ( { value, onChange, placeholder, className } : CommandComboboxProps ) {
35+ export function CommandCombobox ( { value, onChange, onSelectDetails , placeholder, className } : CommandComboboxProps ) {
2036 const [ open , setOpen ] = useState ( false ) ;
2137 const [ search , setSearch ] = useState ( '' ) ;
2238 const containerRef = useRef < HTMLDivElement > ( null ) ;
2339 const inputRef = useRef < HTMLInputElement > ( null ) ;
2440
25- const { commands, isLoading } = useCommands ( {
41+ const { commands, isLoading : commandsLoading } = useCommands ( {
2642 filter : { showDisabled : false } ,
2743 } ) ;
2844
29- // Group commands by group field
45+ const { skills, isLoading : skillsLoading } = useSkills ( {
46+ filter : { enabledOnly : true } ,
47+ } ) ;
48+
49+ const isLoading = commandsLoading || skillsLoading ;
50+
51+ // Merge commands and skills into unified items
52+ const unifiedItems = useMemo < UnifiedItem [ ] > ( ( ) => {
53+ const items : UnifiedItem [ ] = [ ] ;
54+
55+ for ( const cmd of commands ) {
56+ items . push ( {
57+ name : cmd . name ,
58+ description : cmd . description ,
59+ group : cmd . group || 'other' ,
60+ argumentHint : cmd . argumentHint ,
61+ source : 'command' ,
62+ } ) ;
63+ }
64+
65+ for ( const skill of skills ) {
66+ items . push ( {
67+ name : skill . name ,
68+ description : skill . description ,
69+ group : 'skills' ,
70+ source : 'skill' ,
71+ } ) ;
72+ }
73+
74+ return items ;
75+ } , [ commands , skills ] ) ;
76+
77+ // Group and filter items
3078 const groupedFiltered = useMemo ( ( ) => {
3179 const filtered = search
32- ? commands . filter (
33- ( c ) =>
34- c . name . toLowerCase ( ) . includes ( search . toLowerCase ( ) ) ||
35- c . description . toLowerCase ( ) . includes ( search . toLowerCase ( ) ) ||
36- c . aliases ?. some ( ( a ) => a . toLowerCase ( ) . includes ( search . toLowerCase ( ) ) )
80+ ? unifiedItems . filter (
81+ ( item ) =>
82+ item . name . toLowerCase ( ) . includes ( search . toLowerCase ( ) ) ||
83+ item . description . toLowerCase ( ) . includes ( search . toLowerCase ( ) )
3784 )
38- : commands ;
85+ : unifiedItems ;
3986
40- const groups : Record < string , Command [ ] > = { } ;
41- for ( const cmd of filtered ) {
42- const group = cmd . group || 'other' ;
43- if ( ! groups [ group ] ) groups [ group ] = [ ] ;
44- groups [ group ] . push ( cmd ) ;
87+ const groups : Record < string , UnifiedItem [ ] > = { } ;
88+ for ( const item of filtered ) {
89+ if ( ! groups [ item . group ] ) groups [ item . group ] = [ ] ;
90+ groups [ item . group ] . push ( item ) ;
4591 }
4692 return groups ;
47- } , [ commands , search ] ) ;
93+ } , [ unifiedItems , search ] ) ;
4894
4995 const totalFiltered = useMemo (
50- ( ) => Object . values ( groupedFiltered ) . reduce ( ( sum , cmds ) => sum + cmds . length , 0 ) ,
96+ ( ) => Object . values ( groupedFiltered ) . reduce ( ( sum , items ) => sum + items . length , 0 ) ,
5197 [ groupedFiltered ]
5298 ) ;
5399
@@ -65,12 +111,18 @@ export function CommandCombobox({ value, onChange, placeholder, className }: Com
65111 } , [ open ] ) ;
66112
67113 const handleSelect = useCallback (
68- ( name : string ) => {
69- onChange ( name ) ;
114+ ( item : UnifiedItem ) => {
115+ onChange ( item . name ) ;
116+ onSelectDetails ?.( {
117+ name : item . name ,
118+ argumentHint : item . argumentHint ,
119+ description : item . description ,
120+ source : item . source ,
121+ } ) ;
70122 setOpen ( false ) ;
71123 setSearch ( '' ) ;
72124 } ,
73- [ onChange ]
125+ [ onChange , onSelectDetails ]
74126 ) ;
75127
76128 const handleInputChange = useCallback ( ( e : React . ChangeEvent < HTMLInputElement > ) => {
@@ -89,11 +141,9 @@ export function CommandCombobox({ value, onChange, placeholder, className }: Com
89141 ) ;
90142
91143 // Display label for current value
92- const selectedCommand = commands . find ( ( c ) => c . name === value ) ;
144+ const selectedItem = unifiedItems . find ( ( item ) => item . name === value ) ;
93145 const displayValue = value
94- ? selectedCommand
95- ? `/${ selectedCommand . name } `
96- : `/${ value } `
146+ ? `/${ selectedItem ?. name || value } `
97147 : '' ;
98148
99149 return (
@@ -135,7 +185,7 @@ export function CommandCombobox({ value, onChange, placeholder, className }: Com
135185 />
136186 </ div >
137187
138- { /* Command list */ }
188+ { /* Items list */ }
139189 < div className = "max-h-64 overflow-y-auto p-1" >
140190 { isLoading ? (
141191 < div className = "py-4 text-center text-sm text-muted-foreground" > Loading...</ div >
@@ -145,26 +195,31 @@ export function CommandCombobox({ value, onChange, placeholder, className }: Com
145195 </ div >
146196 ) : (
147197 Object . entries ( groupedFiltered )
148- . sort ( ( [ a ] , [ b ] ) => a . localeCompare ( b ) )
149- . map ( ( [ group , cmds ] ) => (
198+ . sort ( ( [ a ] , [ b ] ) => {
199+ // Skills group last
200+ if ( a === 'skills' ) return 1 ;
201+ if ( b === 'skills' ) return - 1 ;
202+ return a . localeCompare ( b ) ;
203+ } )
204+ . map ( ( [ group , items ] ) => (
150205 < div key = { group } >
151206 < div className = "px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider" >
152207 { group }
153208 </ div >
154- { cmds . map ( ( cmd ) => (
209+ { items . map ( ( item ) => (
155210 < button
156- key = { cmd . name }
211+ key = { ` ${ item . source } - ${ item . name } ` }
157212 type = "button"
158- onClick = { ( ) => handleSelect ( cmd . name ) }
213+ onClick = { ( ) => handleSelect ( item ) }
159214 className = { cn (
160215 'flex w-full flex-col items-start rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground' ,
161- value === cmd . name && 'bg-accent/50'
216+ value === item . name && 'bg-accent/50'
162217 ) }
163218 >
164- < span className = "font-mono text-foreground" > /{ cmd . name } </ span >
165- { cmd . description && (
219+ < span className = "font-mono text-foreground" > /{ item . name } </ span >
220+ { item . description && (
166221 < span className = "text-xs text-muted-foreground truncate w-full text-left" >
167- { cmd . description }
222+ { item . description }
168223 </ span >
169224 ) }
170225 </ button >
0 commit comments