1- import { useState , useMemo , useCallback } from "react"
1+ import { useState , useMemo , useCallback , useEffect } from "react"
22import { Fzf } from "fzf"
33
44import { cn } from "@/lib/utils"
@@ -10,14 +10,68 @@ import { Button } from "@/components/ui"
1010
1111import { IconButton } from "./IconButton"
1212
13+ type ApiConfigMeta = {
14+ id : string
15+ name : string
16+ apiProvider ?: string
17+ modelId ?: string
18+ }
19+
20+ type ProviderGroup = {
21+ key : string
22+ label : string
23+ configs : ApiConfigMeta [ ]
24+ }
25+
26+ const PROVIDER_LABELS : Record < string , string > = {
27+ anthropic : "Anthropic" ,
28+ bedrock : "Amazon Bedrock" ,
29+ deepseek : "DeepSeek" ,
30+ gemini : "Google Gemini" ,
31+ "gemini-cli" : "Gemini CLI" ,
32+ vertex : "Vertex AI" ,
33+ openai : "OpenAI Compatible" ,
34+ "openai-native" : "OpenAI" ,
35+ "openai-codex" : "OpenAI Codex" ,
36+ openrouter : "OpenRouter" ,
37+ ollama : "Ollama" ,
38+ lmstudio : "LM Studio" ,
39+ mistral : "Mistral" ,
40+ moonshot : "Moonshot" ,
41+ minimax : "MiniMax" ,
42+ requesty : "Requesty" ,
43+ unbound : "Unbound" ,
44+ poe : "Poe" ,
45+ xai : "xAI" ,
46+ baseten : "Baseten" ,
47+ litellm : "LiteLLM" ,
48+ sambanova : "SambaNova" ,
49+ zai : "Z.ai" ,
50+ fireworks : "Fireworks AI" ,
51+ "qwen-code" : "Qwen Code" ,
52+ roo : "Roo" ,
53+ "vscode-lm" : "VS Code LM" ,
54+ "vercel-ai-gateway" : "Vercel AI Gateway" ,
55+ }
56+
57+ const getProviderKey = ( config ?: ApiConfigMeta ) => config ?. apiProvider ?? "unknown"
58+
59+ const getProviderLabel = ( apiProvider ?: string ) => {
60+ if ( ! apiProvider ) {
61+ return "Other"
62+ }
63+
64+ return PROVIDER_LABELS [ apiProvider ] ?? apiProvider
65+ }
66+
1367interface ApiConfigSelectorProps {
1468 value : string
1569 displayName : string
1670 disabled ?: boolean
1771 title : string
1872 onChange : ( value : string ) => void
1973 triggerClassName ?: string
20- listApiConfigMeta : Array < { id : string ; name : string ; modelId ?: string } >
74+ listApiConfigMeta : ApiConfigMeta [ ]
2175 pinnedApiConfigs ?: Record < string , boolean >
2276 togglePinnedApiConfig : ( id : string ) => void
2377 lockApiConfigAcrossModes : boolean
@@ -40,14 +94,17 @@ export const ApiConfigSelector = ({
4094 const { t } = useAppTranslation ( )
4195 const [ open , setOpen ] = useState ( false )
4296 const [ searchValue , setSearchValue ] = useState ( "" )
97+ const [ activeProviderKey , setActiveProviderKey ] = useState < string > ( "" )
4398 const portalContainer = useRooPortal ( "roo-portal" )
4499
45100 // Create searchable items for fuzzy search.
46101 const searchableItems = useMemo (
47102 ( ) =>
48103 listApiConfigMeta . map ( ( config ) => ( {
49104 original : config ,
50- searchStr : config . name ,
105+ searchStr : [ config . name , config . modelId , config . apiProvider , getProviderLabel ( config . apiProvider ) ]
106+ . filter ( Boolean )
107+ . join ( " " ) ,
51108 } ) ) ,
52109 [ listApiConfigMeta ] ,
53110 )
@@ -68,54 +125,129 @@ export const ApiConfigSelector = ({
68125 return matchingItems
69126 } , [ listApiConfigMeta , searchValue , fzfInstance ] )
70127
71- // Separate pinned and unpinned configs.
72- const { pinnedConfigs, unpinnedConfigs } = useMemo ( ( ) => {
73- const pinned = filteredConfigs . filter ( ( config ) => pinnedApiConfigs ?. [ config . id ] )
74- const unpinned = filteredConfigs . filter ( ( config ) => ! pinnedApiConfigs ?. [ config . id ] )
75- return { pinnedConfigs : pinned , unpinnedConfigs : unpinned }
128+ const providerGroups = useMemo < ProviderGroup [ ] > ( ( ) => {
129+ const groups = new Map < string , ProviderGroup > ( )
130+
131+ for ( const config of filteredConfigs ) {
132+ const key = getProviderKey ( config )
133+
134+ if ( ! groups . has ( key ) ) {
135+ groups . set ( key , {
136+ key,
137+ label : getProviderLabel ( config . apiProvider ) ,
138+ configs : [ ] ,
139+ } )
140+ }
141+
142+ groups . get ( key ) ! . configs . push ( config )
143+ }
144+
145+ return Array . from ( groups . values ( ) ) . map ( ( group ) => ( {
146+ ...group ,
147+ configs : [ ...group . configs ] . sort ( ( a , b ) => {
148+ const pinnedDelta = Number ( ! ! pinnedApiConfigs ?. [ b . id ] ) - Number ( ! ! pinnedApiConfigs ?. [ a . id ] )
149+
150+ return pinnedDelta
151+ } ) ,
152+ } ) )
76153 } , [ filteredConfigs , pinnedApiConfigs ] )
77154
155+ const currentConfig = useMemo (
156+ ( ) => listApiConfigMeta . find ( ( config ) => config . id === value ) ,
157+ [ listApiConfigMeta , value ] ,
158+ )
159+
160+ const currentProviderKey = getProviderKey ( currentConfig )
161+ const preferredProviderKey = providerGroups . some ( ( group ) => group . key === currentProviderKey )
162+ ? currentProviderKey
163+ : ( providerGroups [ 0 ] ?. key ?? "" )
164+
165+ useEffect ( ( ) => {
166+ if ( ! providerGroups . length ) {
167+ setActiveProviderKey ( "" )
168+ return
169+ }
170+
171+ if ( ! providerGroups . some ( ( group ) => group . key === activeProviderKey ) ) {
172+ setActiveProviderKey ( preferredProviderKey )
173+ }
174+ } , [ activeProviderKey , preferredProviderKey , providerGroups ] )
175+
176+ const activeProviderGroup = providerGroups . find ( ( group ) => group . key === activeProviderKey ) ?? providerGroups [ 0 ]
177+
78178 const handleSelect = useCallback (
79179 ( configId : string ) => {
180+ const selectedConfig = listApiConfigMeta . find ( ( config ) => config . id === configId )
181+
182+ if ( selectedConfig ) {
183+ setActiveProviderKey ( getProviderKey ( selectedConfig ) )
184+ }
185+
80186 onChange ( configId )
81187 setOpen ( false )
82188 setSearchValue ( "" )
83189 } ,
84- [ onChange ] ,
190+ [ listApiConfigMeta , onChange ] ,
85191 )
86192
87193 const handleEditClick = useCallback ( ( ) => {
88194 vscode . postMessage ( { type : "switchTab" , tab : "settings" } )
89195 setOpen ( false )
90196 } , [ ] )
91197
92- const renderConfigItem = useCallback (
93- ( config : { id : string ; name : string ; modelId ?: string } , isPinned : boolean ) => {
198+ const renderProviderItem = useCallback (
199+ ( group : ProviderGroup ) => {
200+ const isActive = group . key === activeProviderGroup ?. key
201+ const currentCount = group . configs . filter ( ( config ) => config . id === value ) . length
202+ const pinnedCount = group . configs . filter ( ( config ) => pinnedApiConfigs ?. [ config . id ] ) . length
203+
204+ return (
205+ < button
206+ key = { group . key }
207+ type = "button"
208+ onClick = { ( ) => setActiveProviderKey ( group . key ) }
209+ className = { cn (
210+ "w-full px-2 py-1.5 text-left text-sm cursor-pointer flex items-center gap-2" ,
211+ "hover:bg-vscode-list-hoverBackground" ,
212+ isActive &&
213+ "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground" ,
214+ ) } >
215+ < span className = "truncate flex-1 min-w-0" > { group . label } </ span >
216+ < div className = "flex items-center gap-1 flex-shrink-0" >
217+ { pinnedCount > 0 && < span className = "codicon codicon-pin text-[10px] opacity-60" /> }
218+ { currentCount > 0 && < span className = "codicon codicon-check text-xs" /> }
219+ < span className = "text-[10px] opacity-70" > { group . configs . length } </ span >
220+ </ div >
221+ </ button >
222+ )
223+ } ,
224+ [ activeProviderGroup ?. key , pinnedApiConfigs , value ] ,
225+ )
226+
227+ const renderModelItem = useCallback (
228+ ( config : ApiConfigMeta ) => {
94229 const isCurrentConfig = config . id === value
230+ const isPinned = ! ! pinnedApiConfigs ?. [ config . id ]
95231
96232 return (
97233 < div
98234 key = { config . id }
99235 onClick = { ( ) => handleSelect ( config . id ) }
100236 className = { cn (
101- "px-3 py-1.5 text-sm cursor-pointer flex items-center group" ,
237+ "px-3 py-1.5 text-sm cursor-pointer flex items-center group gap-2 " ,
102238 "hover:bg-vscode-list-hoverBackground" ,
103239 isCurrentConfig &&
104240 "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground" ,
105241 ) } >
106- < div className = "flex-1 min-w-0 flex items-center gap-1 overflow-hidden" >
107- < span className = "flex-shrink-0 " > { config . name } </ span >
242+ < div className = "flex-1 min-w-0 flex flex-col overflow-hidden leading-tight " >
243+ < span className = "truncate " > { config . modelId || config . name } </ span >
108244 { config . modelId && (
109- < >
110- < span
111- className = "text-vscode-descriptionForeground opacity-70 min-w-0 overflow-hidden"
112- style = { { direction : "rtl" , textOverflow : "ellipsis" , whiteSpace : "nowrap" } } >
113- { config . modelId }
114- </ span >
115- </ >
245+ < span className = "text-xs text-vscode-descriptionForeground opacity-80 truncate" >
246+ { config . name }
247+ </ span >
116248 ) }
117249 </ div >
118- < div className = "flex items-center gap-1" >
250+ < div className = "flex items-center gap-1 flex-shrink-0 " >
119251 { isCurrentConfig && (
120252 < div className = "size-5 p-1 flex items-center justify-center" >
121253 < span className = "codicon codicon-check text-xs" />
@@ -142,7 +274,7 @@ export const ApiConfigSelector = ({
142274 </ div >
143275 )
144276 } ,
145- [ value , handleSelect , t , togglePinnedApiConfig ] ,
277+ [ value , pinnedApiConfigs , handleSelect , t , togglePinnedApiConfig ] ,
146278 )
147279
148280 return (
@@ -167,7 +299,7 @@ export const ApiConfigSelector = ({
167299 align = "start"
168300 sideOffset = { 4 }
169301 container = { portalContainer }
170- className = "p-0 overflow-hidden w-[300px ]" >
302+ className = "p-0 overflow-hidden w-[520px] max-w-[calc(100vw-24px) ]" >
171303 < div className = "flex flex-col w-full" >
172304 { /* Search input or info blurb */ }
173305 { listApiConfigMeta . length > 6 ? (
@@ -197,29 +329,29 @@ export const ApiConfigSelector = ({
197329 </ div >
198330 ) }
199331
200- { /* Config list - single scroll container */ }
332+ { /* Provider/model picker */ }
201333 { filteredConfigs . length === 0 && searchValue ? (
202334 < div className = "py-2 px-3 text-sm text-vscode-foreground/70" > { t ( "common:ui.no_results" ) } </ div >
203335 ) : (
204- < div className = "max-h-[300px] overflow-y-auto" >
205- { /* Pinned configs - sticky header */ }
206- { pinnedConfigs . length > 0 && (
207- < div
208- className = { cn (
209- "sticky top-0 z-10 bg-vscode-dropdown-background py-1" ,
210- unpinnedConfigs . length > 0 && "border-b border-vscode-dropdown-foreground/10" ,
211- ) }
212- aria-label = "Pinned configurations" >
213- { pinnedConfigs . map ( ( config ) => renderConfigItem ( config , true ) ) }
336+ < div className = "grid grid-cols-[170px_minmax(0,1fr)] max-h-[320px] overflow-hidden" >
337+ < div
338+ className = "border-r border-vscode-dropdown-border overflow-y-auto"
339+ data-testid = "api-provider-column"
340+ aria-label = { t ( "settings:providers.apiProvider" ) } >
341+ < div className = "sticky top-0 z-10 bg-vscode-dropdown-background px-2 py-1 text-[10px] uppercase tracking-wide text-vscode-descriptionForeground border-b border-vscode-dropdown-border" >
342+ { t ( "settings:providers.apiProvider" ) }
214343 </ div >
215- ) }
216-
217- { /* Unpinned configs */ }
218- { unpinnedConfigs . length > 0 && (
219- < div className = "py-1" aria-label = "All configurations" >
220- { unpinnedConfigs . map ( ( config ) => renderConfigItem ( config , false ) ) }
344+ < div className = "py-1" > { providerGroups . map ( renderProviderItem ) } </ div >
345+ </ div >
346+ < div
347+ className = "overflow-y-auto"
348+ data-testid = "api-model-column"
349+ aria-label = { t ( "settings:providers.model" ) } >
350+ < div className = "sticky top-0 z-10 bg-vscode-dropdown-background px-3 py-1 text-[10px] uppercase tracking-wide text-vscode-descriptionForeground border-b border-vscode-dropdown-border" >
351+ { activeProviderGroup ?. label ?? t ( "settings:providers.model" ) }
221352 </ div >
222- ) }
353+ < div className = "py-1" > { activeProviderGroup ?. configs . map ( renderModelItem ) } </ div >
354+ </ div >
223355 </ div >
224356 ) }
225357
0 commit comments