11import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk' ;
22import {
33 Container ,
4- fuzzyFilter ,
54 Key ,
65 matchesKey ,
76 truncateToWidth ,
@@ -11,13 +10,10 @@ import chalk from 'chalk';
1110
1211import { DEFAULT_OAUTH_PROVIDER_NAME , PRODUCT_NAME } from '#/constant/app' ;
1312import type { ColorPalette } from '#/tui/theme/colors' ;
14- import { pageView } from '#/tui/utils/paging' ;
15- import { isPrintableChar , printableChar } from '#/tui/utils/printable-key' ;
13+ import { SearchableList } from '#/tui/utils/searchable-list' ;
1614
1715import type { ChoiceOption } from './choice-picker' ;
1816
19- const DEFAULT_PAGE_SIZE = 8 ;
20-
2117type ThinkingAvailability = 'toggle' | 'always-on' | 'unsupported' ;
2218
2319interface ModelChoice {
@@ -89,61 +85,34 @@ function effectiveThinking(model: ModelAlias, thinkingDraft: boolean): boolean {
8985export class ModelSelectorComponent extends Container implements Focusable {
9086 focused = false ;
9187 private readonly opts : ModelSelectorOptions ;
92- private readonly choices : readonly ModelChoice [ ] ;
93- private selectedIndex : number ;
88+ private readonly list : SearchableList < ModelChoice > ;
9489 private thinkingDraft : boolean ;
95- private query = '' ;
9690
9791 constructor ( opts : ModelSelectorOptions ) {
9892 super ( ) ;
9993 this . opts = opts ;
100- this . choices = createModelChoices ( opts . models ) ;
94+ const choices = createModelChoices ( opts . models ) ;
10195 const selectedValue = opts . selectedValue ?? opts . currentValue ;
102- const selectedIdx = this . choices . findIndex ( ( choice ) => choice . alias === selectedValue ) ;
103- this . selectedIndex = Math . max ( selectedIdx , 0 ) ;
96+ const selectedIdx = choices . findIndex ( ( choice ) => choice . alias === selectedValue ) ;
97+ this . list = new SearchableList ( {
98+ items : choices ,
99+ toSearchText : ( c ) => c . label ,
100+ pageSize : opts . pageSize ,
101+ initialIndex : Math . max ( selectedIdx , 0 ) ,
102+ searchable : opts . searchable === true ,
103+ } ) ;
104104 this . thinkingDraft = opts . currentThinking ;
105105 }
106106
107- private get pageSize ( ) : number {
108- return this . opts . pageSize ?? DEFAULT_PAGE_SIZE ;
109- }
110-
111- private filteredChoices ( ) : readonly ModelChoice [ ] {
112- if ( this . query . length === 0 ) return this . choices ;
113- return fuzzyFilter ( [ ...this . choices ] , this . query , ( c ) => c . label ) ;
114- }
115-
116107 handleInput ( data : string ) : void {
117- const choices = this . filteredChoices ( ) ;
118- const lastIndex = Math . max ( 0 , choices . length - 1 ) ;
119- const searchable = this . opts . searchable === true ;
120- const selected = choices [ this . selectedIndex ] ;
121-
122108 if ( matchesKey ( data , Key . escape ) ) {
123- if ( searchable && this . query . length > 0 ) {
124- this . query = '' ;
125- this . selectedIndex = 0 ;
126- return ;
127- }
109+ if ( this . list . clearQuery ( ) ) return ;
128110 this . opts . onCancel ( ) ;
129111 return ;
130112 }
131- if ( matchesKey ( data , Key . up ) ) {
132- this . selectedIndex = Math . max ( 0 , this . selectedIndex - 1 ) ;
133- return ;
134- }
135- if ( matchesKey ( data , Key . down ) ) {
136- this . selectedIndex = Math . min ( lastIndex , this . selectedIndex + 1 ) ;
137- return ;
138- }
139- if ( matchesKey ( data , Key . pageDown ) ) {
140- this . selectedIndex = Math . min ( lastIndex , this . selectedIndex + this . pageSize ) ;
141- return ;
142- }
143- if ( matchesKey ( data , Key . pageUp ) ) {
144- this . selectedIndex = Math . max ( 0 , this . selectedIndex - this . pageSize ) ;
145- return ;
146- }
113+ const selected = this . list . selected ( ) ;
114+ // Left/Right toggle thinking (only when the model supports it); paging is on
115+ // PgUp/PgDn so the horizontal arrows stay free for the thinking control.
147116 if ( selected !== undefined && thinkingAvailability ( selected . model ) === 'toggle' ) {
148117 if ( matchesKey ( data , Key . left ) ) {
149118 this . thinkingDraft = true ;
@@ -162,50 +131,37 @@ export class ModelSelectorComponent extends Container implements Focusable {
162131 } ) ;
163132 return ;
164133 }
165- if ( ! searchable ) return ;
166- if ( matchesKey ( data , Key . backspace ) ) {
167- if ( this . query . length > 0 ) {
168- this . query = this . query . slice ( 0 , - 1 ) ;
169- this . selectedIndex = 0 ;
170- }
171- return ;
172- }
173- const ch = printableChar ( data ) ;
174- if ( isPrintableChar ( ch ) ) {
175- this . query += ch ;
176- this . selectedIndex = 0 ;
177- }
134+ this . list . handleKey ( data ) ;
178135 }
179136
180137 override render ( width : number ) : string [ ] {
181138 const { colors } = this . opts ;
182139 const searchable = this . opts . searchable === true ;
183- const choices = this . filteredChoices ( ) ;
184- const view = pageView ( choices . length , this . selectedIndex , this . pageSize ) ;
185- const selectedIndex = Math . min ( this . selectedIndex , Math . max ( 0 , choices . length - 1 ) ) ;
140+ const view = this . list . view ( ) ;
141+ const choices = view . items ;
186142
187143 const navParts = [ '↑↓ model' , '←→ thinking' ] ;
188- if ( view . pageCount > 1 ) navParts . push ( 'PgUp/PgDn page' ) ;
144+ if ( view . page . pageCount > 1 ) navParts . push ( 'PgUp/PgDn page' ) ;
189145 navParts . push ( 'Enter apply' , 'Esc cancel' ) ;
190146
191147 const titleSuffix =
192- searchable && this . query . length === 0 ? chalk . hex ( colors . textMuted ) ( ' (type to search)' ) : '' ;
148+ searchable && view . query . length === 0 ? chalk . hex ( colors . textMuted ) ( ' (type to search)' ) : '' ;
193149 const lines : string [ ] = [
194150 chalk . hex ( colors . primary ) ( '─' . repeat ( width ) ) ,
195151 chalk . hex ( colors . primary ) . bold ( ' Select a model' ) + titleSuffix ,
196152 ] ;
197- if ( searchable && this . query . length > 0 ) {
198- lines . push ( chalk . hex ( colors . primary ) ( ' Search: ' ) + chalk . hex ( colors . text ) ( this . query ) ) ;
153+ if ( searchable && view . query . length > 0 ) {
154+ lines . push ( chalk . hex ( colors . primary ) ( ' Search: ' ) + chalk . hex ( colors . text ) ( view . query ) ) ;
199155 }
200156 lines . push ( chalk . hex ( colors . textMuted ) ( ` ${ navParts . join ( ' · ' ) } ` ) ) ;
201157 lines . push ( '' ) ;
202158
203159 if ( choices . length === 0 ) {
204160 lines . push ( chalk . hex ( colors . textMuted ) ( ' No matches' ) ) ;
205161 }
206- for ( let i = view . start ; i < view . end ; i ++ ) {
162+ for ( let i = view . page . start ; i < view . page . end ; i ++ ) {
207163 const choice = choices [ i ] ! ;
208- const isSelected = i === selectedIndex ;
164+ const isSelected = i === view . selectedIndex ;
209165 const isCurrent = choice . alias === this . opts . currentValue ;
210166 const pointer = isSelected ? '❯' : ' ' ;
211167 const labelStyle = isSelected ? chalk . hex ( colors . primary ) . bold : chalk . hex ( colors . text ) ;
@@ -219,14 +175,16 @@ export class ModelSelectorComponent extends Container implements Focusable {
219175
220176 lines . push ( '' ) ;
221177 lines . push ( chalk . hex ( colors . textMuted ) ( ' Thinking' ) ) ;
222- const selected = choices [ selectedIndex ] ;
178+ const selected = choices [ view . selectedIndex ] ;
223179 if ( selected !== undefined ) {
224180 lines . push ( this . renderThinkingControl ( selected . model ) ) ;
225181 }
226182 lines . push ( '' ) ;
227- if ( view . pageCount > 1 ) {
183+ if ( view . page . pageCount > 1 ) {
228184 lines . push (
229- chalk . hex ( colors . textMuted ) ( ` Page ${ String ( view . page + 1 ) } /${ String ( view . pageCount ) } ` ) ,
185+ chalk . hex ( colors . textMuted ) (
186+ ` Page ${ String ( view . page . page + 1 ) } /${ String ( view . page . pageCount ) } ` ,
187+ ) ,
230188 ) ;
231189 }
232190 lines . push ( chalk . hex ( colors . primary ) ( '─' . repeat ( width ) ) ) ;
0 commit comments