diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index ee9f3b44..06c0c789 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2847,7 +2847,7 @@ dependencies = [ [[package]] name = "openchamber-desktop" -version = "1.3.9" +version = "1.4.0" dependencies = [ "anyhow", "axum", diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 6f687760..cc5e11a9 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1064,11 +1064,11 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo - {isMobile || isVSCode ? null : ( + {isMobile ? null : ( + + + ); + }; + + // Filter models based on search query + const filterByQuery = (modelName: string, providerName: string, query: string) => { + if (!query.trim()) return true; + const lowerQuery = query.toLowerCase(); + return ( + modelName.toLowerCase().includes(lowerQuery) || + providerName.toLowerCase().includes(lowerQuery) + ); + }; + + const renderModelSelector = () => { + // Filter favorites + const filteredFavorites = favoriteModelsList.filter(({ model, providerID }) => { + const provider = providers.find(p => p.id === providerID); + const providerName = provider?.name || providerID; + const modelName = getModelDisplayName(model); + return filterByQuery(modelName, providerName, desktopModelQuery); + }); + + // Filter recents + const filteredRecents = recentModelsList.filter(({ model, providerID }) => { + const provider = providers.find(p => p.id === providerID); + const providerName = provider?.name || providerID; + const modelName = getModelDisplayName(model); + return filterByQuery(modelName, providerName, desktopModelQuery); + }); + + // Filter providers and their models + const filteredProviders = providers + .map((provider) => { + const providerModels = Array.isArray(provider.models) ? provider.models : []; + const filteredModels = providerModels.filter((model: ProviderModel) => { + const modelName = getModelDisplayName(model); + return filterByQuery(modelName, provider.name || provider.id || '', desktopModelQuery); + }); + return { ...provider, models: filteredModels }; + }) + .filter((provider) => provider.models.length > 0); + + const hasResults = filteredFavorites.length > 0 || filteredRecents.length > 0 || filteredProviders.length > 0; + + // Build flat list for keyboard navigation + type FlatModelItem = { model: ProviderModel; providerID: string; modelID: string; section: string }; + const flatModelList: FlatModelItem[] = []; + + filteredFavorites.forEach(({ model, providerID, modelID }) => { + flatModelList.push({ model, providerID, modelID, section: 'fav' }); + }); + filteredRecents.forEach(({ model, providerID, modelID }) => { + flatModelList.push({ model, providerID, modelID, section: 'recent' }); + }); + filteredProviders.forEach((provider) => { + (provider.models as ProviderModel[]).forEach((model) => { + flatModelList.push({ model, providerID: provider.id as string, modelID: model.id as string, section: 'provider' }); + }); + }); + + const totalItems = flatModelList.length; + + // Handle keyboard navigation + const handleModelKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + setModelSelectedIndex((prev) => (prev + 1) % Math.max(1, totalItems)); + // Scroll into view + setTimeout(() => { + const nextIndex = (modelSelectedIndex + 1) % Math.max(1, totalItems); + modelItemRefs.current[nextIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 0); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + setModelSelectedIndex((prev) => (prev - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems)); + // Scroll into view + setTimeout(() => { + const prevIndex = (modelSelectedIndex - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems); + modelItemRefs.current[prevIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 0); + } else if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + const selectedItem = flatModelList[modelSelectedIndex]; + if (selectedItem) { + handleProviderAndModelChange(selectedItem.providerID, selectedItem.modelID); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + setAgentMenuOpen(false); + } + }; + + // Build index mapping for rendering + let currentFlatIndex = 0; + + return ( + {!isCompact ? ( @@ -1637,307 +1852,90 @@ export const ModelControls: React.FC = ({ className }) => { - - {/* Favorites Section */} - {favoriteModelsList.length > 0 && ( - - - - Favorites - - - - {favoriteModelsList.map(({ model, providerID, modelID }) => { - const metadata = getModelMetadata(providerID, modelID); - const capabilityIcons = getCapabilityIcons(metadata).map((icon) => ({ - ...icon, - id: `cap-${icon.key}`, - })); - const modalityIcons = [ - ...getModalityIcons(metadata, 'input'), - ...getModalityIcons(metadata, 'output'), - ]; - const uniqueModalityIcons = Array.from( - new Map(modalityIcons.map((icon) => [icon.key, icon])).values() - ).map((icon) => ({ ...icon, id: `mod-${icon.key}` })); - const indicatorIcons = [...capabilityIcons, ...uniqueModalityIcons]; - const contextTokens = formatTokens(metadata?.limit?.context); - const outputTokens = formatTokens(metadata?.limit?.output); + + {/* Search Input */} +
+
+ + setDesktopModelQuery(e.target.value)} + onKeyDown={handleModelKeyDown} + className="pl-8 h-8 typography-meta" + autoFocus + /> +
+
- return ( - { - handleProviderAndModelChange(providerID, modelID); - }} - > -
-
- - {getModelDisplayName(model)} - - {metadata?.limit?.context || metadata?.limit?.output ? ( - - {metadata?.limit?.context ? `${contextTokens} ctx` : ''} - {metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''} - {metadata?.limit?.output ? `${outputTokens} out` : ''} - - ) : null} -
-
- {indicatorIcons.map(({ id, icon: Icon, label }) => ( - - - - ))} - -
-
-
- ); + {/* Scrollable content */} + +
+ {!hasResults && ( +
+ No models found +
+ )} + + {/* Favorites Section */} + {filteredFavorites.length > 0 && ( + <> + + + Favorites + + {filteredFavorites.map(({ model, providerID, modelID }) => { + const idx = currentFlatIndex++; + return renderModelRow(model, providerID, modelID, 'fav', idx, modelSelectedIndex === idx); })} - - - - )} - - {/* Recents Section */} - {recentModelsList.length > 0 && ( - - - - Recent - - - - {recentModelsList.map(({ model, providerID, modelID }) => { - const metadata = getModelMetadata(providerID, modelID); - const capabilityIcons = getCapabilityIcons(metadata).map((icon) => ({ - ...icon, - id: `cap-${icon.key}`, - })); - const modalityIcons = [ - ...getModalityIcons(metadata, 'input'), - ...getModalityIcons(metadata, 'output'), - ]; - const uniqueModalityIcons = Array.from( - new Map(modalityIcons.map((icon) => [icon.key, icon])).values() - ).map((icon) => ({ ...icon, id: `mod-${icon.key}` })); - const indicatorIcons = [...capabilityIcons, ...uniqueModalityIcons]; - const contextTokens = formatTokens(metadata?.limit?.context); - const outputTokens = formatTokens(metadata?.limit?.output); + + )} - return ( - { - handleProviderAndModelChange(providerID, modelID); - }} - > -
-
- - {getModelDisplayName(model)} - - {metadata?.limit?.context || metadata?.limit?.output ? ( - - {metadata?.limit?.context ? `${contextTokens} ctx` : ''} - {metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''} - {metadata?.limit?.output ? `${outputTokens} out` : ''} - - ) : null} -
-
- {indicatorIcons.map(({ id, icon: Icon, label }) => ( - - - - ))} - -
-
-
- ); + {/* Recents Section */} + {filteredRecents.length > 0 && ( + <> + {filteredFavorites.length > 0 && } + + + Recent + + {filteredRecents.map(({ model, providerID, modelID }) => { + const idx = currentFlatIndex++; + return renderModelRow(model, providerID, modelID, 'recent', idx, modelSelectedIndex === idx); })} -
-
-
- )} - - {/* Separator before providers */} - {(favoriteModelsList.length > 0 || recentModelsList.length > 0) && ( - - )} - - {/* All Providers Section */} - {providers.map((provider) => { - const providerModels = Array.isArray(provider.models) ? provider.models : []; + + )} - if (providerModels.length === 0) { - return ( - - - {provider.name} (No models) - - ); - } + {/* Separator before providers */} + {(filteredFavorites.length > 0 || filteredRecents.length > 0) && filteredProviders.length > 0 && ( + + )} - return ( - - - - {provider.name} - - - - {providerModels.map((model: ProviderModel) => { - const metadata = getModelMetadata(provider.id, model.id!); - const capabilityIcons = getCapabilityIcons(metadata).map((icon) => ({ - ...icon, - id: `cap-${icon.key}`, - })); - const modalityIcons = [ - ...getModalityIcons(metadata, 'input'), - ...getModalityIcons(metadata, 'output'), - ]; - const uniqueModalityIcons = Array.from( - new Map(modalityIcons.map((icon) => [icon.key, icon])).values() - ).map((icon) => ({ ...icon, id: `mod-${icon.key}` })); - const indicatorIcons = [...capabilityIcons, ...uniqueModalityIcons]; - const contextTokens = formatTokens(metadata?.limit?.context); - const outputTokens = formatTokens(metadata?.limit?.output); + {/* All Providers - Flat List */} + {filteredProviders.map((provider, index) => ( + + {index > 0 && } + + + {provider.name} + + {(provider.models as ProviderModel[]).map((model: ProviderModel) => { + const idx = currentFlatIndex++; + return renderModelRow(model, provider.id as string, model.id as string, 'provider', idx, modelSelectedIndex === idx); + })} + + ))} +
+
- return ( - { - handleProviderAndModelChange(provider.id as string, model.id as string); - }} - > -
-
- - {getModelDisplayName(model)} - - {metadata?.limit?.context || metadata?.limit?.output ? ( - - {metadata?.limit?.context ? `${contextTokens} ctx` : ''} - {metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''} - {metadata?.limit?.output ? `${outputTokens} out` : ''} - - ) : null} -
-
- {indicatorIcons.map(({ id, icon: Icon, label }) => ( - - - - ))} - -
-
-
- ); - })} -
-
-
- ); - })} + {/* Keyboard hints footer */} +
+ ↑↓ navigate • Enter select • Esc close +
) : ( @@ -1974,6 +1972,7 @@ export const ModelControls: React.FC = ({ className }) => { {renderModelTooltipContent()}
); + }; const renderAgentTooltipContent = () => { if (!currentAgent) { @@ -2148,7 +2147,7 @@ export const ModelControls: React.FC = ({ className }) => { - + {agents.filter(agent => isPrimaryMode(agent.mode)).map((agent) => ( = ({ presentation = 'dropdown', }) => { const { isMobile } = useDeviceInfo(); - const isVSCodeRuntime = useIsVSCodeRuntime(); - const isCompact = isMobile || isVSCodeRuntime; + // Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns + const isCompact = isMobile; const { currentDirectory } = useDirectoryStore(); const searchFiles = useFileSearchStore((state) => state.searchFiles); const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false); diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index fa5919e3..afb87cbb 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { RiAddLine, RiCloseLine, RiPlayLine, RiSearchLine } from '@remixicon/react'; +import { RiAddLine, RiCheckLine, RiCloseLine, RiPlayLine, RiSearchLine, RiStarFill, RiTimeLine } from '@remixicon/react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { @@ -23,7 +23,9 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useMultiRunStore } from '@/stores/useMultiRunStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; +import { useModelLists } from '@/hooks/useModelLists'; import type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multirun'; +import type { ModelMetadata } from '@/types'; interface MultiRunLauncherProps { /** Prefill prompt textarea (optional) */ @@ -67,6 +69,24 @@ const ModelChip: React.FC<{ ); }; +const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { + notation: 'compact', + compactDisplay: 'short', + maximumFractionDigits: 1, + minimumFractionDigits: 0, +}); + +const formatTokens = (value?: number | null) => { + if (typeof value !== 'number' || Number.isNaN(value)) { + return ''; + } + if (value === 0) { + return '0'; + } + const formatted = COMPACT_NUMBER_FORMATTER.format(value); + return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted; +}; + /** * Model selector for multi-run (allows selecting multiple unique models). */ @@ -75,34 +95,79 @@ const ModelMultiSelect: React.FC<{ onAdd: (model: MultiRunModelSelection) => void; onRemove: (index: number) => void; }> = ({ selectedModels, onAdd, onRemove }) => { - const providers = useConfigStore((state) => state.providers); + const { providers, modelsMetadata } = useConfigStore(); + const { favoriteModelsList, recentModelsList } = useModelLists(); const [isOpen, setIsOpen] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(''); + const [selectedIndex, setSelectedIndex] = React.useState(0); const searchInputRef = React.useRef(null); const dropdownRef = React.useRef(null); + const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]); // Get set of already selected model keys const selectedKeys = React.useMemo(() => { return new Set(selectedModels.map((m) => `${m.providerID}:${m.modelID}`)); }, [selectedModels]); - // Filter models based on search query - const filteredProviders = React.useMemo(() => { - if (!searchQuery.trim()) return providers; + const getModelMetadata = (provId: string, modId: string): ModelMetadata | undefined => { + const key = `${provId}/${modId}`; + return modelsMetadata.get(key); + }; - const query = searchQuery.toLowerCase(); + const getModelDisplayName = (model: Record) => { + const name = model?.name || model?.id || ''; + const nameStr = String(name); + if (nameStr.length > 40) { + return nameStr.substring(0, 37) + '...'; + } + return nameStr; + }; + + // Filter helper + const filterByQuery = React.useCallback((modelName: string, providerName: string) => { + if (!searchQuery.trim()) return true; + const lowerQuery = searchQuery.toLowerCase(); + return ( + modelName.toLowerCase().includes(lowerQuery) || + providerName.toLowerCase().includes(lowerQuery) + ); + }, [searchQuery]); + + // Filter favorites + const filteredFavorites = React.useMemo(() => { + return favoriteModelsList.filter(({ model, providerID }) => { + const provider = providers.find(p => p.id === providerID); + const providerName = provider?.name || providerID; + const modelName = getModelDisplayName(model); + return filterByQuery(modelName, providerName); + }); + }, [favoriteModelsList, providers, filterByQuery]); + + // Filter recents + const filteredRecents = React.useMemo(() => { + return recentModelsList.filter(({ model, providerID }) => { + const provider = providers.find(p => p.id === providerID); + const providerName = provider?.name || providerID; + const modelName = getModelDisplayName(model); + return filterByQuery(modelName, providerName); + }); + }, [recentModelsList, providers, filterByQuery]); + + // Filter providers + const filteredProviders = React.useMemo(() => { return providers .map((provider) => { const models = Array.isArray(provider.models) ? provider.models : []; const filteredModels = models.filter((model) => { - const modelName = (model.name || model.id || '').toString().toLowerCase(); - const providerName = provider.name.toLowerCase(); - return modelName.includes(query) || providerName.includes(query); + const modelName = getModelDisplayName(model); + return filterByQuery(modelName, provider.name || provider.id || ''); }); return { ...provider, models: filteredModels }; }) .filter((provider) => provider.models.length > 0); - }, [providers, searchQuery]); + }, [providers, filterByQuery]); + + const hasResults = filteredFavorites.length > 0 || filteredRecents.length > 0 || filteredProviders.length > 0; // Focus search input when opened React.useEffect(() => { @@ -119,6 +184,7 @@ const ModelMultiSelect: React.FC<{ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setIsOpen(false); setSearchQuery(''); + setSelectedIndex(0); } }; @@ -126,6 +192,66 @@ const ModelMultiSelect: React.FC<{ return () => document.removeEventListener('mousedown', handleClickOutside); }, [isOpen]); + // Reset selection when search query changes + React.useEffect(() => { + setSelectedIndex(0); + }, [searchQuery]); + + // Render a model row + const renderModelRow = ( + model: Record, + providerID: string, + modelID: string, + keyPrefix: string, + flatIndex: number, + isHighlighted: boolean + ) => { + const key = `${providerID}:${modelID}`; + const isSelected = selectedKeys.has(key); + const metadata = getModelMetadata(providerID, modelID); + const contextTokens = formatTokens(metadata?.limit?.context); + + return ( + + ); + }; + return (
@@ -142,83 +268,153 @@ const ModelMultiSelect: React.FC<{ Add model - {isOpen && ( -
- {/* Search input */} -
-
- - setSearchQuery(e.target.value)} - className="h-8 pl-8 typography-meta" - /> + {isOpen && (() => { + // Build flat list for keyboard navigation + type FlatModelItem = { model: Record; providerID: string; modelID: string; section: string }; + const flatModelList: FlatModelItem[] = []; + + filteredFavorites.forEach(({ model, providerID, modelID }) => { + flatModelList.push({ model, providerID, modelID, section: 'fav' }); + }); + filteredRecents.forEach(({ model, providerID, modelID }) => { + flatModelList.push({ model, providerID, modelID, section: 'recent' }); + }); + filteredProviders.forEach((provider) => { + provider.models.forEach((model) => { + flatModelList.push({ model, providerID: provider.id, modelID: model.id as string, section: 'provider' }); + }); + }); + + const totalItems = flatModelList.length; + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + const nextIndex = (selectedIndex + 1) % Math.max(1, totalItems); + setSelectedIndex(nextIndex); + setTimeout(() => { + itemRefs.current[nextIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 0); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + const prevIndex = (selectedIndex - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems); + setSelectedIndex(prevIndex); + setTimeout(() => { + itemRefs.current[prevIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 0); + } else if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + const selectedItem = flatModelList[selectedIndex]; + if (selectedItem && !selectedKeys.has(`${selectedItem.providerID}:${selectedItem.modelID}`)) { + onAdd({ + providerID: selectedItem.providerID, + modelID: selectedItem.modelID, + displayName: (selectedItem.model.name as string) || selectedItem.modelID, + }); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + setIsOpen(false); + setSearchQuery(''); + setSelectedIndex(0); + } + }; + + let currentFlatIndex = 0; + + return ( +
+ {/* Search input */} +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + className="h-8 pl-8 typography-meta" + /> +
+
+ + {/* Models list */} + +
+ {!hasResults && ( +
+ No models found +
+ )} + + {/* Favorites Section */} + {filteredFavorites.length > 0 && ( + <> +
+ + Favorites +
+ {filteredFavorites.map(({ model, providerID, modelID }) => { + const idx = currentFlatIndex++; + return renderModelRow(model, providerID, modelID, 'fav', idx, selectedIndex === idx); + })} + + )} + + {/* Recents Section */} + {filteredRecents.length > 0 && ( + <> + {filteredFavorites.length > 0 &&
} +
+ + Recent +
+ {filteredRecents.map(({ model, providerID, modelID }) => { + const idx = currentFlatIndex++; + return renderModelRow(model, providerID, modelID, 'recent', idx, selectedIndex === idx); + })} + + )} + + {/* Separator before providers */} + {(filteredFavorites.length > 0 || filteredRecents.length > 0) && filteredProviders.length > 0 && ( +
+ )} + + {/* All Providers - Flat List */} + {filteredProviders.map((provider, index) => ( + + {index > 0 &&
} +
+ + {provider.name} +
+ {provider.models.map((model) => { + const idx = currentFlatIndex++; + return renderModelRow(model, provider.id, model.id as string, 'provider', idx, selectedIndex === idx); + })} + + ))} +
+ + + {/* Keyboard hints footer */} +
+ ↑↓ navigate • Enter select • Esc close
- - {/* Models list */} - - {filteredProviders.length === 0 ? ( -
- No models found -
- ) : ( - filteredProviders.map((provider) => { - const models = Array.isArray(provider.models) ? provider.models : []; - if (models.length === 0) return null; - - return ( -
-
- - - {provider.name} - -
- {models.map((model) => { - const key = `${provider.id}:${model.id}`; - const isSelected = selectedKeys.has(key); - - return ( - - ); - })} -
- ); - }) - )} -
-
- )} + ); + })()}
{/* Selected models */} diff --git a/packages/ui/src/components/sections/agents/ModelSelector.tsx b/packages/ui/src/components/sections/agents/ModelSelector.tsx index e058ae98..7d2e27d6 100644 --- a/packages/ui/src/components/sections/agents/ModelSelector.tsx +++ b/packages/ui/src/components/sections/agents/ModelSelector.tsx @@ -2,22 +2,21 @@ import React from 'react'; import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, + DropdownMenuLabel, DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; -import { RiArrowDownSLine, RiArrowRightSLine, RiPencilAiLine, RiStarFill, RiStarLine, RiTimeLine } from '@remixicon/react'; +import { RiArrowDownSLine, RiArrowRightSLine, RiCheckLine, RiCloseLine, RiPencilAiLine, RiSearchLine, RiStarFill, RiStarLine, RiTimeLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useModelLists } from '@/hooks/useModelLists'; +import type { ModelMetadata } from '@/types'; type ProviderModel = Record & { id?: string; name?: string }; @@ -28,6 +27,24 @@ interface ModelSelectorProps { className?: string; } +const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { + notation: 'compact', + compactDisplay: 'short', + maximumFractionDigits: 1, + minimumFractionDigits: 0, +}); + +const formatTokens = (value?: number | null) => { + if (typeof value !== 'number' || Number.isNaN(value)) { + return ''; + } + if (value === 0) { + return '0'; + } + const formatted = COMPACT_NUMBER_FORMATTER.format(value); + return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted; +}; + export const ModelSelector: React.FC = ({ providerId, modelId, @@ -43,21 +60,36 @@ export const ModelSelector: React.FC = ({ const [isMobilePanelOpen, setIsMobilePanelOpen] = React.useState(false); const [expandedMobileProviders, setExpandedMobileProviders] = React.useState>(new Set()); + const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); + const [searchQuery, setSearchQuery] = React.useState(''); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const closeMobilePanel = () => setIsMobilePanelOpen(false); - const toggleMobileProviderExpansion = (providerId: string) => { + const toggleMobileProviderExpansion = (provId: string) => { setExpandedMobileProviders(prev => { const newSet = new Set(prev); - if (newSet.has(providerId)) { - newSet.delete(providerId); + if (newSet.has(provId)) { + newSet.delete(provId); } else { - newSet.add(providerId); + newSet.add(provId); } return newSet; }); }; + // Reset search and selection when dropdown closes + React.useEffect(() => { + if (!isDropdownOpen) { + setSearchQuery(''); + setSelectedIndex(0); + } + }, [isDropdownOpen]); + // Reset selection when search query changes + React.useEffect(() => { + setSelectedIndex(0); + }, [searchQuery]); const getModelDisplayName = (model: Record) => { const name = model?.name || model?.id || ''; @@ -68,17 +100,120 @@ export const ModelSelector: React.FC = ({ return nameStr; }; - const getModelMetadata = (providerId: string, modelId: string) => { - const key = `${providerId}/${modelId}`; + const getModelMetadata = (provId: string, modId: string): ModelMetadata | undefined => { + const key = `${provId}/${modId}`; return modelsMetadata.get(key); }; const handleProviderAndModelChange = (newProviderId: string, newModelId: string) => { onChange(newProviderId, newModelId); - // Add to recent models on successful selection - addRecentModel(newProviderId, newModelId); + if (newProviderId && newModelId) { + addRecentModel(newProviderId, newModelId); + } + setIsDropdownOpen(false); }; + // Filter helper + const filterByQuery = (modelName: string, providerName: string) => { + if (!searchQuery.trim()) return true; + const lowerQuery = searchQuery.toLowerCase(); + return ( + modelName.toLowerCase().includes(lowerQuery) || + providerName.toLowerCase().includes(lowerQuery) + ); + }; + + // Render a model row for desktop dropdown + const renderModelRow = ( + model: ProviderModel, + provID: string, + modID: string, + keyPrefix: string, + flatIndex: number, + isHighlighted: boolean + ) => { + const metadata = getModelMetadata(provID, modID); + const contextTokens = formatTokens(metadata?.limit?.context); + const isSelected = providerId === provID && modelId === modID; + const isFavorite = isFavoriteModel(provID, modID); + + return ( +
{ itemRefs.current[flatIndex] = el; }} + className={cn( + "typography-meta group flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer", + isHighlighted ? "bg-accent" : "hover:bg-accent/50" + )} + onClick={() => handleProviderAndModelChange(provID, modID)} + onMouseEnter={() => setSelectedIndex(flatIndex)} + > +
+ + {getModelDisplayName(model)} + + {contextTokens ? ( + + {contextTokens} + + ) : null} +
+
+ {isSelected && ( + + )} + +
+
+ ); + }; + + // Filter data for desktop dropdown + const filteredFavorites = favoriteModelsList.filter(({ model, providerID }) => { + const provider = providers.find(p => p.id === providerID); + const providerName = provider?.name || providerID; + const modelName = getModelDisplayName(model); + return filterByQuery(modelName, providerName); + }); + + const filteredRecents = recentModelsList.filter(({ model, providerID }) => { + const provider = providers.find(p => p.id === providerID); + const providerName = provider?.name || providerID; + const modelName = getModelDisplayName(model); + return filterByQuery(modelName, providerName); + }); + + const filteredProviders = providers + .map((provider) => { + const providerModels = Array.isArray(provider.models) ? provider.models : []; + const filteredModels = providerModels.filter((model: ProviderModel) => { + const modelName = getModelDisplayName(model); + return filterByQuery(modelName, provider.name || provider.id || ''); + }); + return { ...provider, models: filteredModels }; + }) + .filter((provider) => provider.models.length > 0); + + const hasResults = filteredFavorites.length > 0 || filteredRecents.length > 0 || filteredProviders.length > 0; + const renderMobileModelPanel = () => { if (!isActuallyMobile) return null; @@ -98,7 +233,6 @@ export const ModelSelector: React.FC = ({
{favoriteModelsList.map(({ model, providerID, modelID }) => { const isSelectedModel = providerID === providerId && modelID === modelId; - const metadata = getModelMetadata(providerID, modelID); return (
= ({ /> {getModelDisplayName(model)}
- {typeof (metadata as unknown as Record)?.description === 'string' && ( - - {(metadata as unknown as Record).description as React.ReactNode} - - )}
@@ -350,7 +467,7 @@ export const ModelSelector: React.FC = ({ ) : ( - +
= ({
- - {/* Favorites Section */} - {favoriteModelsList.length > 0 && ( - - - - Favorites - - - - {favoriteModelsList.map(({ model, providerID, modelID }) => { - const metadata = getModelMetadata(providerID, modelID); - return ( - { - e.preventDefault(); - handleProviderAndModelChange(providerID, modelID); - }} - > -
-
- {getModelDisplayName(model)} - {typeof (metadata as unknown as Record)?.description === 'string' && ( - - {(metadata as unknown as Record).description as React.ReactNode} - - )} -
- -
-
- ); - })} -
-
-
- )} - - {/* Recents Section */} - {recentModelsList.length > 0 && ( - - - - Recent - - - - {recentModelsList.map(({ model, providerID, modelID }) => { - const metadata = getModelMetadata(providerID, modelID); - return ( - { - e.preventDefault(); - handleProviderAndModelChange(providerID, modelID); - }} - > -
-
- {getModelDisplayName(model)} - {typeof (metadata as unknown as Record)?.description === 'string' && ( - - {(metadata as unknown as Record).description as React.ReactNode} - - )} -
- -
-
- ); - })} -
-
-
- )} - - {/* Separator before providers */} - {(favoriteModelsList.length > 0 || recentModelsList.length > 0) && ( - - )} + + {(() => { + // Build flat list for keyboard navigation + type FlatModelItem = { model: ProviderModel; providerID: string; modelID: string; section: string }; + const flatModelList: FlatModelItem[] = []; + + filteredFavorites.forEach(({ model, providerID, modelID }) => { + flatModelList.push({ model, providerID, modelID, section: 'fav' }); + }); + filteredRecents.forEach(({ model, providerID, modelID }) => { + flatModelList.push({ model, providerID, modelID, section: 'recent' }); + }); + filteredProviders.forEach((provider) => { + (provider.models as ProviderModel[]).forEach((model) => { + flatModelList.push({ model, providerID: provider.id as string, modelID: model.id as string, section: 'provider' }); + }); + }); - {providers.map((provider) => { - const providerModels = Array.isArray(provider.models) ? provider.models : []; + const totalItems = flatModelList.length; - if (providerModels.length === 0) { - return ( - - - {provider.name} (No models) - - ); - } + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + const nextIndex = (selectedIndex + 1) % Math.max(1, totalItems); + setSelectedIndex(nextIndex); + setTimeout(() => { + itemRefs.current[nextIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 0); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + const prevIndex = (selectedIndex - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems); + setSelectedIndex(prevIndex); + setTimeout(() => { + itemRefs.current[prevIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 0); + } else if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + const selectedItem = flatModelList[selectedIndex]; + if (selectedItem) { + handleProviderAndModelChange(selectedItem.providerID, selectedItem.modelID); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + setIsDropdownOpen(false); + } + }; + + let currentFlatIndex = 0; return ( - - - - {provider.name} - - - - {providerModels.map((modelItem: ProviderModel) => { - const metadata = getModelMetadata(provider.id as string, modelItem.id as string); + <> + {/* Search Input */} +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + className="pl-8 h-8 typography-meta" + autoFocus + /> +
+
- return ( - { - e.preventDefault(); - handleProviderAndModelChange(provider.id as string, modelItem.id as string); - }} - > -
-
- {getModelDisplayName(modelItem)} - {typeof (metadata as unknown as Record)?.description === 'string' && ( - - {(metadata as unknown as Record).description as React.ReactNode} - - )} -
- -
-
- ); - })} -
-
-
+ {/* Scrollable content */} + +
+ {/* Not selected option */} +
handleProviderAndModelChange('', '')} + > + + Not selected + {!providerId && !modelId && ( + + )} +
+ + + + {!hasResults && searchQuery && ( +
+ No models found +
+ )} + + {/* Favorites Section */} + {filteredFavorites.length > 0 && ( + <> + + + Favorites + + {filteredFavorites.map(({ model, providerID, modelID }) => { + const idx = currentFlatIndex++; + return renderModelRow(model, providerID, modelID, 'fav', idx, selectedIndex === idx); + })} + + )} + + {/* Recents Section */} + {filteredRecents.length > 0 && ( + <> + {filteredFavorites.length > 0 && } + + + Recent + + {filteredRecents.map(({ model, providerID, modelID }) => { + const idx = currentFlatIndex++; + return renderModelRow(model, providerID, modelID, 'recent', idx, selectedIndex === idx); + })} + + )} + + {/* Separator before providers */} + {(filteredFavorites.length > 0 || filteredRecents.length > 0) && filteredProviders.length > 0 && ( + + )} + + {/* All Providers - Flat List */} + {filteredProviders.map((provider, index) => ( + + {index > 0 && } + + + {provider.name} + + {(provider.models as ProviderModel[]).map((model: ProviderModel) => { + const idx = currentFlatIndex++; + return renderModelRow(model, provider.id as string, model.id as string, 'provider', idx, selectedIndex === idx); + })} + + ))} +
+
+ + {/* Keyboard hints footer */} +
+ ↑↓ navigate • Enter select • Esc close +
+ ); - })} - handleProviderAndModelChange('', '')} - > - No model (optional) - + })()}
)} diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index e331ee28..02737eb5 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -7,7 +7,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { useUIStore } from '@/stores/useUIStore'; -import { RiAddLine, RiArrowUpSLine, RiArrowUpWideLine, RiCloseCircleLine, RiCodeLine, RiCommandLine, RiGitBranchLine, RiLayoutLeftLine, RiPaletteLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, RiText } from '@remixicon/react'; +import { RiAddLine, RiArrowUpSLine, RiArrowUpWideLine, RiBrainAi3Line, RiCloseCircleLine, RiCodeLine, RiCommandLine, RiGitBranchLine, RiLayoutLeftLine, RiPaletteLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, RiText } from '@remixicon/react'; const renderKeyToken = (token: string, index: number) => { const normalized = token.trim().toLowerCase(); @@ -64,6 +64,7 @@ export const HelpDialog: React.FC = () => { { keys: ["Ctrl + X"], description: "Open Command Palette", icon: RiCommandLine }, { keys: ["Ctrl + H"], description: "Show Keyboard Shortcuts (this dialog)", icon: RiQuestionLine }, { keys: ["Ctrl + L"], description: "Toggle Session Sidebar", icon: RiLayoutLeftLine }, + { keys: ["Ctrl + M"], description: "Open Model Selector", icon: RiBrainAi3Line }, ] }, { diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index eb0cf384..f05324c3 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -15,6 +15,7 @@ export const useKeyboardShortcuts = () => { setSessionCreateDialogOpen, setActiveMainTab, setSettingsDialogOpen, + setModelSelectorOpen, } = useUIStore(); const { themeMode, setThemeMode } = useThemeSystem(); const { working } = useAssistantStatus(); @@ -143,6 +144,37 @@ export const useKeyboardShortcuts = () => { return; } + // Ctrl+M: Open model selector (same conditions as double-ESC: chat tab, no overlays) + if (e.ctrlKey && !e.metaKey && !e.shiftKey && e.key.toLowerCase() === 'm') { + const { + isSettingsDialogOpen, + isCommandPaletteOpen, + isHelpDialogOpen, + isSessionSwitcherOpen, + isSessionCreateDialogOpen, + isAboutDialogOpen, + activeMainTab, + isModelSelectorOpen, + } = useUIStore.getState(); + + // Skip if settings open + if (isSettingsDialogOpen) { + return; + } + + // Skip if any overlay open or not on chat tab + const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isSessionCreateDialogOpen || isAboutDialogOpen; + const isChatActive = activeMainTab === 'chat'; + + if (hasOverlay || !isChatActive) { + return; + } + + e.preventDefault(); + setModelSelectorOpen(!isModelSelectorOpen); + return; + } + if (e.key === 'Escape') { const { isSettingsDialogOpen, @@ -222,6 +254,7 @@ export const useKeyboardShortcuts = () => { setSessionCreateDialogOpen, setActiveMainTab, setSettingsDialogOpen, + setModelSelectorOpen, setThemeMode, themeMode, working, diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 1ee5476a..60818261 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -32,6 +32,7 @@ interface UIStore { isAboutDialogOpen: boolean; isSessionCreateDialogOpen: boolean; isSettingsDialogOpen: boolean; + isModelSelectorOpen: boolean; sidebarSection: SidebarSection; eventStreamStatus: EventStreamStatus; eventStreamHint: string | null; @@ -69,6 +70,7 @@ interface UIStore { setAboutDialogOpen: (open: boolean) => void; setSessionCreateDialogOpen: (open: boolean) => void; setSettingsDialogOpen: (open: boolean) => void; + setModelSelectorOpen: (open: boolean) => void; applyTheme: () => void; setSidebarSection: (section: SidebarSection) => void; setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void; @@ -116,6 +118,7 @@ export const useUIStore = create()( isAboutDialogOpen: false, isSessionCreateDialogOpen: false, isSettingsDialogOpen: false, + isModelSelectorOpen: false, sidebarSection: 'sessions', eventStreamStatus: 'idle', eventStreamHint: null, @@ -229,6 +232,10 @@ export const useUIStore = create()( set({ isSettingsDialogOpen: open }); }, + setModelSelectorOpen: (open) => { + set({ isModelSelectorOpen: open }); + }, + setSidebarSection: (section) => { set({ sidebarSection: section }); },