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,7 @@ 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"
1415
1516interface ModeSelectorProps {
1617 value : Mode
@@ -34,11 +35,13 @@ export const ModeSelector = ({
3435 customModePrompts,
3536} : ModeSelectorProps ) => {
3637 const [ open , setOpen ] = React . useState ( false )
38+ const [ searchValue , setSearchValue ] = React . useState ( "" )
39+ const searchInputRef = React . useRef < HTMLInputElement > ( null )
3740 const portalContainer = useRooPortal ( "roo-portal" )
3841 const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState ( )
3942 const { t } = useAppTranslation ( )
4043
41- const trackModeSelectorOpened = ( ) => {
44+ const trackModeSelectorOpened = React . useCallback ( ( ) => {
4245 // Track telemetry every time the mode selector is opened
4346 telemetryClient . capture ( TelemetryEventName . MODE_SELECTOR_OPENED )
4447
@@ -47,7 +50,7 @@ export const ModeSelector = ({
4750 setHasOpenedModeSelector ( true )
4851 vscode . postMessage ( { type : "hasOpenedModeSelector" , bool : true } )
4952 }
50- }
53+ } , [ hasOpenedModeSelector , setHasOpenedModeSelector ] )
5154
5255 // Get all modes including custom modes and merge custom prompt descriptions
5356 const modes = React . useMemo ( ( ) => {
@@ -61,6 +64,59 @@ export const ModeSelector = ({
6164 // Find the selected mode
6265 const selectedMode = React . useMemo ( ( ) => modes . find ( ( mode ) => mode . slug === value ) , [ modes , value ] )
6366
67+ // Memoize searchable items for fuzzy search
68+ const searchableItems = React . useMemo ( ( ) => {
69+ return modes . map ( ( mode ) => ( {
70+ original : mode ,
71+ searchStr : [ mode . name , mode . slug , mode . description ] . filter ( Boolean ) . join ( " " ) ,
72+ } ) )
73+ } , [ modes ] )
74+
75+ // Create a memoized Fzf instance
76+ const fzfInstance = React . useMemo ( ( ) => {
77+ return new Fzf ( searchableItems , {
78+ selector : ( item ) => item . searchStr ,
79+ } )
80+ } , [ searchableItems ] )
81+
82+ // Filter modes based on search value using fuzzy search
83+ const filteredModes = React . useMemo ( ( ) => {
84+ if ( ! searchValue ) return modes
85+
86+ const matchingItems = fzfInstance . find ( searchValue ) . map ( ( result ) => result . item . original )
87+ return matchingItems
88+ } , [ modes , searchValue , fzfInstance ] )
89+
90+ const onClearSearch = React . useCallback ( ( ) => {
91+ setSearchValue ( "" )
92+ searchInputRef . current ?. focus ( )
93+ } , [ ] )
94+
95+ const handleSelect = React . useCallback (
96+ ( modeSlug : string ) => {
97+ onChange ( modeSlug as Mode )
98+ setOpen ( false )
99+ // Clear search after selection
100+ requestAnimationFrame ( ( ) => setSearchValue ( "" ) )
101+ } ,
102+ [ onChange ] ,
103+ )
104+
105+ const onOpenChange = React . useCallback (
106+ ( isOpen : boolean ) => {
107+ if ( isOpen ) trackModeSelectorOpened ( )
108+ setOpen ( isOpen )
109+ // Clear search when closing
110+ if ( ! isOpen ) {
111+ requestAnimationFrame ( ( ) => setSearchValue ( "" ) )
112+ }
113+ } ,
114+ [ trackModeSelectorOpened ] ,
115+ )
116+
117+ // Combine instruction text for tooltip
118+ const instructionText = `${ t ( "chat:modeSelector.description" ) } ${ modeShortcutText } `
119+
64120 const trigger = (
65121 < PopoverTrigger
66122 disabled = { disabled }
@@ -83,13 +139,7 @@ export const ModeSelector = ({
83139 )
84140
85141 return (
86- < Popover
87- open = { open }
88- onOpenChange = { ( isOpen ) => {
89- if ( isOpen ) trackModeSelectorOpened ( )
90- setOpen ( isOpen )
91- } }
92- data-testid = "mode-selector-root" >
142+ < Popover open = { open } onOpenChange = { onOpenChange } data-testid = "mode-selector-root" >
93143 { title ? < StandardTooltip content = { title } > { trigger } </ StandardTooltip > : trigger }
94144
95145 < PopoverContent
@@ -98,78 +148,102 @@ export const ModeSelector = ({
98148 container = { portalContainer }
99149 className = "p-0 overflow-hidden min-w-80 max-w-9/10" >
100150 < 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- } }
151+ { /* Search input only */ }
152+ < div className = "relative p-2 border-b border-vscode-dropdown-border" >
153+ < input
154+ aria-label = "Search modes"
155+ ref = { searchInputRef }
156+ value = { searchValue }
157+ onChange = { ( e ) => setSearchValue ( e . target . value ) }
158+ placeholder = "Search modes..."
159+ 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"
160+ data-testid = "mode-search-input"
161+ />
162+ { searchValue . length > 0 && (
163+ < div className = "absolute right-4 top-0 bottom-0 flex items-center justify-center" >
164+ < X
165+ className = "text-vscode-input-foreground opacity-50 hover:opacity-100 size-4 p-0.5 cursor-pointer"
166+ onClick = { onClearSearch }
131167 />
132168 </ div >
133- </ div >
134- < p className = "my-0 pr-4 text-sm w-full" >
135- { t ( "chat:modeSelector.description" ) }
136- < br />
137- { modeShortcutText }
138- </ p >
169+ ) }
139170 </ div >
140171
141172 { /* 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 }
173+ < div className = "max-h-[300px] overflow-y-auto" >
174+ { filteredModes . length === 0 && searchValue ? (
175+ < div className = "py-2 px-3 text-sm text-vscode-foreground/70" >
176+ { t ( "settings:modelPicker.noMatchFound" ) }
177+ </ div >
178+ ) : (
179+ < div className = "py-1" >
180+ { filteredModes . map ( ( mode ) => (
181+ < div
182+ key = { mode . slug }
183+ onClick = { ( ) => handleSelect ( mode . slug ) }
184+ className = { cn (
185+ "px-3 py-1.5 text-sm cursor-pointer flex items-center" ,
186+ "hover:bg-vscode-list-hoverBackground" ,
187+ mode . slug === value
188+ ? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
189+ : "" ,
190+ ) }
191+ data-testid = "mode-selector-item" >
192+ < div className = "flex-1 min-w-0" >
193+ < div className = "font-bold truncate" > { mode . name } </ div >
194+ { mode . description && (
195+ < div className = "text-xs text-vscode-descriptionForeground truncate" >
196+ { mode . description }
197+ </ div >
198+ ) }
199+ </ div >
200+ { mode . slug === value && < Check className = "ml-auto size-4 p-0.5" /> }
201+ </ div >
202+ ) ) }
203+ </ div >
204+ ) }
205+ </ div >
206+
207+ { /* Bottom bar with buttons on left and title on right */ }
208+ < div className = "flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border" >
209+ < div className = "flex flex-row gap-1" >
210+ < IconButton
211+ iconClass = "codicon-extensions"
212+ title = { t ( "chat:modeSelector.marketplace" ) }
153213 onClick = { ( ) => {
154- onChange ( mode . slug as Mode )
214+ window . postMessage (
215+ {
216+ type : "action" ,
217+ action : "marketplaceButtonClicked" ,
218+ values : { marketplaceTab : "mode" } ,
219+ } ,
220+ "*" ,
221+ )
155222 setOpen ( false )
156223 } }
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- ) ) }
224+ />
225+ < IconButton
226+ iconClass = "codicon-settings-gear"
227+ title = { t ( "chat:modeSelector.settings" ) }
228+ onClick = { ( ) => {
229+ vscode . postMessage ( {
230+ type : "switchTab" ,
231+ tab : "modes" ,
232+ } )
233+ setOpen ( false )
234+ } }
235+ />
236+ </ div >
237+
238+ { /* Info icon and title on the right with matching spacing */ }
239+ < div className = "flex items-center gap-1" >
240+ < StandardTooltip content = { instructionText } >
241+ < span className = "codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
242+ </ StandardTooltip >
243+ < h4 className = "m-0 font-medium text-sm text-vscode-descriptionForeground" >
244+ { t ( "chat:modeSelector.title" ) }
245+ </ h4 >
246+ </ div >
173247 </ div >
174248 </ div >
175249 </ PopoverContent >
0 commit comments