11import React from "react"
2- import { ChevronUp , Check } from "lucide-react"
2+ import { ChevronUp , Check , X } from "lucide-react"
33import { cn } from "@/lib/utils"
44import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
55import { Popover , PopoverContent , PopoverTrigger , StandardTooltip } from "@/components/ui"
@@ -11,6 +11,10 @@ import { Mode, getAllModes } from "@roo/modes"
1111import { ModeConfig , CustomModePrompts } from "@roo-code/types"
1212import { telemetryClient } from "@/utils/TelemetryClient"
1313import { TelemetryEventName } from "@roo-code/types"
14+ import { Fzf } from "fzf"
15+
16+ // Minimum number of modes required to show search functionality
17+ const SEARCH_THRESHOLD = 6
1418
1519interface ModeSelectorProps {
1620 value : Mode
@@ -21,6 +25,7 @@ interface ModeSelectorProps {
2125 modeShortcutText : string
2226 customModes ?: ModeConfig [ ]
2327 customModePrompts ?: CustomModePrompts
28+ disableSearch ?: boolean
2429}
2530
2631export const ModeSelector = ( {
@@ -32,13 +37,16 @@ export const ModeSelector = ({
3237 modeShortcutText,
3338 customModes,
3439 customModePrompts,
40+ disableSearch = false ,
3541} : ModeSelectorProps ) => {
3642 const [ open , setOpen ] = React . useState ( false )
43+ const [ searchValue , setSearchValue ] = React . useState ( "" )
44+ const searchInputRef = React . useRef < HTMLInputElement > ( null )
3745 const portalContainer = useRooPortal ( "roo-portal" )
3846 const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState ( )
3947 const { t } = useAppTranslation ( )
4048
41- const trackModeSelectorOpened = ( ) => {
49+ const trackModeSelectorOpened = React . useCallback ( ( ) => {
4250 // Track telemetry every time the mode selector is opened
4351 telemetryClient . capture ( TelemetryEventName . MODE_SELECTOR_OPENED )
4452
@@ -47,7 +55,7 @@ export const ModeSelector = ({
4755 setHasOpenedModeSelector ( true )
4856 vscode . postMessage ( { type : "hasOpenedModeSelector" , bool : true } )
4957 }
50- }
58+ } , [ hasOpenedModeSelector , setHasOpenedModeSelector ] )
5159
5260 // Get all modes including custom modes and merge custom prompt descriptions
5361 const modes = React . useMemo ( ( ) => {
@@ -61,6 +69,96 @@ export const ModeSelector = ({
6169 // Find the selected mode
6270 const selectedMode = React . useMemo ( ( ) => modes . find ( ( mode ) => mode . slug === value ) , [ modes , value ] )
6371
72+ // Memoize searchable items for fuzzy search with separate name and description search
73+ const nameSearchItems = React . useMemo ( ( ) => {
74+ return modes . map ( ( mode ) => ( {
75+ original : mode ,
76+ searchStr : [ mode . name , mode . slug ] . filter ( Boolean ) . join ( " " ) ,
77+ } ) )
78+ } , [ modes ] )
79+
80+ const descriptionSearchItems = React . useMemo ( ( ) => {
81+ return modes . map ( ( mode ) => ( {
82+ original : mode ,
83+ searchStr : mode . description || "" ,
84+ } ) )
85+ } , [ modes ] )
86+
87+ // Create memoized Fzf instances for name and description searches
88+ const nameFzfInstance = React . useMemo ( ( ) => {
89+ return new Fzf ( nameSearchItems , {
90+ selector : ( item ) => item . searchStr ,
91+ } )
92+ } , [ nameSearchItems ] )
93+
94+ const descriptionFzfInstance = React . useMemo ( ( ) => {
95+ return new Fzf ( descriptionSearchItems , {
96+ selector : ( item ) => item . searchStr ,
97+ } )
98+ } , [ descriptionSearchItems ] )
99+
100+ // Filter modes based on search value using fuzzy search with priority
101+ const filteredModes = React . useMemo ( ( ) => {
102+ if ( ! searchValue ) return modes
103+
104+ // First search in names/slugs
105+ const nameMatches = nameFzfInstance . find ( searchValue )
106+ const nameMatchedModes = new Set ( nameMatches . map ( ( result ) => result . item . original . slug ) )
107+
108+ // Then search in descriptions
109+ const descriptionMatches = descriptionFzfInstance . find ( searchValue )
110+
111+ // Combine results: name matches first, then description matches
112+ const combinedResults = [
113+ ...nameMatches . map ( ( result ) => result . item . original ) ,
114+ ...descriptionMatches
115+ . filter ( ( result ) => ! nameMatchedModes . has ( result . item . original . slug ) )
116+ . map ( ( result ) => result . item . original ) ,
117+ ]
118+
119+ return combinedResults
120+ } , [ modes , searchValue , nameFzfInstance , descriptionFzfInstance ] )
121+
122+ const onClearSearch = React . useCallback ( ( ) => {
123+ setSearchValue ( "" )
124+ searchInputRef . current ?. focus ( )
125+ } , [ ] )
126+
127+ const handleSelect = React . useCallback (
128+ ( modeSlug : string ) => {
129+ onChange ( modeSlug as Mode )
130+ setOpen ( false )
131+ // Clear search after selection
132+ setSearchValue ( "" )
133+ } ,
134+ [ onChange ] ,
135+ )
136+
137+ const onOpenChange = React . useCallback (
138+ ( isOpen : boolean ) => {
139+ if ( isOpen ) trackModeSelectorOpened ( )
140+ setOpen ( isOpen )
141+ // Clear search when closing
142+ if ( ! isOpen ) {
143+ setSearchValue ( "" )
144+ }
145+ } ,
146+ [ trackModeSelectorOpened ] ,
147+ )
148+
149+ // Auto-focus search input when popover opens
150+ React . useEffect ( ( ) => {
151+ if ( open && searchInputRef . current ) {
152+ searchInputRef . current . focus ( )
153+ }
154+ } , [ open ] )
155+
156+ // Determine if search should be shown
157+ const showSearch = ! disableSearch && modes . length > SEARCH_THRESHOLD
158+
159+ // Combine instruction text for tooltip
160+ const instructionText = `${ t ( "chat:modeSelector.description" ) } ${ modeShortcutText } `
161+
64162 const trigger = (
65163 < PopoverTrigger
66164 disabled = { disabled }
@@ -83,13 +181,7 @@ export const ModeSelector = ({
83181 )
84182
85183 return (
86- < Popover
87- open = { open }
88- onOpenChange = { ( isOpen ) => {
89- if ( isOpen ) trackModeSelectorOpened ( )
90- setOpen ( isOpen )
91- } }
92- data-testid = "mode-selector-root" >
184+ < Popover open = { open } onOpenChange = { onOpenChange } data-testid = "mode-selector-root" >
93185 { title ? < StandardTooltip content = { title } > { trigger } </ StandardTooltip > : trigger }
94186
95187 < PopoverContent
@@ -98,78 +190,110 @@ export const ModeSelector = ({
98190 container = { portalContainer }
99191 className = "p-0 overflow-hidden min-w-80 max-w-9/10" >
100192 < div className = "flex flex-col w-full" >
101- < div className = "p-3 border-b border-vscode-dropdown-border cursor-default" >
102- < div className = "flex flex-row items-center gap-1 p-0 mt-0 mb-1 w-full" >
103- < h4 className = "m-0 pb-2 flex-1" > { t ( "chat:modeSelector.title" ) } </ h4 >
104- < div className = "flex flex-row gap-1 ml-auto mb-1" >
105- < IconButton
106- iconClass = "codicon-extensions"
107- title = { t ( "chat:modeSelector.marketplace" ) }
108- onClick = { ( ) => {
109- window . postMessage (
110- {
111- type : "action" ,
112- action : "marketplaceButtonClicked" ,
113- values : { marketplaceTab : "mode" } ,
114- } ,
115- "*" ,
116- )
117-
118- setOpen ( false )
119- } }
120- />
121- < IconButton
122- iconClass = "codicon-settings-gear"
123- title = { t ( "chat:modeSelector.settings" ) }
124- onClick = { ( ) => {
125- vscode . postMessage ( {
126- type : "switchTab" ,
127- tab : "modes" ,
128- } )
129- setOpen ( false )
130- } }
131- />
132- </ div >
193+ { /* Show search bar only when there are more than SEARCH_THRESHOLD items, otherwise show info blurb */ }
194+ { showSearch ? (
195+ < div className = "relative p-2 border-b border-vscode-dropdown-border" >
196+ < input
197+ aria-label = "Search modes"
198+ ref = { searchInputRef }
199+ value = { searchValue }
200+ onChange = { ( e ) => setSearchValue ( e . target . value ) }
201+ placeholder = { t ( "chat:modeSelector.searchPlaceholder" ) }
202+ className = "w-full h-8 px-2 py-1 text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded focus:outline-0"
203+ data-testid = "mode-search-input"
204+ />
205+ { searchValue . length > 0 && (
206+ < div className = "absolute right-4 top-0 bottom-0 flex items-center justify-center" >
207+ < X
208+ className = "text-vscode-input-foreground opacity-50 hover:opacity-100 size-4 p-0.5 cursor-pointer"
209+ onClick = { onClearSearch }
210+ />
211+ </ div >
212+ ) }
133213 </ div >
134- < p className = "my-0 pr-4 text-sm w-full" >
135- { t ( "chat:modeSelector.description" ) }
136- < br />
137- { modeShortcutText }
138- </ p >
139- </ div >
214+ ) : (
215+ < div className = "p-3 border-b border-vscode-dropdown-border" >
216+ < p className = "m-0 text-xs text-vscode-descriptionForeground" > { instructionText } </ p >
217+ </ div >
218+ ) }
140219
141220 { /* Mode List */ }
142- < div className = "max-h-[400px] overflow-y-auto py-0" >
143- { modes . map ( ( mode ) => (
144- < div
145- className = { cn (
146- "p-2 text-sm cursor-pointer flex flex-row gap-4 items-center" ,
147- "hover:bg-vscode-list-hoverBackground" ,
148- mode . slug === value
149- ? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
150- : "" ,
151- ) }
152- key = { mode . slug }
221+ < div className = "max-h-[300px] overflow-y-auto" >
222+ { filteredModes . length === 0 && searchValue ? (
223+ < div className = "py-2 px-3 text-sm text-vscode-foreground/70" >
224+ { t ( "chat:modeSelector.noResults" ) }
225+ </ div >
226+ ) : (
227+ < div className = "py-1" >
228+ { filteredModes . map ( ( mode ) => (
229+ < div
230+ key = { mode . slug }
231+ onClick = { ( ) => handleSelect ( mode . slug ) }
232+ className = { cn (
233+ "px-3 py-1.5 text-sm cursor-pointer flex items-center" ,
234+ "hover:bg-vscode-list-hoverBackground" ,
235+ mode . slug === value
236+ ? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
237+ : "" ,
238+ ) }
239+ data-testid = "mode-selector-item" >
240+ < div className = "flex-1 min-w-0" >
241+ < div className = "font-bold truncate" > { mode . name } </ div >
242+ { mode . description && (
243+ < div className = "text-xs text-vscode-descriptionForeground truncate" >
244+ { mode . description }
245+ </ div >
246+ ) }
247+ </ div >
248+ { mode . slug === value && < Check className = "ml-auto size-4 p-0.5" /> }
249+ </ div >
250+ ) ) }
251+ </ div >
252+ ) }
253+ </ div >
254+
255+ { /* Bottom bar with buttons on left and title on right */ }
256+ < div className = "flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border" >
257+ < div className = "flex flex-row gap-1" >
258+ < IconButton
259+ iconClass = "codicon-extensions"
260+ title = { t ( "chat:modeSelector.marketplace" ) }
153261 onClick = { ( ) => {
154- onChange ( mode . slug as Mode )
262+ window . postMessage (
263+ {
264+ type : "action" ,
265+ action : "marketplaceButtonClicked" ,
266+ values : { marketplaceTab : "mode" } ,
267+ } ,
268+ "*" ,
269+ )
155270 setOpen ( false )
156271 } }
157- data-testid = "mode-selector-item" >
158- < div className = "flex-grow" >
159- < p className = "m-0 mb-0 font-bold" > { mode . name } </ p >
160- { mode . description && (
161- < p className = "m-0 py-0 pl-4 h-4 flex-1 text-xs overflow-hidden" >
162- { mode . description }
163- </ p >
164- ) }
165- </ div >
166- { mode . slug === value ? (
167- < Check className = "m-0 size-4 p-0.5" />
168- ) : (
169- < div className = "size-4" />
170- ) }
171- </ div >
172- ) ) }
272+ />
273+ < IconButton
274+ iconClass = "codicon-settings-gear"
275+ title = { t ( "chat:modeSelector.settings" ) }
276+ onClick = { ( ) => {
277+ vscode . postMessage ( {
278+ type : "switchTab" ,
279+ tab : "modes" ,
280+ } )
281+ setOpen ( false )
282+ } }
283+ />
284+ </ div >
285+
286+ { /* Info icon and title on the right - only show info icon when search bar is visible */ }
287+ < div className = "flex items-center gap-1 pr-1" >
288+ { showSearch && (
289+ < StandardTooltip content = { instructionText } >
290+ < span className = "codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
291+ </ StandardTooltip >
292+ ) }
293+ < h4 className = "m-0 font-medium text-sm text-vscode-descriptionForeground" >
294+ { t ( "chat:modeSelector.title" ) }
295+ </ h4 >
296+ </ div >
173297 </ div >
174298 </ div >
175299 </ PopoverContent >
0 commit comments