@@ -15,6 +15,37 @@ import { formatModelCost } from "../utils/format.js";
1515import { i18n } from "../utils/i18n.js" ;
1616import { discoverModels } from "../utils/model-discovery.js" ;
1717
18+ /**
19+ * Score a query against a text using subsequence matching.
20+ * All query characters must appear in order in the text.
21+ * Higher score = tighter match (fewer gaps between matched characters).
22+ * Returns 0 if no match.
23+ */
24+ function subsequenceScore ( query : string , text : string ) : number {
25+ let qi = 0 ;
26+ let ti = 0 ;
27+ let gaps = 0 ;
28+ let lastMatchIndex = - 1 ;
29+
30+ while ( qi < query . length && ti < text . length ) {
31+ if ( query [ qi ] === text [ ti ] ) {
32+ if ( lastMatchIndex >= 0 ) {
33+ gaps += ti - lastMatchIndex - 1 ;
34+ }
35+ lastMatchIndex = ti ;
36+ qi ++ ;
37+ }
38+ ti ++ ;
39+ }
40+
41+ // All query chars must match
42+ if ( qi < query . length ) return 0 ;
43+
44+ // Score: longer query match = better, fewer gaps = better
45+ // Normalize so exact substring gets highest score
46+ return query . length / ( query . length + gaps ) ;
47+ }
48+
1849@customElement ( "agent-model-selector" )
1950export class ModelSelector extends DialogBase {
2051 @state ( ) currentModel : Model < any > | null = null ;
@@ -27,16 +58,24 @@ export class ModelSelector extends DialogBase {
2758 @state ( ) private customProviderModels : Model < any > [ ] = [ ] ;
2859
2960 private onSelectCallback ?: ( model : Model < any > ) => void ;
61+ private allowedProviders ?: Set < string > ;
3062 private scrollContainerRef = createRef < HTMLDivElement > ( ) ;
3163 private searchInputRef = createRef < HTMLInputElement > ( ) ;
3264 private lastMousePosition = { x : 0 , y : 0 } ;
3365
3466 protected override modalWidth = "min(400px, 90vw)" ;
3567
36- static async open ( currentModel : Model < any > | null , onSelect : ( model : Model < any > ) => void ) {
68+ static async open (
69+ currentModel : Model < any > | null ,
70+ onSelect : ( model : Model < any > ) => void ,
71+ allowedProviders ?: string [ ] ,
72+ ) {
3773 const selector = new ModelSelector ( ) ;
3874 selector . currentModel = currentModel ;
3975 selector . onSelectCallback = onSelect ;
76+ if ( allowedProviders ) {
77+ selector . allowedProviders = new Set ( allowedProviders ) ;
78+ }
4079 selector . open ( ) ;
4180 selector . loadCustomProviders ( ) ;
4281 }
@@ -173,19 +212,30 @@ export class ModelSelector extends DialogBase {
173212 allModels . push ( { provider : model . provider , id : model . id , model } ) ;
174213 }
175214
215+ // Filter by allowed providers if set
216+ if ( this . allowedProviders ) {
217+ const allowed = this . allowedProviders ;
218+ allModels . splice ( 0 , allModels . length , ...allModels . filter ( ( { provider } ) => allowed . has ( provider ) ) ) ;
219+ }
220+
176221 // Filter models based on search and capability filters
177222 let filteredModels = allModels ;
178223
179- // Apply search filter
224+ // Apply search filter (subsequence match: characters must appear in order)
180225 if ( this . searchQuery ) {
181- filteredModels = filteredModels . filter ( ( { provider, id, model } ) => {
182- const searchTokens = this . searchQuery
183- . toLowerCase ( )
184- . split ( / \s + / )
185- . filter ( ( t ) => t ) ;
186- const searchText = `${ provider } ${ id } ${ model . name } ` . toLowerCase ( ) ;
187- return searchTokens . every ( ( token ) => searchText . includes ( token ) ) ;
188- } ) ;
226+ const query = this . searchQuery . toLowerCase ( ) . replace ( / \s + / g, "" ) ;
227+ if ( query ) {
228+ const scored : Array < { item : ( typeof allModels ) [ 0 ] ; score : number } > = [ ] ;
229+ for ( const entry of filteredModels ) {
230+ const searchText = `${ entry . provider } ${ entry . id } ${ entry . model . name } ` . toLowerCase ( ) ;
231+ const score = subsequenceScore ( query , searchText ) ;
232+ if ( score > 0 ) {
233+ scored . push ( { item : entry , score } ) ;
234+ }
235+ }
236+ scored . sort ( ( a , b ) => b . score - a . score ) ;
237+ filteredModels = scored . map ( ( s ) => s . item ) ;
238+ }
189239 }
190240
191241 // Apply capability filters
@@ -196,14 +246,18 @@ export class ModelSelector extends DialogBase {
196246 filteredModels = filteredModels . filter ( ( { model } ) => model . input . includes ( "image" ) ) ;
197247 }
198248
199- // Sort: current model first, then by provider
200- filteredModels . sort ( ( a , b ) => {
201- const aIsCurrent = modelsAreEqual ( this . currentModel , a . model ) ;
202- const bIsCurrent = modelsAreEqual ( this . currentModel , b . model ) ;
203- if ( aIsCurrent && ! bIsCurrent ) return - 1 ;
204- if ( ! aIsCurrent && bIsCurrent ) return 1 ;
205- return a . provider . localeCompare ( b . provider ) ;
206- } ) ;
249+ // Sort: when not searching, current model first then by provider.
250+ // When searching, preserve the score-based order from above,
251+ // but still float the current model to the top.
252+ if ( ! this . searchQuery ) {
253+ filteredModels . sort ( ( a , b ) => {
254+ const aIsCurrent = modelsAreEqual ( this . currentModel , a . model ) ;
255+ const bIsCurrent = modelsAreEqual ( this . currentModel , b . model ) ;
256+ if ( aIsCurrent && ! bIsCurrent ) return - 1 ;
257+ if ( ! aIsCurrent && bIsCurrent ) return 1 ;
258+ return a . provider . localeCompare ( b . provider ) ;
259+ } ) ;
260+ }
207261
208262 return filteredModels ;
209263 }
0 commit comments