import React from "react" import { Fzf } from "fzf" import { Check, X } from "lucide-react" import { type ModeConfig, type CustomModePrompts, TelemetryEventName } from "@roo-code/types" import { type Mode, getAllModes, defaultModeSlug } from "@roo/modes" import { vscode } from "@/utils/vscode" import { telemetryClient } from "@/utils/TelemetryClient" import { cn } from "@/lib/utils" import { useExtensionState } from "@/context/ExtensionStateContext" import { useAppTranslation } from "@/i18n/TranslationContext" import { useRooPortal } from "@/components/ui/hooks/useRooPortal" import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui" import { IconButton } from "./IconButton" const SEARCH_THRESHOLD = 6 interface ModeSelectorProps { value: Mode onChange: (value: Mode) => void disabled?: boolean title: string triggerClassName?: string modeShortcutText: string customModes?: ModeConfig[] customModePrompts?: CustomModePrompts disableSearch?: boolean } export const ModeSelector = ({ value, onChange, disabled = false, title, triggerClassName = "", modeShortcutText, customModes, customModePrompts, disableSearch = false, }: ModeSelectorProps) => { const [open, setOpen] = React.useState(false) const [searchValue, setSearchValue] = React.useState("") const searchInputRef = React.useRef(null) const selectedItemRef = React.useRef(null) const scrollContainerRef = React.useRef(null) const lastNotifiedInvalidModeRef = React.useRef(null) const portalContainer = useRooPortal("roo-portal") const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState() const { t } = useAppTranslation() const trackModeSelectorOpened = React.useCallback(() => { // Track telemetry every time the mode selector is opened. telemetryClient.capture(TelemetryEventName.MODE_SELECTOR_OPENED) // Track first-time usage for UI purposes. if (!hasOpenedModeSelector) { setHasOpenedModeSelector(true) vscode.postMessage({ type: "hasOpenedModeSelector", bool: true }) } }, [hasOpenedModeSelector, setHasOpenedModeSelector]) // Get all modes including custom modes and merge custom prompt descriptions. const modes = React.useMemo(() => { const allModes = getAllModes(customModes) return allModes.map((mode) => ({ ...mode, description: customModePrompts?.[mode.slug]?.description ?? mode.description, })) }, [customModes, customModePrompts]) // Find the selected mode, falling back to default if current mode doesn't exist (e.g., after workspace switch) const selectedMode = React.useMemo(() => { return modes.find((mode) => mode.slug === value) ?? modes.find((mode) => mode.slug === defaultModeSlug) }, [modes, value]) // Notify parent when current mode is invalid so it can update its state React.useEffect(() => { const isValidMode = modes.some((mode) => mode.slug === value) if (isValidMode) { lastNotifiedInvalidModeRef.current = null return } if (lastNotifiedInvalidModeRef.current === value) { return } const fallbackMode = modes.find((mode) => mode.slug === defaultModeSlug) if (fallbackMode) { lastNotifiedInvalidModeRef.current = value onChange(fallbackMode.slug as Mode) } // eslint-disable-next-line react-hooks/exhaustive-deps -- onChange omitted to prevent loops when parent doesn't memoize }, [modes, value]) // Memoize searchable items for fuzzy search with separate name and // description search. const nameSearchItems = React.useMemo(() => { return modes.map((mode) => ({ original: mode, searchStr: [mode.name, mode.slug].filter(Boolean).join(" "), })) }, [modes]) const descriptionSearchItems = React.useMemo(() => { return modes.map((mode) => ({ original: mode, searchStr: mode.description || "", })) }, [modes]) // Create memoized Fzf instances for name and description searches. const nameFzfInstance = React.useMemo( () => new Fzf(nameSearchItems, { selector: (item) => item.searchStr }), [nameSearchItems], ) const descriptionFzfInstance = React.useMemo( () => new Fzf(descriptionSearchItems, { selector: (item) => item.searchStr }), [descriptionSearchItems], ) // Filter modes based on search value using fuzzy search with priority. const filteredModes = React.useMemo(() => { if (!searchValue) return modes // First search in names/slugs. const nameMatches = nameFzfInstance.find(searchValue) const nameMatchedModes = new Set(nameMatches.map((result) => result.item.original.slug)) // Then search in descriptions. const descriptionMatches = descriptionFzfInstance.find(searchValue) // Combine results: name matches first, then description matches. const combinedResults = [ ...nameMatches.map((result) => result.item.original), ...descriptionMatches .filter((result) => !nameMatchedModes.has(result.item.original.slug)) .map((result) => result.item.original), ] return combinedResults }, [modes, searchValue, nameFzfInstance, descriptionFzfInstance]) const onClearSearch = React.useCallback(() => { setSearchValue("") searchInputRef.current?.focus() }, []) const handleSelect = React.useCallback( (modeSlug: string) => { onChange(modeSlug as Mode) setOpen(false) // Clear search after selection. setSearchValue("") }, [onChange], ) const onOpenChange = React.useCallback( (isOpen: boolean) => { if (isOpen) trackModeSelectorOpened() setOpen(isOpen) // Clear search when closing. if (!isOpen) { setSearchValue("") } }, [trackModeSelectorOpened], ) // Auto-focus search input and scroll to selected item when popover opens. React.useEffect(() => { if (open) { // Focus search input if (searchInputRef.current) { searchInputRef.current.focus() } requestAnimationFrame(() => { if (selectedItemRef.current && scrollContainerRef.current) { const container = scrollContainerRef.current const item = selectedItemRef.current // Calculate positions const containerHeight = container.clientHeight const itemTop = item.offsetTop const itemHeight = item.offsetHeight // Center the item in the container const scrollPosition = itemTop - containerHeight / 2 + itemHeight / 2 // Ensure we don't scroll past boundaries const maxScroll = container.scrollHeight - containerHeight const finalScrollPosition = Math.min(Math.max(0, scrollPosition), maxScroll) container.scrollTo({ top: finalScrollPosition, behavior: "instant", }) } }) } }, [open]) // Determine if search should be shown. const showSearch = !disableSearch && modes.length > SEARCH_THRESHOLD // Combine instruction text for tooltip. const instructionText = `${t("chat:modeSelector.description")} ${modeShortcutText}` return ( {selectedMode?.name || ""}
{/* Show search bar only when there are more than SEARCH_THRESHOLD items, otherwise show info blurb */} {showSearch ? (
setSearchValue(e.target.value)} placeholder={t("chat:modeSelector.searchPlaceholder")} 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" data-testid="mode-search-input" /> {searchValue.length > 0 && (
)}
) : (

{instructionText}

)} {/* Mode List */}
{filteredModes.length === 0 && searchValue ? (
{t("chat:modeSelector.noResults")}
) : (
{filteredModes.map((mode) => { const isSelected = mode.slug === value return (
handleSelect(mode.slug)} className={cn( "px-3 py-1.5 text-sm cursor-pointer flex items-center", "hover:bg-vscode-list-hoverBackground", isSelected ? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground" : "", )} data-testid="mode-selector-item">
{mode.name}
{mode.description && (
{mode.description}
)}
{isSelected && }
) })}
)}
{/* Bottom bar with buttons on left and title on right */}
{ window.postMessage( { type: "action", action: "marketplaceButtonClicked", values: { marketplaceTab: "mode" }, }, "*", ) setOpen(false) }} /> { vscode.postMessage({ type: "switchTab", tab: "settings", values: { section: "modes" }, }) setOpen(false) }} />
{/* Info icon and title on the right - only show info icon when search bar is visible */}
{showSearch && ( )}

{t("chat:modeSelector.title")}

) }