Skip to content

Commit 2247086

Browse files
committed
refactor(tui): share search and pagination across list pickers
ChoicePicker and ModelSelector each carried their own copy of the cursor + fuzzy-search + pagination state machine. Extract it into a reusable SearchableList so both pickers share one implementation; behavior is unchanged.
1 parent f3a10d8 commit 2247086

4 files changed

Lines changed: 296 additions & 137 deletions

File tree

apps/kimi-code/src/tui/components/dialogs/choice-picker.ts

Lines changed: 27 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
import {
1212
Container,
13-
fuzzyFilter,
1413
matchesKey,
1514
Key,
1615
truncateToWidth,
@@ -20,8 +19,7 @@ import {
2019
import chalk from 'chalk';
2120

2221
import type { ColorPalette } from '#/tui/theme/colors';
23-
import { pageView } from '#/tui/utils/paging';
24-
import { isPrintableChar, printableChar } from '#/tui/utils/printable-key';
22+
import { SearchableList } from '#/tui/utils/searchable-list';
2523

2624
export interface ChoiceOption {
2725
/** Value passed to onSelect (e.g. the actual editor command string). */
@@ -47,7 +45,6 @@ export interface ChoicePickerOptions {
4745
}
4846

4947
const CURRENT_MARK = '← current';
50-
const DEFAULT_PAGE_SIZE = 8;
5148

5249
function wrapDescription(text: string, width: number): string[] {
5350
const maxWidth = Math.max(1, width);
@@ -75,109 +72,73 @@ function wrapDescription(text: string, width: number): string[] {
7572
export class ChoicePickerComponent extends Container implements Focusable {
7673
focused = false;
7774
private readonly opts: ChoicePickerOptions;
78-
private selectedIndex: number;
79-
private query = '';
75+
private readonly list: SearchableList<ChoiceOption>;
8076

8177
constructor(opts: ChoicePickerOptions) {
8278
super();
8379
this.opts = opts;
8480
const currentIdx = opts.options.findIndex((o) => o.value === opts.currentValue);
85-
this.selectedIndex = Math.max(currentIdx, 0);
86-
}
87-
88-
private get pageSize(): number {
89-
return this.opts.pageSize ?? DEFAULT_PAGE_SIZE;
90-
}
91-
92-
private filteredOptions(): readonly ChoiceOption[] {
93-
if (this.query.length === 0) return this.opts.options;
94-
return fuzzyFilter(
95-
[...this.opts.options],
96-
this.query,
97-
(o) => `${o.label} ${o.description ?? ''}`,
98-
);
81+
this.list = new SearchableList({
82+
items: opts.options,
83+
toSearchText: (o) => `${o.label} ${o.description ?? ''}`,
84+
pageSize: opts.pageSize,
85+
initialIndex: Math.max(currentIdx, 0),
86+
searchable: opts.searchable === true,
87+
});
9988
}
10089

10190
handleInput(data: string): void {
102-
const options = this.filteredOptions();
103-
const lastIndex = Math.max(0, options.length - 1);
104-
const searchable = this.opts.searchable === true;
105-
10691
if (matchesKey(data, Key.escape)) {
107-
if (searchable && this.query.length > 0) {
108-
this.query = '';
109-
this.selectedIndex = 0;
110-
return;
111-
}
92+
if (this.list.clearQuery()) return;
11293
this.opts.onCancel();
11394
return;
11495
}
115-
if (matchesKey(data, Key.up)) {
116-
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
96+
// Left/Right page through the list (this picker has no horizontal control).
97+
if (matchesKey(data, Key.left)) {
98+
this.list.pageUp();
11799
return;
118100
}
119-
if (matchesKey(data, Key.down)) {
120-
this.selectedIndex = Math.min(lastIndex, this.selectedIndex + 1);
121-
return;
122-
}
123-
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.right)) {
124-
this.selectedIndex = Math.min(lastIndex, this.selectedIndex + this.pageSize);
125-
return;
126-
}
127-
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.left)) {
128-
this.selectedIndex = Math.max(0, this.selectedIndex - this.pageSize);
101+
if (matchesKey(data, Key.right)) {
102+
this.list.pageDown();
129103
return;
130104
}
131105
if (matchesKey(data, Key.enter)) {
132-
const chosen = options[this.selectedIndex];
106+
const chosen = this.list.selected();
133107
if (chosen !== undefined) this.opts.onSelect(chosen.value);
134108
return;
135109
}
136-
if (!searchable) return;
137-
if (matchesKey(data, Key.backspace)) {
138-
if (this.query.length > 0) {
139-
this.query = this.query.slice(0, -1);
140-
this.selectedIndex = 0;
141-
}
142-
return;
143-
}
144-
const ch = printableChar(data);
145-
if (isPrintableChar(ch)) {
146-
this.query += ch;
147-
this.selectedIndex = 0;
148-
}
110+
this.list.handleKey(data);
149111
}
150112

151113
override render(width: number): string[] {
152114
const { colors } = this.opts;
153115
const searchable = this.opts.searchable === true;
154-
const options = this.filteredOptions();
155-
const view = pageView(options.length, this.selectedIndex, this.pageSize);
156-
const selectedIndex = Math.min(this.selectedIndex, Math.max(0, options.length - 1));
116+
const view = this.list.view();
117+
const options = view.items;
157118

158119
const navParts = ['↑↓ navigate'];
159-
if (view.pageCount > 1) navParts.push('←→ page');
120+
if (view.page.pageCount > 1) navParts.push('←→ page');
160121
navParts.push('Enter select', 'Esc cancel');
161122
const hint = this.opts.hint ?? navParts.join(' · ');
162123

163124
const titleSuffix =
164-
searchable && this.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : '';
125+
searchable && view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : '';
165126
const lines: string[] = [
166127
chalk.hex(colors.primary)('─'.repeat(width)),
167128
chalk.hex(colors.primary).bold(` ${this.opts.title}`) + titleSuffix,
168129
];
169-
if (searchable && this.query.length > 0) {
170-
lines.push(chalk.hex(colors.primary)(` Search: `) + chalk.hex(colors.text)(this.query));
130+
if (searchable && view.query.length > 0) {
131+
lines.push(chalk.hex(colors.primary)(` Search: `) + chalk.hex(colors.text)(view.query));
171132
}
172133
lines.push(chalk.hex(colors.textMuted)(` ${hint}`));
173134
lines.push('');
174135

175136
if (options.length === 0) {
176137
lines.push(chalk.hex(colors.textMuted)(' No matches'));
177138
}
178-
for (let i = view.start; i < view.end; i++) {
139+
for (let i = view.page.start; i < view.page.end; i++) {
179140
const opt = options[i]!;
180-
const isSelected = i === selectedIndex;
141+
const isSelected = i === view.selectedIndex;
181142
const isCurrent = opt.value === this.opts.currentValue;
182143
const pointer = isSelected ? '❯' : ' ';
183144
const labelStyle = isSelected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
@@ -196,10 +157,10 @@ export class ChoicePickerComponent extends Container implements Focusable {
196157
}
197158

198159
lines.push('');
199-
if (view.pageCount > 1) {
160+
if (view.page.pageCount > 1) {
200161
lines.push(
201162
chalk.hex(colors.textMuted)(
202-
` Page ${String(view.page + 1)}/${String(view.pageCount)}`,
163+
` Page ${String(view.page.page + 1)}/${String(view.page.pageCount)}`,
203164
),
204165
);
205166
}

apps/kimi-code/src/tui/components/dialogs/model-selector.ts

Lines changed: 29 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk';
22
import {
33
Container,
4-
fuzzyFilter,
54
Key,
65
matchesKey,
76
truncateToWidth,
@@ -11,13 +10,10 @@ import chalk from 'chalk';
1110

1211
import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app';
1312
import 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

1715
import type { ChoiceOption } from './choice-picker';
1816

19-
const DEFAULT_PAGE_SIZE = 8;
20-
2117
type ThinkingAvailability = 'toggle' | 'always-on' | 'unsupported';
2218

2319
interface ModelChoice {
@@ -89,61 +85,34 @@ function effectiveThinking(model: ModelAlias, thinkingDraft: boolean): boolean {
8985
export 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

Comments
 (0)