|
|
@@ -1,5 +1,5 @@
|
|
|
import React from "react"
|
|
|
-import { ChevronUp, Check } from "lucide-react"
|
|
|
+import { ChevronUp, Check, X } from "lucide-react"
|
|
|
import { cn } from "@/lib/utils"
|
|
|
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
|
|
|
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
|
|
|
@@ -11,6 +11,10 @@ import { Mode, getAllModes } from "@roo/modes"
|
|
|
import { ModeConfig, CustomModePrompts } from "@roo-code/types"
|
|
|
import { telemetryClient } from "@/utils/TelemetryClient"
|
|
|
import { TelemetryEventName } from "@roo-code/types"
|
|
|
+import { Fzf } from "fzf"
|
|
|
+
|
|
|
+// Minimum number of modes required to show search functionality
|
|
|
+const SEARCH_THRESHOLD = 6
|
|
|
|
|
|
interface ModeSelectorProps {
|
|
|
value: Mode
|
|
|
@@ -21,6 +25,7 @@ interface ModeSelectorProps {
|
|
|
modeShortcutText: string
|
|
|
customModes?: ModeConfig[]
|
|
|
customModePrompts?: CustomModePrompts
|
|
|
+ disableSearch?: boolean
|
|
|
}
|
|
|
|
|
|
export const ModeSelector = ({
|
|
|
@@ -32,13 +37,16 @@ export const ModeSelector = ({
|
|
|
modeShortcutText,
|
|
|
customModes,
|
|
|
customModePrompts,
|
|
|
+ disableSearch = false,
|
|
|
}: ModeSelectorProps) => {
|
|
|
const [open, setOpen] = React.useState(false)
|
|
|
+ const [searchValue, setSearchValue] = React.useState("")
|
|
|
+ const searchInputRef = React.useRef<HTMLInputElement>(null)
|
|
|
const portalContainer = useRooPortal("roo-portal")
|
|
|
const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState()
|
|
|
const { t } = useAppTranslation()
|
|
|
|
|
|
- const trackModeSelectorOpened = () => {
|
|
|
+ const trackModeSelectorOpened = React.useCallback(() => {
|
|
|
// Track telemetry every time the mode selector is opened
|
|
|
telemetryClient.capture(TelemetryEventName.MODE_SELECTOR_OPENED)
|
|
|
|
|
|
@@ -47,7 +55,7 @@ export const ModeSelector = ({
|
|
|
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(() => {
|
|
|
@@ -61,6 +69,96 @@ export const ModeSelector = ({
|
|
|
// Find the selected mode
|
|
|
const selectedMode = React.useMemo(() => modes.find((mode) => mode.slug === value), [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(() => {
|
|
|
+ return new Fzf(nameSearchItems, {
|
|
|
+ selector: (item) => item.searchStr,
|
|
|
+ })
|
|
|
+ }, [nameSearchItems])
|
|
|
+
|
|
|
+ const descriptionFzfInstance = React.useMemo(() => {
|
|
|
+ return 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 when popover opens
|
|
|
+ React.useEffect(() => {
|
|
|
+ if (open && searchInputRef.current) {
|
|
|
+ searchInputRef.current.focus()
|
|
|
+ }
|
|
|
+ }, [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}`
|
|
|
+
|
|
|
const trigger = (
|
|
|
<PopoverTrigger
|
|
|
disabled={disabled}
|
|
|
@@ -83,13 +181,7 @@ export const ModeSelector = ({
|
|
|
)
|
|
|
|
|
|
return (
|
|
|
- <Popover
|
|
|
- open={open}
|
|
|
- onOpenChange={(isOpen) => {
|
|
|
- if (isOpen) trackModeSelectorOpened()
|
|
|
- setOpen(isOpen)
|
|
|
- }}
|
|
|
- data-testid="mode-selector-root">
|
|
|
+ <Popover open={open} onOpenChange={onOpenChange} data-testid="mode-selector-root">
|
|
|
{title ? <StandardTooltip content={title}>{trigger}</StandardTooltip> : trigger}
|
|
|
|
|
|
<PopoverContent
|
|
|
@@ -98,78 +190,110 @@ export const ModeSelector = ({
|
|
|
container={portalContainer}
|
|
|
className="p-0 overflow-hidden min-w-80 max-w-9/10">
|
|
|
<div className="flex flex-col w-full">
|
|
|
- <div className="p-3 border-b border-vscode-dropdown-border cursor-default">
|
|
|
- <div className="flex flex-row items-center gap-1 p-0 mt-0 mb-1 w-full">
|
|
|
- <h4 className="m-0 pb-2 flex-1">{t("chat:modeSelector.title")}</h4>
|
|
|
- <div className="flex flex-row gap-1 ml-auto mb-1">
|
|
|
- <IconButton
|
|
|
- iconClass="codicon-extensions"
|
|
|
- title={t("chat:modeSelector.marketplace")}
|
|
|
- onClick={() => {
|
|
|
- window.postMessage(
|
|
|
- {
|
|
|
- type: "action",
|
|
|
- action: "marketplaceButtonClicked",
|
|
|
- values: { marketplaceTab: "mode" },
|
|
|
- },
|
|
|
- "*",
|
|
|
- )
|
|
|
-
|
|
|
- setOpen(false)
|
|
|
- }}
|
|
|
- />
|
|
|
- <IconButton
|
|
|
- iconClass="codicon-settings-gear"
|
|
|
- title={t("chat:modeSelector.settings")}
|
|
|
- onClick={() => {
|
|
|
- vscode.postMessage({
|
|
|
- type: "switchTab",
|
|
|
- tab: "modes",
|
|
|
- })
|
|
|
- setOpen(false)
|
|
|
- }}
|
|
|
- />
|
|
|
- </div>
|
|
|
+ {/* Show search bar only when there are more than SEARCH_THRESHOLD items, otherwise show info blurb */}
|
|
|
+ {showSearch ? (
|
|
|
+ <div className="relative p-2 border-b border-vscode-dropdown-border">
|
|
|
+ <input
|
|
|
+ aria-label="Search modes"
|
|
|
+ ref={searchInputRef}
|
|
|
+ value={searchValue}
|
|
|
+ onChange={(e) => 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 && (
|
|
|
+ <div className="absolute right-4 top-0 bottom-0 flex items-center justify-center">
|
|
|
+ <X
|
|
|
+ className="text-vscode-input-foreground opacity-50 hover:opacity-100 size-4 p-0.5 cursor-pointer"
|
|
|
+ onClick={onClearSearch}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
- <p className="my-0 pr-4 text-sm w-full">
|
|
|
- {t("chat:modeSelector.description")}
|
|
|
- <br />
|
|
|
- {modeShortcutText}
|
|
|
- </p>
|
|
|
- </div>
|
|
|
+ ) : (
|
|
|
+ <div className="p-3 border-b border-vscode-dropdown-border">
|
|
|
+ <p className="m-0 text-xs text-vscode-descriptionForeground">{instructionText}</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
{/* Mode List */}
|
|
|
- <div className="max-h-[400px] overflow-y-auto py-0">
|
|
|
- {modes.map((mode) => (
|
|
|
- <div
|
|
|
- className={cn(
|
|
|
- "p-2 text-sm cursor-pointer flex flex-row gap-4 items-center",
|
|
|
- "hover:bg-vscode-list-hoverBackground",
|
|
|
- mode.slug === value
|
|
|
- ? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
|
|
|
- : "",
|
|
|
- )}
|
|
|
- key={mode.slug}
|
|
|
+ <div className="max-h-[300px] overflow-y-auto">
|
|
|
+ {filteredModes.length === 0 && searchValue ? (
|
|
|
+ <div className="py-2 px-3 text-sm text-vscode-foreground/70">
|
|
|
+ {t("chat:modeSelector.noResults")}
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div className="py-1">
|
|
|
+ {filteredModes.map((mode) => (
|
|
|
+ <div
|
|
|
+ key={mode.slug}
|
|
|
+ onClick={() => handleSelect(mode.slug)}
|
|
|
+ className={cn(
|
|
|
+ "px-3 py-1.5 text-sm cursor-pointer flex items-center",
|
|
|
+ "hover:bg-vscode-list-hoverBackground",
|
|
|
+ mode.slug === value
|
|
|
+ ? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
|
|
|
+ : "",
|
|
|
+ )}
|
|
|
+ data-testid="mode-selector-item">
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
+ <div className="font-bold truncate">{mode.name}</div>
|
|
|
+ {mode.description && (
|
|
|
+ <div className="text-xs text-vscode-descriptionForeground truncate">
|
|
|
+ {mode.description}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ {mode.slug === value && <Check className="ml-auto size-4 p-0.5" />}
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Bottom bar with buttons on left and title on right */}
|
|
|
+ <div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border">
|
|
|
+ <div className="flex flex-row gap-1">
|
|
|
+ <IconButton
|
|
|
+ iconClass="codicon-extensions"
|
|
|
+ title={t("chat:modeSelector.marketplace")}
|
|
|
onClick={() => {
|
|
|
- onChange(mode.slug as Mode)
|
|
|
+ window.postMessage(
|
|
|
+ {
|
|
|
+ type: "action",
|
|
|
+ action: "marketplaceButtonClicked",
|
|
|
+ values: { marketplaceTab: "mode" },
|
|
|
+ },
|
|
|
+ "*",
|
|
|
+ )
|
|
|
setOpen(false)
|
|
|
}}
|
|
|
- data-testid="mode-selector-item">
|
|
|
- <div className="flex-grow">
|
|
|
- <p className="m-0 mb-0 font-bold">{mode.name}</p>
|
|
|
- {mode.description && (
|
|
|
- <p className="m-0 py-0 pl-4 h-4 flex-1 text-xs overflow-hidden">
|
|
|
- {mode.description}
|
|
|
- </p>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- {mode.slug === value ? (
|
|
|
- <Check className="m-0 size-4 p-0.5" />
|
|
|
- ) : (
|
|
|
- <div className="size-4" />
|
|
|
- )}
|
|
|
- </div>
|
|
|
- ))}
|
|
|
+ />
|
|
|
+ <IconButton
|
|
|
+ iconClass="codicon-settings-gear"
|
|
|
+ title={t("chat:modeSelector.settings")}
|
|
|
+ onClick={() => {
|
|
|
+ vscode.postMessage({
|
|
|
+ type: "switchTab",
|
|
|
+ tab: "modes",
|
|
|
+ })
|
|
|
+ setOpen(false)
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Info icon and title on the right - only show info icon when search bar is visible */}
|
|
|
+ <div className="flex items-center gap-1 pr-1">
|
|
|
+ {showSearch && (
|
|
|
+ <StandardTooltip content={instructionText}>
|
|
|
+ <span className="codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
|
|
|
+ </StandardTooltip>
|
|
|
+ )}
|
|
|
+ <h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground">
|
|
|
+ {t("chat:modeSelector.title")}
|
|
|
+ </h4>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</PopoverContent>
|