From b0925c3e02d592f62928e0bf4d9b44a6c5aae850 Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Thu, 28 May 2026 21:21:37 +0800 Subject: [PATCH 01/10] refactor(tui): extract model-domain helpers to model-choice --- .../tui/components/dialogs/model-choice.ts | 71 +++++++++++++++++ .../tui/components/dialogs/model-selector.ts | 77 ++----------------- 2 files changed, 79 insertions(+), 69 deletions(-) create mode 100644 apps/kimi-code/src/tui/components/dialogs/model-choice.ts diff --git a/apps/kimi-code/src/tui/components/dialogs/model-choice.ts b/apps/kimi-code/src/tui/components/dialogs/model-choice.ts new file mode 100644 index 00000000..1ab89cf1 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/model-choice.ts @@ -0,0 +1,71 @@ +/** + * Shared model-domain helpers used by the single-select ModelSelectorComponent + * (/model) and the multi-select CatalogModelMultiSelectComponent (/connect). + * Lives outside both components so neither has to import from the other. + */ + +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import chalk from 'chalk'; + +import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; +import type { ColorPalette } from '#/tui/theme/colors'; + +export type ThinkingAvailability = 'toggle' | 'always-on' | 'unsupported'; + +export interface ModelChoice { + readonly alias: string; + readonly model: ModelAlias; + readonly label: string; +} + +export function modelDisplayName(alias: string, model: ModelAlias | undefined): string { + return model?.displayName ?? model?.model ?? alias; +} + +export function providerDisplayName(provider: string): string { + if (provider === DEFAULT_OAUTH_PROVIDER_NAME) return PRODUCT_NAME; + if (provider.startsWith('managed:')) return provider.slice('managed:'.length); + return provider; +} + +export function createModelChoices(models: Record): readonly ModelChoice[] { + return Object.entries(models).map(([alias, cfg]) => ({ + alias, + model: cfg, + label: `${modelDisplayName(alias, cfg)} (${providerDisplayName(cfg.provider)})`, + })); +} + +export function thinkingAvailability(model: ModelAlias): ThinkingAvailability { + const caps = model.capabilities ?? []; + if (caps.includes('always_thinking')) return 'always-on'; + if (caps.includes('thinking')) return 'toggle'; + return 'unsupported'; +} + +export function effectiveThinking(model: ModelAlias, thinkingDraft: boolean): boolean { + const availability = thinkingAvailability(model); + if (availability === 'always-on') return true; + if (availability === 'unsupported') return false; + return thinkingDraft; +} + +export function renderThinkingControl( + model: ModelAlias, + thinkingDraft: boolean, + colors: ColorPalette, +): string { + const segment = (label: string, active: boolean): string => + active + ? chalk.hex(colors.primary).bold(`[ ${label} ]`) + : chalk.hex(colors.text)(` ${label} `); + + const availability = thinkingAvailability(model); + if (availability === 'always-on') { + return ` ${segment('Always on', true)}`; + } + if (availability === 'unsupported') { + return ` ${segment('Off', true)} ${chalk.hex(colors.textMuted)('unsupported')}`; + } + return ` ${segment('On', thinkingDraft)} ${segment('Off', !thinkingDraft)}`; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index adf95a56..d06a5991 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -8,44 +8,22 @@ import { } from '@earendil-works/pi-tui'; import chalk from 'chalk'; -import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; import type { ColorPalette } from '#/tui/theme/colors'; import { SearchableList } from '#/tui/utils/searchable-list'; -import type { ChoiceOption } from './choice-picker'; - -type ThinkingAvailability = 'toggle' | 'always-on' | 'unsupported'; - -interface ModelChoice { - readonly alias: string; - readonly model: ModelAlias; - readonly label: string; -} +import { + createModelChoices, + effectiveThinking, + renderThinkingControl, + thinkingAvailability, + type ModelChoice, +} from './model-choice'; export interface ModelSelection { readonly alias: string; readonly thinking: boolean; } -export function modelDisplayName(alias: string, model: ModelAlias | undefined): string { - return model?.displayName ?? model?.model ?? alias; -} - -export function providerDisplayName(provider: string): string { - if (provider === DEFAULT_OAUTH_PROVIDER_NAME) return PRODUCT_NAME; - if (provider.startsWith('managed:')) return provider.slice('managed:'.length); - return provider; -} - -export function createModelChoiceOptions( - models: Record, -): readonly ChoiceOption[] { - return Object.entries(models).map(([alias, cfg]) => ({ - value: alias, - label: `${modelDisplayName(alias, cfg)} (${providerDisplayName(cfg.provider)})`, - })); -} - export interface ModelSelectorOptions { readonly models: Record; readonly currentValue: string; @@ -60,28 +38,6 @@ export interface ModelSelectorOptions { readonly onCancel: () => void; } -function createModelChoices(models: Record): readonly ModelChoice[] { - return Object.entries(models).map(([alias, cfg]) => ({ - alias, - model: cfg, - label: `${modelDisplayName(alias, cfg)} (${providerDisplayName(cfg.provider)})`, - })); -} - -function thinkingAvailability(model: ModelAlias): ThinkingAvailability { - const caps = model.capabilities ?? []; - if (caps.includes('always_thinking')) return 'always-on'; - if (caps.includes('thinking')) return 'toggle'; - return 'unsupported'; -} - -function effectiveThinking(model: ModelAlias, thinkingDraft: boolean): boolean { - const availability = thinkingAvailability(model); - if (availability === 'always-on') return true; - if (availability === 'unsupported') return false; - return thinkingDraft; -} - export class ModelSelectorComponent extends Container implements Focusable { focused = false; private readonly opts: ModelSelectorOptions; @@ -177,7 +133,7 @@ export class ModelSelectorComponent extends Container implements Focusable { lines.push(chalk.hex(colors.textMuted)(' Thinking')); const selected = choices[view.selectedIndex]; if (selected !== undefined) { - lines.push(this.renderThinkingControl(selected.model)); + lines.push(renderThinkingControl(selected.model, this.thinkingDraft, colors)); } lines.push(''); if (view.page.pageCount > 1) { @@ -190,21 +146,4 @@ export class ModelSelectorComponent extends Container implements Focusable { lines.push(chalk.hex(colors.primary)('─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width)); } - - private renderThinkingControl(model: ModelAlias): string { - const { colors } = this.opts; - const segment = (label: string, active: boolean): string => - active - ? chalk.hex(colors.primary).bold(`[ ${label} ]`) - : chalk.hex(colors.text)(` ${label} `); - - const availability = thinkingAvailability(model); - if (availability === 'always-on') { - return ` ${segment('Always on', true)}`; - } - if (availability === 'unsupported') { - return ` ${segment('Off', true)} ${chalk.hex(colors.textMuted)('unsupported')}`; - } - return ` ${segment('On', this.thinkingDraft)} ${segment('Off', !this.thinkingDraft)}`; - } } From fa5fee967e72d1d90390975c8c9a37a9edd83d33 Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Thu, 28 May 2026 21:22:40 +0800 Subject: [PATCH 02/10] feat(tui): support badge on ChoiceOption --- apps/kimi-code/src/tui/components/dialogs/choice-picker.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index 29d18074..83745595 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -29,6 +29,8 @@ export interface ChoiceOption { readonly label: string; /** Optional explanatory text shown below the label. */ readonly description?: string | undefined; + /** Optional success-tinted suffix rendered after the label, e.g. `← configured · 2 models`. */ + readonly badge?: string | undefined; } export interface ChoicePickerOptions { @@ -156,6 +158,9 @@ export class ChoicePickerComponent extends Container implements Focusable { if (isCurrent) { line += ' ' + chalk.hex(colors.success)(CURRENT_MARK); } + if (opt.badge !== undefined && opt.badge.length > 0) { + line += ' ' + chalk.hex(colors.success)(opt.badge); + } lines.push(line); if (opt.description !== undefined && opt.description.length > 0) { const descriptionWidth = Math.max(1, width - 4); From 4e845bb832b3f74324e4323d56ee2d42f69f712c Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Thu, 28 May 2026 21:23:44 +0800 Subject: [PATCH 03/10] feat(tui): allow ApiKeyInputDialog to reuse existing key --- .../dialogs/api-key-input-dialog.ts | 19 +++++++++- .../components/dialogs/choice-picker.test.ts | 36 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts index 8296cb0a..f0a8677c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -15,6 +15,10 @@ export type ApiKeyInputResult = | { readonly kind: 'ok'; readonly value: string } | { readonly kind: 'cancel' }; +export interface ApiKeyInputDialogOptions { + readonly existingApiKey?: string; +} + const FOOTER = 'Enter to submit · Esc to cancel'; function maskInputLine(raw: string): string { @@ -50,6 +54,7 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { private readonly colors: ColorPalette; private readonly title: string; private readonly subtitle: string; + private readonly existingApiKey: string | undefined; private done = false; private emptyHinted = false; @@ -57,12 +62,19 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { platformName: string, onDone: (result: ApiKeyInputResult) => void, colors: ColorPalette, + options: ApiKeyInputDialogOptions = {}, ) { super(); this.onDone = onDone; this.colors = colors; + const existingApiKey = options.existingApiKey?.trim(); + this.existingApiKey = + existingApiKey !== undefined && existingApiKey.length > 0 ? existingApiKey : undefined; this.title = `Enter API key for ${platformName}`; - this.subtitle = 'Your key will be saved to ~/.kimi-code/config.toml'; + this.subtitle = + this.existingApiKey === undefined + ? 'Your key will be saved to ~/.kimi-code/config.toml' + : 'Press Enter to keep the existing key, or type a new one.'; this.input.onSubmit = (value) => { this.submit(value); }; @@ -133,6 +145,11 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { if (this.done) return; const trimmed = value.trim(); if (trimmed.length === 0) { + if (this.existingApiKey !== undefined) { + this.done = true; + this.onDone({ kind: 'ok', value: this.existingApiKey }); + return; + } this.emptyHinted = true; return; } diff --git a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts index d41d68a8..62c3d1b2 100644 --- a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts @@ -1,6 +1,7 @@ import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it, vi } from 'vitest'; +import { ApiKeyInputDialogComponent } from '#/tui/components/dialogs/api-key-input-dialog'; import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker'; import { EditorSelectorComponent } from '#/tui/components/dialogs/editor-selector'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; @@ -214,6 +215,41 @@ function rendered(component: { render: (w: number) => string[] }, width = 80): s return component.render(width).map(strip).join('\n'); } +describe('ApiKeyInputDialogComponent', () => { + it('rejects empty input when no existing key is available', () => { + const onDone = vi.fn(); + const dialog = new ApiKeyInputDialogComponent('Acme', onDone, darkColors); + + dialog.handleInput(ENTER); + + expect(onDone).not.toHaveBeenCalled(); + expect(rendered(dialog)).toContain('API key cannot be empty.'); + }); + + it('keeps the existing key when submitted empty', () => { + const onDone = vi.fn(); + const dialog = new ApiKeyInputDialogComponent('Acme', onDone, darkColors, { + existingApiKey: 'sk-existing', + }); + + dialog.handleInput(ENTER); + + expect(onDone).toHaveBeenCalledWith({ kind: 'ok', value: 'sk-existing' }); + }); + + it('submits a new key over the existing key when the user types one', () => { + const onDone = vi.fn(); + const dialog = new ApiKeyInputDialogComponent('Acme', onDone, darkColors, { + existingApiKey: 'sk-existing', + }); + + for (const ch of 'sk-new') dialog.handleInput(ch); + dialog.handleInput(ENTER); + + expect(onDone).toHaveBeenCalledWith({ kind: 'ok', value: 'sk-new' }); + }); +}); + describe('ChoicePickerComponent search and pagination', () => { function makePicker(over: { options?: { value: string; label: string }[]; searchable?: boolean }) { const onSelect = vi.fn(); From 052763d38291730ff05e513a7e402a7f0f5911e8 Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Thu, 28 May 2026 21:24:57 +0800 Subject: [PATCH 04/10] feat(tui): add catalog model multi-select component --- .../dialogs/catalog-model-multi-select.ts | 232 ++++++++++++++++++ .../catalog-model-multi-select.test.ts | 226 +++++++++++++++++ 2 files changed, 458 insertions(+) create mode 100644 apps/kimi-code/src/tui/components/dialogs/catalog-model-multi-select.ts create mode 100644 apps/kimi-code/test/tui/components/dialogs/catalog-model-multi-select.test.ts diff --git a/apps/kimi-code/src/tui/components/dialogs/catalog-model-multi-select.ts b/apps/kimi-code/src/tui/components/dialogs/catalog-model-multi-select.ts new file mode 100644 index 00000000..5df14335 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/catalog-model-multi-select.ts @@ -0,0 +1,232 @@ +/** + * Multi-select model picker used by /connect. Sibling to ModelSelectorComponent + * (single-select, /model). Reuses SearchableList for cursor/search/paging and + * the shared model-domain helpers in ./model-choice. + */ + +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { + Container, + Key, + matchesKey, + truncateToWidth, + type Focusable, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; +import { printableChar } from '#/tui/utils/printable-key'; +import { SearchableList } from '#/tui/utils/searchable-list'; + +import { + createModelChoices, + effectiveThinking, + renderThinkingControl, + thinkingAvailability, + type ModelChoice, +} from './model-choice'; + +export interface ModelMultiSelection { + /** Checked model aliases, in the order the user checked them. */ + readonly aliases: readonly string[]; + readonly defaultAlias: string; + readonly thinking: boolean; +} + +export interface CatalogModelMultiSelectOptions { + readonly models: Record; + readonly currentThinking: boolean; + readonly colors: ColorPalette; + readonly selectedAliases?: readonly string[]; + readonly defaultAlias?: string; + /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ + readonly searchable?: boolean; + /** Items per page. Lists longer than this paginate (PgUp/PgDn). */ + readonly pageSize?: number; + readonly onSelect: (selection: ModelMultiSelection) => void; + readonly onCancel: () => void; +} + +export class CatalogModelMultiSelectComponent extends Container implements Focusable { + focused = false; + private readonly opts: CatalogModelMultiSelectOptions; + private readonly list: SearchableList; + // Insertion order is preserved, so iteration yields the user's check order + // and the first entry is the default model unless one is set explicitly. + private readonly checked = new Set(); + // The alias the user explicitly promoted to default via Tab. Cleared when + // that alias is unchecked. Undefined → use first-checked. + private explicitDefault?: string; + private thinkingDraft: boolean; + + constructor(opts: CatalogModelMultiSelectOptions) { + super(); + this.opts = opts; + const choices = createModelChoices(opts.models); + const availableAliases = new Set(choices.map((choice) => choice.alias)); + for (const alias of opts.selectedAliases ?? []) { + if (availableAliases.has(alias)) this.checked.add(alias); + } + if (opts.defaultAlias !== undefined && this.checked.has(opts.defaultAlias)) { + this.explicitDefault = opts.defaultAlias; + } + const initialAlias = this.defaultAlias(); + const initialIndex = + initialAlias !== undefined + ? choices.findIndex((choice) => choice.alias === initialAlias) + : -1; + this.list = new SearchableList({ + items: choices, + toSearchText: (c) => c.label, + pageSize: opts.pageSize, + initialIndex: initialIndex >= 0 ? initialIndex : undefined, + searchable: opts.searchable === true, + }); + this.thinkingDraft = opts.currentThinking; + } + + /** Tab-promoted (if still checked) → first-checked → undefined. */ + private defaultAlias(): string | undefined { + if (this.explicitDefault !== undefined && this.checked.has(this.explicitDefault)) { + return this.explicitDefault; + } + return this.checked.values().next().value; + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + if (this.list.clearQuery()) return; + this.opts.onCancel(); + return; + } + // Decode first — under Kitty CSI-u, Space isn't a raw ' '. + if (printableChar(data) === ' ') { + const highlighted = this.list.selected(); + if (highlighted !== undefined) this.toggle(highlighted.alias); + return; + } + // Default must be among the written models, so promoting also checks. + if (matchesKey(data, Key.tab)) { + const highlighted = this.list.selected(); + if (highlighted !== undefined) this.setDefault(highlighted.alias); + return; + } + // Left/Right toggle thinking for the default model (only when it supports + // toggling); paging stays on PgUp/PgDn so the arrows control thinking. + const targetAlias = this.defaultAlias(); + const target = targetAlias !== undefined ? this.opts.models[targetAlias] : undefined; + if (target !== undefined && thinkingAvailability(target) === 'toggle') { + if (matchesKey(data, Key.left)) { + this.thinkingDraft = true; + return; + } + if (matchesKey(data, Key.right)) { + this.thinkingDraft = false; + return; + } + } + if (matchesKey(data, Key.enter)) { + this.submit(); + return; + } + this.list.handleKey(data); + } + + private toggle(alias: string): void { + if (this.checked.has(alias)) { + this.checked.delete(alias); + if (this.explicitDefault === alias) this.explicitDefault = undefined; + } else { + this.checked.add(alias); + } + } + + private setDefault(alias: string): void { + // add() on an already-checked alias keeps its original position, so + // promoting to default never reorders the written models. + this.checked.add(alias); + this.explicitDefault = alias; + } + + private submit(): void { + // Enter only confirms an already-built selection. Without an explicit + // Space/Tab the picker stays open — never silently include the highlighted + // row, since that would contradict the "only the models you check are + // written" guarantee. + if (this.checked.size === 0) return; + const aliases = [...this.checked]; + const defaultAlias = this.defaultAlias(); + if (defaultAlias === undefined) return; + const target = this.opts.models[defaultAlias]; + const thinking = target !== undefined ? effectiveThinking(target, this.thinkingDraft) : false; + this.opts.onSelect({ aliases, defaultAlias, thinking }); + } + + override render(width: number): string[] { + const { colors } = this.opts; + const searchable = this.opts.searchable === true; + const view = this.list.view(); + const choices = view.items; + const defaultAlias = this.defaultAlias(); + + const navParts = ['↑↓ navigate', 'Space select', 'Tab default', '←→ thinking']; + if (view.page.pageCount > 1) navParts.push('PgUp/PgDn page'); + navParts.push('Enter confirm', 'Esc cancel'); + + const titleSuffix = + searchable && view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : ''; + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Select one or more models') + titleSuffix, + ]; + if (searchable && view.query.length > 0) { + lines.push(chalk.hex(colors.primary)(' Search: ') + chalk.hex(colors.text)(view.query)); + } + lines.push(chalk.hex(colors.textMuted)(` ${navParts.join(' · ')}`)); + lines.push(''); + + if (choices.length === 0) { + lines.push(chalk.hex(colors.textMuted)(' No matches')); + } + for (let i = view.page.start; i < view.page.end; i++) { + const choice = choices[i]!; + const isHighlighted = i === view.selectedIndex; + const isChecked = this.checked.has(choice.alias); + const pointer = isHighlighted ? '❯' : ' '; + const checkbox = isChecked ? '[x]' : '[ ]'; + const labelStyle = isHighlighted ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + let line = chalk.hex(isHighlighted ? colors.primary : colors.textDim)(` ${pointer} `); + line += chalk.hex(isChecked ? colors.success : colors.textDim)(`${checkbox} `); + line += labelStyle(choice.label); + if (choice.alias === defaultAlias && this.checked.size > 0) { + line += ' ' + chalk.hex(colors.success)('← default'); + } + lines.push(line); + } + + lines.push(''); + if (this.checked.size === 0) { + lines.push( + chalk.hex(colors.textMuted)( + ' Press Space to select at least one model — Tab makes the highlighted one the default.', + ), + ); + } else { + lines.push(chalk.hex(colors.textMuted)(' Thinking')); + const target = defaultAlias !== undefined ? this.opts.models[defaultAlias] : undefined; + if (target !== undefined) { + lines.push(renderThinkingControl(target, this.thinkingDraft, colors)); + } + } + lines.push(''); + if (view.page.pageCount > 1) { + lines.push( + chalk.hex(colors.textMuted)( + ` Page ${String(view.page.page + 1)}/${String(view.page.pageCount)}`, + ), + ); + } + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } +} diff --git a/apps/kimi-code/test/tui/components/dialogs/catalog-model-multi-select.test.ts b/apps/kimi-code/test/tui/components/dialogs/catalog-model-multi-select.test.ts new file mode 100644 index 00000000..a338dae0 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/catalog-model-multi-select.test.ts @@ -0,0 +1,226 @@ +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { CatalogModelMultiSelectComponent } from '#/tui/components/dialogs/catalog-model-multi-select'; +import { darkColors } from '#/tui/theme/colors'; + +const ANSI_SGR = /\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function rendered(component: { render: (w: number) => string[] }, width = 80): string { + return component.render(width).map(strip).join('\n'); +} + +const ESC = String.fromCodePoint(27); +const ENTER = String.fromCodePoint(13); +const SPACE = ' '; +const TAB = String.fromCodePoint(9); +const DOWN = `${ESC}[B`; +const UP = `${ESC}[A`; + +describe('CatalogModelMultiSelectComponent', () => { + function buildModels(): Record { + return { + 'prov/alpha': { + provider: 'prov', + model: 'alpha', + maxContextSize: 1000, + displayName: 'Alpha', + capabilities: ['thinking'], + }, + 'prov/beta': { + provider: 'prov', + model: 'beta', + maxContextSize: 1000, + displayName: 'Beta', + capabilities: ['thinking'], + }, + 'prov/gamma': { + provider: 'prov', + model: 'gamma', + maxContextSize: 1000, + displayName: 'Gamma', + capabilities: ['thinking'], + }, + }; + } + + function makeSelector( + initial: { selectedAliases?: readonly string[]; defaultAlias?: string } = {}, + ) { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const selector = new CatalogModelMultiSelectComponent({ + models: buildModels(), + currentThinking: true, + colors: darkColors, + selectedAliases: initial.selectedAliases, + defaultAlias: initial.defaultAlias, + searchable: true, + onSelect, + onCancel, + }); + return { selector, onSelect, onCancel }; + } + + it('keeps checked models when the search query changes', () => { + const { selector } = makeSelector(); + selector.handleInput(SPACE); // check Alpha (highlighted) + + for (const ch of 'beta') selector.handleInput(ch); // filter to Beta + selector.handleInput(SPACE); // check Beta + + selector.handleInput(ESC); // clear the query, restore full list + const out = rendered(selector); + expect(out).toContain('[x] Alpha (prov)'); + expect(out).toContain('[x] Beta (prov)'); + expect(out).toContain('[ ] Gamma (prov)'); + }); + + it('returns aliases in check order with the first checked as default', () => { + const { selector, onSelect } = makeSelector(); + selector.handleInput(DOWN); // highlight Beta + selector.handleInput(SPACE); // check Beta first + selector.handleInput(UP); // highlight Alpha + selector.handleInput(SPACE); // check Alpha second + selector.handleInput(ENTER); + + expect(onSelect).toHaveBeenCalledWith({ + aliases: ['prov/beta', 'prov/alpha'], + defaultAlias: 'prov/beta', + thinking: true, + }); + }); + + it('preselects configured aliases and ignores aliases outside the catalog', () => { + const { selector, onSelect } = makeSelector({ + selectedAliases: ['prov/beta', 'prov/missing'], + }); + + const out = rendered(selector); + expect(out).toContain('[ ] Alpha (prov)'); + expect(out).toContain('[x] Beta (prov)'); + expect(out).toContain('[ ] Gamma (prov)'); + expect(out).toContain('❯ [x] Beta (prov) ← default'); + + selector.handleInput(ENTER); + + expect(onSelect).toHaveBeenCalledWith({ + aliases: ['prov/beta'], + defaultAlias: 'prov/beta', + thinking: true, + }); + }); + + it('uses an initial default alias only when it is checked', () => { + const { selector, onSelect } = makeSelector({ + selectedAliases: ['prov/alpha', 'prov/beta'], + defaultAlias: 'prov/beta', + }); + + const defaults = rendered(selector) + .split('\n') + .filter((line) => line.includes('← default')); + expect(defaults).toHaveLength(1); + expect(defaults[0]).toContain('❯ [x] Beta (prov)'); + + selector.handleInput(ENTER); + + expect(onSelect).toHaveBeenCalledWith({ + aliases: ['prov/alpha', 'prov/beta'], + defaultAlias: 'prov/beta', + thinking: true, + }); + }); + + it('ignores an initial default alias that is not checked', () => { + const { selector, onSelect } = makeSelector({ + selectedAliases: ['prov/alpha'], + defaultAlias: 'prov/beta', + }); + + const defaults = rendered(selector) + .split('\n') + .filter((line) => line.includes('← default')); + expect(defaults).toHaveLength(1); + expect(defaults[0]).toContain('❯ [x] Alpha (prov)'); + + selector.handleInput(ENTER); + + expect(onSelect).toHaveBeenCalledWith({ + aliases: ['prov/alpha'], + defaultAlias: 'prov/alpha', + thinking: true, + }); + }); + + it('Enter does nothing when no model is checked', () => { + const { selector, onSelect, onCancel } = makeSelector(); + selector.handleInput(DOWN); // highlight Beta but don't check it + selector.handleInput(ENTER); + + expect(onSelect).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('shows an empty-state hint until a model is checked', () => { + const { selector } = makeSelector(); + expect(rendered(selector)).toContain('Press Space to select at least one model'); + + selector.handleInput(SPACE); // check Alpha + expect(rendered(selector)).not.toContain('Press Space to select at least one model'); + }); + + it('Tab promotes the highlighted model to default, auto-checks it, and marks it in the render', () => { + const { selector, onSelect } = makeSelector(); + selector.handleInput(DOWN); // highlight Beta + selector.handleInput(TAB); // promote Beta to default (auto-checks it) + + const defaultLines = rendered(selector) + .split('\n') + .filter((line) => line.includes('← default')); + expect(defaultLines).toHaveLength(1); + expect(defaultLines[0]).toContain('Beta (prov)'); + + selector.handleInput(ENTER); + expect(onSelect).toHaveBeenCalledWith({ + aliases: ['prov/beta'], + defaultAlias: 'prov/beta', + thinking: true, + }); + }); + + it('Tab overrides the first-checked default without reordering aliases', () => { + const { selector, onSelect } = makeSelector(); + selector.handleInput(SPACE); // check Alpha first + selector.handleInput(DOWN); // highlight Beta + selector.handleInput(SPACE); // check Beta second + selector.handleInput(TAB); // promote Beta to default + selector.handleInput(ENTER); + + expect(onSelect).toHaveBeenCalledWith({ + aliases: ['prov/alpha', 'prov/beta'], + defaultAlias: 'prov/beta', + thinking: true, + }); + }); + + it('reverts to the first-checked default when the promoted model is unchecked', () => { + const { selector, onSelect } = makeSelector(); + selector.handleInput(SPACE); // check Alpha first + selector.handleInput(DOWN); // highlight Beta + selector.handleInput(SPACE); // check Beta + selector.handleInput(TAB); // promote Beta to default + selector.handleInput(SPACE); // uncheck Beta, clearing the explicit default + selector.handleInput(ENTER); + + expect(onSelect).toHaveBeenCalledWith({ + aliases: ['prov/alpha'], + defaultAlias: 'prov/alpha', + thinking: true, + }); + }); +}); From 737dcc93ff90354058bed6ca736b76b69f63440d Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Thu, 28 May 2026 21:26:22 +0800 Subject: [PATCH 05/10] feat(tui): project config into /connect picker initial state --- .../src/tui/utils/connect-catalog.ts | 69 ++++++- .../test/tui/utils/connect-catalog.test.ts | 189 +++++++++++++++++- 2 files changed, 255 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/utils/connect-catalog.ts b/apps/kimi-code/src/tui/utils/connect-catalog.ts index dfd86bda..4487b880 100644 --- a/apps/kimi-code/src/tui/utils/connect-catalog.ts +++ b/apps/kimi-code/src/tui/utils/connect-catalog.ts @@ -1,4 +1,4 @@ -import { DEFAULT_CATALOG_URL } from '@moonshot-ai/kimi-code-sdk'; +import { DEFAULT_CATALOG_URL, type CatalogModel, type KimiConfig } from '@moonshot-ai/kimi-code-sdk'; const BARE_HTTP_URL_RE = /^https?:\/\/\S+$/; @@ -80,3 +80,70 @@ export function resolveConnectCatalogRequest(args: string): ConnectCatalogResolu }, }; } + +export interface CatalogModelSelectionInitialState { + readonly selectedAliases: readonly string[]; + readonly defaultAlias?: string; + readonly thinking?: boolean; +} + +export function catalogProviderExistingApiKey( + providerId: string, + config: KimiConfig, +): string | undefined { + const apiKey = config.providers[providerId]?.apiKey?.trim(); + return apiKey !== undefined && apiKey.length > 0 ? apiKey : undefined; +} + +/** + * Project the current config into the initial state for the /connect + * multi-select picker: which catalog aliases are already configured for this + * provider, which (if any) is the default, and whether the saved default has + * thinking on. Aliases that no longer exist in the catalog are dropped. + */ +export function catalogModelSelectionInitialState( + providerId: string, + models: readonly CatalogModel[], + config: KimiConfig, +): CatalogModelSelectionInitialState { + const aliasByModelId = new Map(models.map((model) => [model.id, `${providerId}/${model.id}`])); + const selectedAliases: string[] = []; + const seen = new Set(); + for (const model of Object.values(config.models ?? {})) { + if (model.provider !== providerId) continue; + const alias = aliasByModelId.get(model.model); + if (alias !== undefined && !seen.has(alias)) { + selectedAliases.push(alias); + seen.add(alias); + } + } + + let defaultAlias: string | undefined; + const defaultModel = + config.defaultModel !== undefined ? config.models?.[config.defaultModel] : undefined; + if (defaultModel?.provider === providerId) { + const alias = aliasByModelId.get(defaultModel.model); + if (alias !== undefined && seen.has(alias)) defaultAlias = alias; + } + + return { + selectedAliases, + defaultAlias, + thinking: defaultAlias !== undefined ? config.defaultThinking : undefined, + }; +} + +/** + * Map providerId → number of models wired up to that provider in `config`. + * Only providers that also have a `[providers.]` entry are included, so + * orphan model aliases (whose provider block was hand-deleted) don't get + * badged as configured in the picker. + */ +export function configuredProviderModelCounts(config: KimiConfig): ReadonlyMap { + const counts = new Map(); + for (const model of Object.values(config.models ?? {})) { + if (config.providers[model.provider] === undefined) continue; + counts.set(model.provider, (counts.get(model.provider) ?? 0) + 1); + } + return counts; +} diff --git a/apps/kimi-code/test/tui/utils/connect-catalog.test.ts b/apps/kimi-code/test/tui/utils/connect-catalog.test.ts index 98ece0d2..8ab00374 100644 --- a/apps/kimi-code/test/tui/utils/connect-catalog.test.ts +++ b/apps/kimi-code/test/tui/utils/connect-catalog.test.ts @@ -2,11 +2,21 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { DEFAULT_CATALOG_URL, loadBuiltInCatalog } from '@moonshot-ai/kimi-code-sdk'; +import { + DEFAULT_CATALOG_URL, + loadBuiltInCatalog, + type CatalogModel, + type KimiConfig, +} from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it } from 'vitest'; import { BUILT_IN_CATALOG_JSON } from '#/built-in-catalog'; -import { resolveConnectCatalogRequest } from '#/tui/utils/connect-catalog'; +import { + catalogProviderExistingApiKey, + catalogModelSelectionInitialState, + configuredProviderModelCounts, + resolveConnectCatalogRequest, +} from '#/tui/utils/connect-catalog'; import { builtInCatalogDefine } from '../../../scripts/built-in-catalog.mjs'; @@ -106,6 +116,181 @@ describe('resolveConnectCatalogRequest', () => { }); }); +describe('catalogModelSelectionInitialState', () => { + function model(id: string): CatalogModel { + return { id, capability: { id, contextWindow: 1000 } } as unknown as CatalogModel; + } + + function config(over: Partial): KimiConfig { + return { providers: {}, ...over } as KimiConfig; + } + + const models = [model('large'), model('mini'), model('nano')]; + + it('returns empty state when no config models match the provider', () => { + expect( + catalogModelSelectionInitialState( + 'acme', + models, + config({ + models: { 'other/x': { provider: 'other', model: 'x', maxContextSize: 1 } }, + }), + ), + ).toEqual({ selectedAliases: [], defaultAlias: undefined, thinking: undefined }); + }); + + it('preselects every alias the config wires to this provider, in config order', () => { + const result = catalogModelSelectionInitialState( + 'acme', + models, + config({ + models: { + 'acme/nano': { provider: 'acme', model: 'nano', maxContextSize: 1 }, + 'acme/large': { provider: 'acme', model: 'large', maxContextSize: 1 }, + }, + }), + ); + + expect(result.selectedAliases).toEqual(['acme/nano', 'acme/large']); + expect(result.defaultAlias).toBeUndefined(); + expect(result.thinking).toBeUndefined(); + }); + + it('drops config entries whose model is no longer in the catalog', () => { + const result = catalogModelSelectionInitialState( + 'acme', + models, + config({ + models: { + 'acme/mini': { provider: 'acme', model: 'mini', maxContextSize: 1 }, + 'acme/legacy': { provider: 'acme', model: 'legacy', maxContextSize: 1 }, + }, + }), + ); + + expect(result.selectedAliases).toEqual(['acme/mini']); + }); + + it('promotes defaultModel to defaultAlias and carries defaultThinking', () => { + const result = catalogModelSelectionInitialState( + 'acme', + models, + config({ + models: { + 'acme/mini': { provider: 'acme', model: 'mini', maxContextSize: 1 }, + 'acme/large': { provider: 'acme', model: 'large', maxContextSize: 1 }, + }, + defaultModel: 'acme/large', + defaultThinking: true, + }), + ); + + expect(result.defaultAlias).toBe('acme/large'); + expect(result.thinking).toBe(true); + }); + + it('ignores defaultModel that belongs to another provider', () => { + const result = catalogModelSelectionInitialState( + 'acme', + models, + config({ + models: { + 'acme/mini': { provider: 'acme', model: 'mini', maxContextSize: 1 }, + 'other/x': { provider: 'other', model: 'x', maxContextSize: 1 }, + }, + defaultModel: 'other/x', + defaultThinking: true, + }), + ); + + expect(result.selectedAliases).toEqual(['acme/mini']); + expect(result.defaultAlias).toBeUndefined(); + // No default for this provider → don't carry thinking either. + expect(result.thinking).toBeUndefined(); + }); +}); + +describe('catalogProviderExistingApiKey', () => { + function config(over: Partial): KimiConfig { + return { providers: {}, ...over } as KimiConfig; + } + + it('returns a trimmed existing provider apiKey', () => { + expect( + catalogProviderExistingApiKey( + 'acme', + config({ providers: { acme: { type: 'openai', apiKey: ' sk-existing ' } } }), + ), + ).toBe('sk-existing'); + }); + + it('ignores missing and empty provider apiKey values', () => { + expect( + catalogProviderExistingApiKey( + 'acme', + config({ providers: { acme: { type: 'openai', apiKey: ' ' } } }), + ), + ).toBeUndefined(); + expect( + catalogProviderExistingApiKey( + 'acme', + config({ providers: { other: { type: 'openai', apiKey: 'sk-other' } } }), + ), + ).toBeUndefined(); + }); +}); + +describe('configuredProviderModelCounts', () => { + function config(over: Partial): KimiConfig { + return { providers: {}, ...over } as KimiConfig; + } + + it('returns an empty map for an empty config', () => { + expect(configuredProviderModelCounts(config({}))).toEqual(new Map()); + }); + + it('excludes providers that have an entry but no models wired up', () => { + const counts = configuredProviderModelCounts( + config({ + providers: { acme: { type: 'openai', apiKey: 'k' } }, + models: {}, + }), + ); + expect(counts.size).toBe(0); + }); + + it('excludes orphan models whose provider block was hand-deleted', () => { + const counts = configuredProviderModelCounts( + config({ + providers: {}, + models: { 'ghost/mini': { provider: 'ghost', model: 'mini', maxContextSize: 1 } }, + }), + ); + expect(counts.size).toBe(0); + }); + + it('counts models per provider, only when the provider block also exists', () => { + const counts = configuredProviderModelCounts( + config({ + providers: { + acme: { type: 'openai', apiKey: 'a' }, + openai: { type: 'openai', apiKey: 'b' }, + }, + models: { + 'acme/large': { provider: 'acme', model: 'large', maxContextSize: 1 }, + 'acme/mini': { provider: 'acme', model: 'mini', maxContextSize: 1 }, + 'openai/gpt': { provider: 'openai', model: 'gpt', maxContextSize: 1 }, + 'ghost/x': { provider: 'ghost', model: 'x', maxContextSize: 1 }, + }, + }), + ); + expect(counts.get('acme')).toBe(2); + expect(counts.get('openai')).toBe(1); + expect(counts.has('ghost')).toBe(false); + expect(counts.size).toBe(2); + }); +}); + describe('built-in connect catalog injection', () => { it('keeps the source placeholder empty so generated catalog data is not committed', () => { expect(BUILT_IN_CATALOG_JSON).toBeUndefined(); From 91ebd4e6107ee117bdac4514d1a37589f9ce9a05 Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Thu, 28 May 2026 21:30:09 +0800 Subject: [PATCH 06/10] feat(tui): /connect uses multi-select with preselection and badges --- apps/kimi-code/src/tui/commands/auth.ts | 24 +- apps/kimi-code/src/tui/commands/prompts.ts | 110 ++++- .../test/tui/kimi-tui-message-flow.test.ts | 403 +++++++++++++++++- 3 files changed, 509 insertions(+), 28 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/auth.ts b/apps/kimi-code/src/tui/commands/auth.ts index e9e8304c..d9a49968 100644 --- a/apps/kimi-code/src/tui/commands/auth.ts +++ b/apps/kimi-code/src/tui/commands/auth.ts @@ -23,7 +23,10 @@ import { import { BUILT_IN_CATALOG_JSON } from '../../built-in-catalog'; import type { ChoiceOption } from '../components/dialogs/choice-picker'; import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '../constant/kimi-tui'; -import { resolveConnectCatalogRequest } from '../utils/connect-catalog'; +import { + catalogProviderExistingApiKey, + resolveConnectCatalogRequest, +} from '../utils/connect-catalog'; import { formatErrorMessage } from '../utils/event-payload'; import type { LoginProgressSpinnerHandle } from '../types'; import { @@ -237,7 +240,9 @@ export async function handleConnectCommand(host: SlashCommandHost, args: string) if (catalog === undefined) return; - const providerId = await promptCatalogProviderSelection(host, catalog); + const existingConfig = await host.harness.getConfig(); + + const providerId = await promptCatalogProviderSelection(host, catalog, existingConfig); if (providerId === undefined) return; const entry = catalog[providerId]; if (entry === undefined) return; @@ -248,17 +253,18 @@ export async function handleConnectCommand(host: SlashCommandHost, args: string) return; } - const selection = await promptModelSelectionForCatalog(host, providerId, models); + const selection = await promptModelSelectionForCatalog(host, providerId, models, existingConfig); if (selection === undefined) return; - const apiKey = await promptApiKey(host, entry.name ?? providerId); + const apiKey = await promptApiKey(host, entry.name ?? providerId, { + existingApiKey: catalogProviderExistingApiKey(providerId, existingConfig), + }); if (apiKey === undefined) return; const wire = inferWireType(entry); if (wire === undefined) return; const baseUrl = catalogBaseUrl(entry, wire); - const existingConfig = await host.harness.getConfig(); if (existingConfig.providers[providerId] !== undefined) { await host.harness.removeProvider(providerId); } @@ -269,8 +275,8 @@ export async function handleConnectCommand(host: SlashCommandHost, args: string) wire, baseUrl, apiKey, - models, - selectedModelId: selection.model.id, + models: selection.models, + selectedModelId: selection.defaultModelId, thinking: selection.thinking, }); @@ -282,8 +288,8 @@ export async function handleConnectCommand(host: SlashCommandHost, args: string) }); await host.authFlow.refreshConfigAfterLogin(); - host.track('connect', { provider: providerId, model: selection.model.id }); - host.showStatus(`Connected: ${entry.name ?? providerId} · ${selection.model.id}`); + host.track('connect', { provider: providerId, model: selection.defaultModelId }); + host.showStatus(`Connected: ${entry.name ?? providerId} · ${selection.defaultModelId}`); } export async function handleLogoutCommand(host: SlashCommandHost): Promise { diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index dbc86a25..eb3069b6 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -3,6 +3,7 @@ import { inferWireType, type Catalog, type CatalogModel, + type KimiConfig, type ModelAlias, } from '@moonshot-ai/kimi-code-sdk'; import { capabilitiesForModel } from '@moonshot-ai/kimi-code-oauth'; @@ -11,11 +12,24 @@ import type { OpenPlatformDefinition, } from '@moonshot-ai/kimi-code-oauth'; -import { ApiKeyInputDialogComponent, type ApiKeyInputResult } from '../components/dialogs/api-key-input-dialog'; +import { + ApiKeyInputDialogComponent, + type ApiKeyInputDialogOptions, + type ApiKeyInputResult, +} from '../components/dialogs/api-key-input-dialog'; +import { + CatalogModelMultiSelectComponent, + type ModelMultiSelection, +} from '../components/dialogs/catalog-model-multi-select'; import { ChoicePickerComponent, type ChoiceOption } from '../components/dialogs/choice-picker'; import { FeedbackInputDialogComponent, type FeedbackInputDialogResult } from '../components/dialogs/feedback-input-dialog'; import { ModelSelectorComponent } from '../components/dialogs/model-selector'; import { PlatformSelectorComponent } from '../components/dialogs/platform-selector'; +import { + catalogModelSelectionInitialState, + configuredProviderModelCounts, + type CatalogModelSelectionInitialState, +} from '../utils/connect-catalog'; import type { SlashCommandHost } from './dispatch'; export function promptPlatformSelection(host: SlashCommandHost): Promise { @@ -69,7 +83,11 @@ export function promptFeedbackInput(host: SlashCommandHost): Promise { +export function promptApiKey( + host: SlashCommandHost, + platformName: string, + options: ApiKeyInputDialogOptions = {}, +): Promise { return new Promise((resolve) => { const dialog = new ApiKeyInputDialogComponent( platformName, @@ -78,29 +96,46 @@ export function promptApiKey(host: SlashCommandHost, platformName: string): Prom resolve(result.kind === 'ok' ? result.value : undefined); }, host.state.theme.colors, + options, ); host.mountEditorReplacement(dialog); }); } -export function promptCatalogProviderSelection(host: SlashCommandHost, catalog: Catalog): Promise { - return new Promise((resolve) => { - const options: ChoiceOption[] = Object.entries(catalog) - .filter(([, entry]) => inferWireType(entry) !== undefined) - .map(([id, entry]) => ({ +export function promptCatalogProviderSelection( + host: SlashCommandHost, + catalog: Catalog, + config: KimiConfig, +): Promise { + const counts = configuredProviderModelCounts(config); + const formatBadge = (count: number): string => + `← configured · ${String(count)} model${count === 1 ? '' : 's'}`; + + const options: ChoiceOption[] = Object.entries(catalog) + .filter(([, entry]) => inferWireType(entry) !== undefined) + .map(([id, entry]) => { + const count = counts.get(id); + return { value: id, label: entry.name ?? id, description: typeof entry.api === 'string' && entry.api.length > 0 ? entry.api : undefined, - })) - .toSorted((a, b) => a.label.localeCompare(b.label)); + badge: count !== undefined ? formatBadge(count) : undefined, + }; + }) + .toSorted((a, b) => { + const aConfigured = a.badge !== undefined; + const bConfigured = b.badge !== undefined; + if (aConfigured !== bConfigured) return aConfigured ? -1 : 1; + return a.label.localeCompare(b.label); + }); - if (options.length === 0) { - host.showError('Catalog has no providers with supported wire types.'); - resolve(undefined); - return; - } + if (options.length === 0) { + host.showError('Catalog has no providers with supported wire types.'); + return Promise.resolve(undefined); + } + return new Promise((resolve) => { const picker = new ChoicePickerComponent({ title: 'Select a provider', options, @@ -144,15 +179,54 @@ export async function promptModelSelectionForCatalog( host: SlashCommandHost, providerId: string, models: CatalogModel[], -): Promise<{ model: CatalogModel; thinking: boolean } | undefined> { + config: KimiConfig, +): Promise<{ models: CatalogModel[]; defaultModelId: string; thinking: boolean } | undefined> { const modelDict: Record = {}; for (const m of models) { modelDict[`${providerId}/${m.id}`] = catalogModelToAlias(providerId, m); } - const selection = await runModelSelector(host, modelDict); + const initialSelection = catalogModelSelectionInitialState(providerId, models, config); + const selection = await runCatalogModelMultiSelect(host, modelDict, initialSelection); if (selection === undefined) return undefined; - const model = models.find((m) => `${providerId}/${m.id}` === selection.alias); - return model ? { model, thinking: selection.thinking } : undefined; + + const byAlias = new Map(models.map((m) => [`${providerId}/${m.id}`, m])); + const selectedModels = selection.aliases + .map((alias) => byAlias.get(alias)) + .filter((m): m is CatalogModel => m !== undefined); + const defaultModel = byAlias.get(selection.defaultAlias); + if (selectedModels.length === 0 || defaultModel === undefined) return undefined; + + return { + models: selectedModels, + defaultModelId: defaultModel.id, + thinking: selection.thinking, + }; +} + +function runCatalogModelMultiSelect( + host: SlashCommandHost, + modelDict: Record, + initialSelection: CatalogModelSelectionInitialState, +): Promise { + return new Promise((resolve) => { + const selector = new CatalogModelMultiSelectComponent({ + models: modelDict, + currentThinking: initialSelection.thinking ?? true, + selectedAliases: initialSelection.selectedAliases, + defaultAlias: initialSelection.defaultAlias, + colors: host.state.theme.colors, + searchable: true, + onSelect: (selection) => { + host.restoreEditor(); + resolve(selection); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(selector); + }); } export function runModelSelector( diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 28b6ea12..c15fc92e 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -10,7 +10,10 @@ import { import type { ApprovalRequest, ApprovalResponse, Event } from '@moonshot-ai/kimi-code-sdk'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ApiKeyInputDialogComponent } from '#/tui/components/dialogs/api-key-input-dialog'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; +import { CatalogModelMultiSelectComponent } from '#/tui/components/dialogs/catalog-model-multi-select'; +import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { PluginMcpSelectorComponent, @@ -26,12 +29,12 @@ import { runModelSelector, } from '#/tui/commands/prompts'; import type { QueuedMessage } from '#/tui/types'; +import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; vi.mock('#/tui/commands/prompts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, promptFeedbackInput: vi.fn() }; }); -import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; vi.mock('#/tui/utils/open-url', () => ({ openUrl: vi.fn() })); @@ -1733,6 +1736,404 @@ describe('KimiTUI message flow', () => { await expect(selection).resolves.toBeUndefined(); }); + it('/connect writes only the models the user selected, default = first checked', async () => { + const catalog = { + acme: { + id: 'acme', + name: 'Acme', + api: 'https://api.acme.com/v1', + type: 'openai', + models: { + 'acme-large': { + id: 'acme-large', + name: 'Acme Large', + limit: { context: 200000, output: 64000 }, + reasoning: true, + tool_call: true, + }, + 'acme-mini': { + id: 'acme-mini', + name: 'Acme Mini', + limit: { context: 100000 }, + tool_call: true, + }, + 'acme-nano': { + id: 'acme-nano', + name: 'Acme Nano', + limit: { context: 50000 }, + tool_call: true, + }, + }, + }, + }; + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify(catalog), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const originalFetch = global.fetch; + global.fetch = fetchMock as unknown as typeof fetch; + + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ providers: {}, models: {} })), + setConfig, + }); + + try { + driver.handleUserInput('/connect refresh'); + + // 1. Provider picker — one provider, select it. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); + }); + (driver.state.editorContainer.children[0] as ChoicePickerComponent).handleInput('\r'); + + // 2. Model multi-select — check acme-mini then acme-nano, leave acme-large out. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + CatalogModelMultiSelectComponent, + ); + }); + const modelPicker = driver.state.editorContainer + .children[0] as CatalogModelMultiSelectComponent; + modelPicker.handleInput(`${ESC}[B`); // highlight acme-mini + modelPicker.handleInput(' '); // check acme-mini (becomes default) + modelPicker.handleInput(`${ESC}[B`); // highlight acme-nano + modelPicker.handleInput(' '); // check acme-nano + modelPicker.handleInput('\r'); + + // 3. API key dialog — type and submit. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ApiKeyInputDialogComponent); + }); + const apiKeyDialog = driver.state.editorContainer.children[0] as ApiKeyInputDialogComponent; + apiKeyDialog.handleInput('\r'); + expect(stripSgr(apiKeyDialog.render(120).join('\n'))).toContain( + 'API key cannot be empty.', + ); + expect(setConfig).not.toHaveBeenCalled(); + + for (const ch of 'sk-test') apiKeyDialog.handleInput(ch); + apiKeyDialog.handleInput('\r'); + + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalled(); + }); + + const written = (setConfig.mock.calls[0] as unknown as [ + { + providers: Record; + models: Record; + defaultModel: string; + }, + ])[0]; + expect(Object.keys(written.models)).toEqual(['acme/acme-mini', 'acme/acme-nano']); + expect(written.models['acme/acme-large']).toBeUndefined(); + expect(written.defaultModel).toBe('acme/acme-mini'); + expect(written.providers['acme']).toMatchObject({ type: 'openai', apiKey: 'sk-test' }); + } finally { + global.fetch = originalFetch; + } + }); + + it('/connect preselects existing provider models and writes the updated selection', async () => { + const catalog = { + acme: { + id: 'acme', + name: 'Acme', + api: 'https://api.acme.com/v1', + type: 'openai', + models: { + 'acme-large': { + id: 'acme-large', + name: 'Acme Large', + limit: { context: 200000, output: 64000 }, + reasoning: true, + tool_call: true, + }, + 'acme-mini': { + id: 'acme-mini', + name: 'Acme Mini', + limit: { context: 100000 }, + tool_call: true, + }, + 'acme-nano': { + id: 'acme-nano', + name: 'Acme Nano', + limit: { context: 50000 }, + tool_call: true, + }, + }, + }, + }; + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify(catalog), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const originalFetch = global.fetch; + global.fetch = fetchMock as unknown as typeof fetch; + + const getConfig = vi.fn(async () => ({ + providers: { acme: { type: 'openai', apiKey: 'old-key' } }, + models: { + 'acme/acme-mini': { provider: 'acme', model: 'acme-mini', maxContextSize: 100000 }, + 'acme/acme-nano': { provider: 'acme', model: 'acme-nano', maxContextSize: 50000 }, + }, + defaultModel: 'acme/acme-nano', + defaultThinking: false, + })); + const removeProvider = vi.fn(async () => ({ providers: {}, models: {} })); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(makeSession(), { + getConfig, + removeProvider, + setConfig, + }); + + try { + driver.handleUserInput('/connect refresh'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); + }); + (driver.state.editorContainer.children[0] as ChoicePickerComponent).handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + CatalogModelMultiSelectComponent, + ); + }); + const modelPicker = driver.state.editorContainer + .children[0] as CatalogModelMultiSelectComponent; + const initialOutput = stripSgr(modelPicker.render(120).join('\n')); + expect(initialOutput).toContain('[ ] Acme Large (acme)'); + expect(initialOutput).toContain('[x] Acme Mini (acme)'); + expect(initialOutput).toContain('[x] Acme Nano (acme) ← default'); + expect(initialOutput).toContain('❯ [x] Acme Nano (acme) ← default'); + + modelPicker.handleInput(`${ESC}[A`); // highlight acme-mini + modelPicker.handleInput(' '); // remove old acme-mini + modelPicker.handleInput(`${ESC}[A`); // highlight acme-large + modelPicker.handleInput(' '); // add acme-large + modelPicker.handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ApiKeyInputDialogComponent); + }); + const apiKeyDialog = driver.state.editorContainer.children[0] as ApiKeyInputDialogComponent; + for (const ch of 'sk-new') apiKeyDialog.handleInput(ch); + apiKeyDialog.handleInput('\r'); + + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalled(); + }); + + expect(removeProvider).toHaveBeenCalledWith('acme'); + const written = (setConfig.mock.calls[0] as unknown as [ + { + providers: Record; + models: Record; + defaultModel: string; + defaultThinking: boolean; + }, + ])[0]; + expect(Object.keys(written.models)).toEqual(['acme/acme-nano', 'acme/acme-large']); + expect(written.defaultModel).toBe('acme/acme-nano'); + expect(written.defaultThinking).toBe(false); + expect(written.providers['acme']).toMatchObject({ type: 'openai', apiKey: 'sk-new' }); + } finally { + global.fetch = originalFetch; + } + }); + + it('/connect marks already-configured providers and floats them to the top', async () => { + const catalog = { + acme: { + id: 'acme', + name: 'Acme', + api: 'https://api.acme.com/v1', + type: 'openai', + models: { + 'acme-large': { + id: 'acme-large', + name: 'Acme Large', + limit: { context: 200000 }, + tool_call: true, + }, + 'acme-mini': { + id: 'acme-mini', + name: 'Acme Mini', + limit: { context: 100000 }, + tool_call: true, + }, + }, + }, + beacon: { + id: 'beacon', + name: 'Beacon', + api: 'https://api.beacon.com/v1', + type: 'openai', + models: { + 'beacon-pro': { + id: 'beacon-pro', + name: 'Beacon Pro', + limit: { context: 100000 }, + tool_call: true, + }, + }, + }, + zenith: { + id: 'zenith', + name: 'Zenith', + api: 'https://api.zenith.com/v1', + type: 'openai', + models: { + 'zenith-1': { + id: 'zenith-1', + name: 'Zenith One', + limit: { context: 50000 }, + tool_call: true, + }, + }, + }, + }; + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify(catalog), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const originalFetch = global.fetch; + global.fetch = fetchMock as unknown as typeof fetch; + + // Two configured providers (zenith with 1 model, acme with 2) and one + // unconfigured (beacon). Configured ones should float to the top with + // accurate counts; unconfigured ones stay in alphabetical order below. + const getConfig = vi.fn(async () => ({ + providers: { + acme: { type: 'openai', apiKey: 'a' }, + zenith: { type: 'openai', apiKey: 'z' }, + }, + models: { + 'acme/acme-large': { provider: 'acme', model: 'acme-large', maxContextSize: 200000 }, + 'acme/acme-mini': { provider: 'acme', model: 'acme-mini', maxContextSize: 100000 }, + 'zenith/zenith-1': { provider: 'zenith', model: 'zenith-1', maxContextSize: 50000 }, + }, + })); + const { driver } = await makeDriver(makeSession(), { getConfig }); + + try { + driver.handleUserInput('/connect refresh'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); + }); + const picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; + const out = stripSgr(picker.render(120).join('\n')); + + expect(out).toContain('Acme ← configured · 2 models'); + expect(out).toContain('Zenith ← configured · 1 model'); + expect(out).toContain('Beacon'); + expect(out).not.toContain('Beacon ← configured'); + + const acmeIdx = out.indexOf('Acme '); + const zenithIdx = out.indexOf('Zenith '); + const beaconIdx = out.indexOf('Beacon'); + expect(acmeIdx).toBeGreaterThan(-1); + expect(zenithIdx).toBeGreaterThan(-1); + expect(beaconIdx).toBeGreaterThan(-1); + expect(acmeIdx).toBeLessThan(zenithIdx); // configured group: alphabetical + expect(zenithIdx).toBeLessThan(beaconIdx); // configured group precedes unconfigured + } finally { + global.fetch = originalFetch; + } + }); + + it('/connect keeps an existing provider apiKey when submitted empty', async () => { + const catalog = { + acme: { + id: 'acme', + name: 'Acme', + api: 'https://api.acme.com/v1', + type: 'openai', + models: { + 'acme-mini': { + id: 'acme-mini', + name: 'Acme Mini', + limit: { context: 100000 }, + tool_call: true, + }, + }, + }, + }; + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify(catalog), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const originalFetch = global.fetch; + global.fetch = fetchMock as unknown as typeof fetch; + + const getConfig = vi.fn(async () => ({ + providers: { acme: { type: 'openai', apiKey: 'old-key' } }, + models: { + 'acme/acme-mini': { provider: 'acme', model: 'acme-mini', maxContextSize: 100000 }, + }, + defaultModel: 'acme/acme-mini', + defaultThinking: false, + })); + const removeProvider = vi.fn(async () => ({ providers: {}, models: {} })); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(makeSession(), { + getConfig, + removeProvider, + setConfig, + }); + + try { + driver.handleUserInput('/connect refresh'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); + }); + (driver.state.editorContainer.children[0] as ChoicePickerComponent).handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + CatalogModelMultiSelectComponent, + ); + }); + (driver.state.editorContainer.children[0] as CatalogModelMultiSelectComponent).handleInput( + '\r', + ); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ApiKeyInputDialogComponent); + }); + (driver.state.editorContainer.children[0] as ApiKeyInputDialogComponent).handleInput('\r'); + + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalled(); + }); + + const written = (setConfig.mock.calls[0] as unknown as [ + { providers: Record }, + ])[0]; + expect(written.providers['acme']?.apiKey).toBe('old-key'); + } finally { + global.fetch = originalFetch; + } + }); + it('deletes Kitty inline images when /new clears the transcript', async () => { setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true }); const { driver, harness } = await makeDriver(makeSession({ id: 'ses-1' })); From 187c5997f6c0416d60d94fe2ac22571a2fe89e9c Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Thu, 28 May 2026 21:33:35 +0800 Subject: [PATCH 07/10] docs: document /connect multi-select flow --- .changeset/connect-multi-select-models.md | 5 +++++ docs/en/configuration/providers.md | 2 +- docs/en/reference/slash-commands.md | 2 +- docs/zh/configuration/providers.md | 2 +- docs/zh/reference/slash-commands.md | 2 +- 5 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 .changeset/connect-multi-select-models.md diff --git a/.changeset/connect-multi-select-models.md b/.changeset/connect-multi-select-models.md new file mode 100644 index 00000000..c0970d5d --- /dev/null +++ b/.changeset/connect-multi-select-models.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +`/connect` now lets you search and check multiple models for a provider with Space, choose the default with Tab, and reuse existing provider details when reconnecting a provider. diff --git a/docs/en/configuration/providers.md b/docs/en/configuration/providers.md index 23247fa7..72201928 100644 --- a/docs/en/configuration/providers.md +++ b/docs/en/configuration/providers.md @@ -31,7 +31,7 @@ The most common ways to switch providers are: use the `/model` slash command ins ## `/connect` and the model catalog -Instead of writing `[providers.*]` and `[models.*]` tables by hand, run the `/connect` slash command inside the TUI to add a provider from a **model catalog**. The catalog lists known providers and models together with their context window, output limit, and capabilities. `/connect` prompts you to pick a provider and a model, asks for an API key, and writes the resulting `[providers.]` and `[models.]` entries to `config.toml`. +Instead of writing `[providers.*]` and `[models.*]` tables by hand, run the `/connect` slash command inside the TUI to add a provider from a **model catalog**. The catalog lists known providers and models together with their context window, output limit, and capabilities. `/connect` prompts you to pick a provider, then to select one or more of its models — type to filter the list, press `Space` to check each model you want, and press `Tab` to make the highlighted model the default. If you reconnect a provider that is already configured, models from that provider are preselected when they still exist in the current catalog, and pressing `Enter` on an empty API key prompt keeps the existing key. After you enter or keep an API key, it writes the `[providers.]` block and a `[models.]` entry for every checked model to `config.toml`, using your chosen default — or, if you didn't pick one, the first checked model — as the default model. Only the models you check are written. The default catalog is bundled with the CLI, so `/connect` works offline. Two flags change the catalog source: diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 26968e25..f7cc0e9f 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -14,7 +14,7 @@ Some commands are only available in the idle state. Running them while the sessi | --- | --- | --- | --- | | `/login` | — | Pick an account or platform and sign in: Kimi Code uses the OAuth device code flow, while the Moonshot AI Open Platform signs in with an API key. | No | | `/logout` | — | Clear the credentials of the currently selected account (Kimi Code OAuth credentials, or the corresponding open platform provider config). | No | -| `/connect [--refresh] [--url=]` | — | Configure a provider and model from a model catalog. The default catalog is bundled with the CLI; pass `--refresh` to fetch the latest catalog from models.dev, or `--url` to read it from a custom URL. See [Providers and models — `/connect` and the model catalog](../configuration/providers.md#connect-and-the-model-catalog). | No | +| `/connect [--refresh] [--url=]` | — | Configure a provider and one or more models from a model catalog. The default catalog is bundled with the CLI; pass `--refresh` to fetch the latest catalog from models.dev, or `--url` to read it from a custom URL. See [Providers and models — `/connect` and the model catalog](../configuration/providers.md#connect-and-the-model-catalog). | No | | `/model` | — | Switch the LLM model used by the current session. | No | | `/settings` | `/config` | Open the settings panel inside the TUI. | Yes | | `/permission` | — | Choose a permission mode. | Yes | diff --git a/docs/zh/configuration/providers.md b/docs/zh/configuration/providers.md index eaa037a4..ccd3965f 100644 --- a/docs/zh/configuration/providers.md +++ b/docs/zh/configuration/providers.md @@ -31,7 +31,7 @@ ANTHROPIC_BASE_URL = "https://my-proxy.example.com" ## `/connect` 与模型目录 -除了在 `config.toml` 中手写 `[providers.*]` 与 `[models.*]` 表,你也可以在 TUI 中运行 `/connect` 斜杠命令,从 **模型目录**(model catalog)添加供应商。模型目录记录了已知的供应商和模型,以及它们的上下文长度、输出长度和能力。`/connect` 会引导你选择供应商、选择模型、输入 API 密钥,然后把对应的 `[providers.]` 与 `[models.]` 写入 `config.toml`。 +除了在 `config.toml` 中手写 `[providers.*]` 与 `[models.*]` 表,你也可以在 TUI 中运行 `/connect` 斜杠命令,从 **模型目录**(model catalog)添加供应商。模型目录记录了已知的供应商和模型,以及它们的上下文长度、输出长度和能力。`/connect` 会引导你选择供应商,然后选择一个或多个模型:输入关键词可过滤列表,按 `Space` 勾选想要的每个模型,按 `Tab` 将当前高亮的模型设为默认模型。如果重新连接一个已配置的供应商,且旧模型仍存在于当前模型目录中,它们会在选择器里预先勾选;如果 API 密钥输入框留空并按 `Enter`,则会保留已有密钥。输入或保留 API 密钥后,它会把 `[providers.]` 配置块,以及每个被勾选模型对应的 `[models.]` 写入 `config.toml`,并以你指定的默认模型(若未指定,则取第一个被勾选的模型)作为默认模型;未勾选的模型不会被写入。 CLI 已经内置了默认的模型目录,因此 `/connect` 无需联网即可使用。如果想换用别的来源,可以传入以下参数: diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 88712880..2375eab2 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -14,7 +14,7 @@ | --- | --- | --- | --- | | `/login` | — | 选择账号或平台并登录:Kimi Code 走 OAuth device code 流程,Moonshot AI 开放平台通过 API 密钥登录。 | 否 | | `/logout` | — | 清除当前所选账号的凭据(Kimi Code OAuth 凭据,或对应开放平台的供应商配置)。 | 否 | -| `/connect [--refresh] [--url=]` | — | 从模型目录中选择并配置供应商与模型。CLI 已内置默认目录;传入 `--refresh` 可从 models.dev 拉取最新目录,传入 `--url` 可指向自定义目录地址。详见 [平台与模型 — `/connect` 与模型目录](../configuration/providers.md#connect-与模型目录)。 | 否 | +| `/connect [--refresh] [--url=]` | — | 从模型目录中选择并配置供应商与一个或多个模型。CLI 已内置默认目录;传入 `--refresh` 可从 models.dev 拉取最新目录,传入 `--url` 可指向自定义目录地址。详见 [平台与模型 — `/connect` 与模型目录](../configuration/providers.md#connect-与模型目录)。 | 否 | | `/model` | — | 切换当前会话使用的 LLM 模型。 | 否 | | `/settings` | `/config` | 打开 TUI 内的设置面板。 | 是 | | `/permission` | — | 选择权限模式(permission mode)。 | 是 | From 2719e7fa41d0a88cfbc5de669646b867a66edfe6 Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Thu, 28 May 2026 21:48:47 +0800 Subject: [PATCH 08/10] test(tui): trim redundant /connect coverage --- .../components/dialogs/catalog-model-multi-select.test.ts | 8 -------- apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts | 6 ------ 2 files changed, 14 deletions(-) diff --git a/apps/kimi-code/test/tui/components/dialogs/catalog-model-multi-select.test.ts b/apps/kimi-code/test/tui/components/dialogs/catalog-model-multi-select.test.ts index a338dae0..63dbba63 100644 --- a/apps/kimi-code/test/tui/components/dialogs/catalog-model-multi-select.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/catalog-model-multi-select.test.ts @@ -166,14 +166,6 @@ describe('CatalogModelMultiSelectComponent', () => { expect(onCancel).not.toHaveBeenCalled(); }); - it('shows an empty-state hint until a model is checked', () => { - const { selector } = makeSelector(); - expect(rendered(selector)).toContain('Press Space to select at least one model'); - - selector.handleInput(SPACE); // check Alpha - expect(rendered(selector)).not.toContain('Press Space to select at least one model'); - }); - it('Tab promotes the highlighted model to default, auto-checks it, and marks it in the render', () => { const { selector, onSelect } = makeSelector(); selector.handleInput(DOWN); // highlight Beta diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index c15fc92e..0e5bf078 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -1810,12 +1810,6 @@ describe('KimiTUI message flow', () => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ApiKeyInputDialogComponent); }); const apiKeyDialog = driver.state.editorContainer.children[0] as ApiKeyInputDialogComponent; - apiKeyDialog.handleInput('\r'); - expect(stripSgr(apiKeyDialog.render(120).join('\n'))).toContain( - 'API key cannot be empty.', - ); - expect(setConfig).not.toHaveBeenCalled(); - for (const ch of 'sk-test') apiKeyDialog.handleInput(ch); apiKeyDialog.handleInput('\r'); From 9af7c50d17805486c2692ec94d411ca389437062 Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Thu, 28 May 2026 22:10:48 +0800 Subject: [PATCH 09/10] fix(tui): simplify dead defensive checks in CatalogModelMultiSelectComponent --- .../dialogs/catalog-model-multi-select.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/apps/kimi-code/src/tui/components/dialogs/catalog-model-multi-select.ts b/apps/kimi-code/src/tui/components/dialogs/catalog-model-multi-select.ts index 5df14335..cdaf2742 100644 --- a/apps/kimi-code/src/tui/components/dialogs/catalog-model-multi-select.ts +++ b/apps/kimi-code/src/tui/components/dialogs/catalog-model-multi-select.ts @@ -71,15 +71,12 @@ export class CatalogModelMultiSelectComponent extends Container implements Focus this.explicitDefault = opts.defaultAlias; } const initialAlias = this.defaultAlias(); - const initialIndex = - initialAlias !== undefined - ? choices.findIndex((choice) => choice.alias === initialAlias) - : -1; + const initialIndex = choices.findIndex((choice) => choice.alias === initialAlias); this.list = new SearchableList({ items: choices, toSearchText: (c) => c.label, pageSize: opts.pageSize, - initialIndex: initialIndex >= 0 ? initialIndex : undefined, + initialIndex: Math.max(initialIndex, 0), searchable: opts.searchable === true, }); this.thinkingDraft = opts.currentThinking; @@ -157,8 +154,7 @@ export class CatalogModelMultiSelectComponent extends Container implements Focus const aliases = [...this.checked]; const defaultAlias = this.defaultAlias(); if (defaultAlias === undefined) return; - const target = this.opts.models[defaultAlias]; - const thinking = target !== undefined ? effectiveThinking(target, this.thinkingDraft) : false; + const thinking = effectiveThinking(this.opts.models[defaultAlias]!, this.thinkingDraft); this.opts.onSelect({ aliases, defaultAlias, thinking }); } @@ -213,10 +209,9 @@ export class CatalogModelMultiSelectComponent extends Container implements Focus ); } else { lines.push(chalk.hex(colors.textMuted)(' Thinking')); - const target = defaultAlias !== undefined ? this.opts.models[defaultAlias] : undefined; - if (target !== undefined) { - lines.push(renderThinkingControl(target, this.thinkingDraft, colors)); - } + // checked.size > 0 here, so defaultAlias() returns a checked alias and that + // alias is always a key in opts.models (checked is filtered by availableAliases). + lines.push(renderThinkingControl(this.opts.models[defaultAlias!]!, this.thinkingDraft, colors)); } lines.push(''); if (view.page.pageCount > 1) { From 1ea62dd43d5b6902047da49f84c624226658b0b2 Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Fri, 29 May 2026 10:03:06 +0800 Subject: [PATCH 10/10] feat(tui): enhance /connect functionality to allow provider removal via model deselection --- .changeset/connect-multi-select-models.md | 2 +- apps/kimi-code/src/tui/commands/auth.ts | 19 +++-- apps/kimi-code/src/tui/commands/prompts.ts | 30 ++++++- .../dialogs/catalog-model-multi-select.ts | 27 ++++++- .../src/tui/controllers/auth-flow.ts | 17 ++++ .../catalog-model-multi-select.test.ts | 40 ++++++++- .../test/tui/kimi-tui-message-flow.test.ts | 81 +++++++++++++++++++ docs/en/configuration/providers.md | 2 +- docs/zh/configuration/providers.md | 2 +- 9 files changed, 198 insertions(+), 22 deletions(-) diff --git a/.changeset/connect-multi-select-models.md b/.changeset/connect-multi-select-models.md index c0970d5d..5546ac01 100644 --- a/.changeset/connect-multi-select-models.md +++ b/.changeset/connect-multi-select-models.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -`/connect` now lets you search and check multiple models for a provider with Space, choose the default with Tab, and reuse existing provider details when reconnecting a provider. +`/connect` now lets you search and check multiple models for a provider with Space, choose the default with Tab, and reuse existing provider details when reconnecting a provider. Reconnecting an already-configured provider and deselecting every model now removes that provider from `config.toml`. diff --git a/apps/kimi-code/src/tui/commands/auth.ts b/apps/kimi-code/src/tui/commands/auth.ts index d9a49968..a3a0562f 100644 --- a/apps/kimi-code/src/tui/commands/auth.ts +++ b/apps/kimi-code/src/tui/commands/auth.ts @@ -256,6 +256,14 @@ export async function handleConnectCommand(host: SlashCommandHost, args: string) const selection = await promptModelSelectionForCatalog(host, providerId, models, existingConfig); if (selection === undefined) return; + if (selection.kind === 'remove') { + await host.harness.removeProvider(providerId); + await host.authFlow.refreshAfterProviderRemoval(providerId); + host.track('connect', { provider: providerId, removed: true }); + host.showStatus(`Removed: ${entry.name ?? providerId}`); + return; + } + const apiKey = await promptApiKey(host, entry.name ?? providerId, { existingApiKey: catalogProviderExistingApiKey(providerId, existingConfig), }); @@ -338,16 +346,7 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise await host.harness.removeProvider(target); } - if (target === currentProvider) { - await host.authFlow.refreshConfigAfterLogout(); - await host.authFlow.clearActiveSessionAfterLogout(); - } else { - const updated = await host.harness.getConfig({ reload: true }); - host.setAppState({ - availableModels: updated.models ?? {}, - availableProviders: updated.providers ?? {}, - }); - } + await host.authFlow.refreshAfterProviderRemoval(target); host.track('logout', { provider: target }); const label = target === DEFAULT_OAUTH_PROVIDER_NAME ? PRODUCT_NAME : target; diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index eb3069b6..6dfcc625 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -175,28 +175,48 @@ export async function promptModelSelectionForOpenPlatform( return model ? { model, thinking: selection.thinking } : undefined; } +/** + * Outcome of the /connect model picker for an already-resolved provider. + * `select` carries the models to write; `remove` means the user cleared every + * model on an already-configured provider and wants the channel removed. + * `undefined` (from the caller) means the picker was cancelled. + */ +export type ConnectModelSelection = + | { kind: 'select'; models: CatalogModel[]; defaultModelId: string; thinking: boolean } + | { kind: 'remove' }; + export async function promptModelSelectionForCatalog( host: SlashCommandHost, providerId: string, models: CatalogModel[], config: KimiConfig, -): Promise<{ models: CatalogModel[]; defaultModelId: string; thinking: boolean } | undefined> { +): Promise { + // Clearing every model only removes a provider that is already configured; + // for a fresh provider an empty selection is a no-op (Esc cancels instead). + const removable = config.providers[providerId] !== undefined; const modelDict: Record = {}; for (const m of models) { modelDict[`${providerId}/${m.id}`] = catalogModelToAlias(providerId, m); } const initialSelection = catalogModelSelectionInitialState(providerId, models, config); - const selection = await runCatalogModelMultiSelect(host, modelDict, initialSelection); + const selection = await runCatalogModelMultiSelect(host, modelDict, initialSelection, removable); if (selection === undefined) return undefined; const byAlias = new Map(models.map((m) => [`${providerId}/${m.id}`, m])); const selectedModels = selection.aliases .map((alias) => byAlias.get(alias)) .filter((m): m is CatalogModel => m !== undefined); - const defaultModel = byAlias.get(selection.defaultAlias); - if (selectedModels.length === 0 || defaultModel === undefined) return undefined; + const defaultModel = + selection.defaultAlias !== undefined ? byAlias.get(selection.defaultAlias) : undefined; + if (selectedModels.length === 0 || defaultModel === undefined) { + // The picker only emits an empty selection when `removable`, so this is the + // remove path; for a non-removable provider it is unreachable, but treat a + // degenerate selection as a cancel rather than writing nothing. + return removable ? { kind: 'remove' } : undefined; + } return { + kind: 'select', models: selectedModels, defaultModelId: defaultModel.id, thinking: selection.thinking, @@ -207,6 +227,7 @@ function runCatalogModelMultiSelect( host: SlashCommandHost, modelDict: Record, initialSelection: CatalogModelSelectionInitialState, + removable: boolean, ): Promise { return new Promise((resolve) => { const selector = new CatalogModelMultiSelectComponent({ @@ -214,6 +235,7 @@ function runCatalogModelMultiSelect( currentThinking: initialSelection.thinking ?? true, selectedAliases: initialSelection.selectedAliases, defaultAlias: initialSelection.defaultAlias, + removable, colors: host.state.theme.colors, searchable: true, onSelect: (selection) => { diff --git a/apps/kimi-code/src/tui/components/dialogs/catalog-model-multi-select.ts b/apps/kimi-code/src/tui/components/dialogs/catalog-model-multi-select.ts index cdaf2742..6e9d394e 100644 --- a/apps/kimi-code/src/tui/components/dialogs/catalog-model-multi-select.ts +++ b/apps/kimi-code/src/tui/components/dialogs/catalog-model-multi-select.ts @@ -27,9 +27,13 @@ import { } from './model-choice'; export interface ModelMultiSelection { - /** Checked model aliases, in the order the user checked them. */ + /** + * Checked model aliases, in the order the user checked them. Empty means the + * user confirmed with nothing selected — a request to remove the channel + * (only reachable when the picker was opened with `removable: true`). + */ readonly aliases: readonly string[]; - readonly defaultAlias: string; + readonly defaultAlias?: string; readonly thinking: boolean; } @@ -39,6 +43,12 @@ export interface CatalogModelMultiSelectOptions { readonly colors: ColorPalette; readonly selectedAliases?: readonly string[]; readonly defaultAlias?: string; + /** + * When true, confirming with zero models checked is allowed and signals that + * the channel should be removed. Used when re-entering an already-configured + * provider. When false/undefined, Enter with nothing checked is a no-op. + */ + readonly removable?: boolean; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; /** Items per page. Lists longer than this paginate (PgUp/PgDn). */ @@ -150,7 +160,14 @@ export class CatalogModelMultiSelectComponent extends Container implements Focus // Space/Tab the picker stays open — never silently include the highlighted // row, since that would contradict the "only the models you check are // written" guarantee. - if (this.checked.size === 0) return; + if (this.checked.size === 0) { + // For an already-configured provider, confirming with nothing checked + // means "remove this channel". Otherwise there is nothing to do. + if (this.opts.removable === true) { + this.opts.onSelect({ aliases: [], defaultAlias: undefined, thinking: this.thinkingDraft }); + } + return; + } const aliases = [...this.checked]; const defaultAlias = this.defaultAlias(); if (defaultAlias === undefined) return; @@ -204,7 +221,9 @@ export class CatalogModelMultiSelectComponent extends Container implements Focus if (this.checked.size === 0) { lines.push( chalk.hex(colors.textMuted)( - ' Press Space to select at least one model — Tab makes the highlighted one the default.', + this.opts.removable === true + ? ' Deselect all and press Enter to remove this channel — or Space to keep models.' + : ' Press Space to select at least one model — Tab makes the highlighted one the default.', ), ); } else { diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 5b5f9562..bfa2b9e1 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -134,4 +134,21 @@ export class AuthFlowController { contextTokens: 0, }); } + + /** + * Refresh UI state after a provider's config has been removed. When the + * removed provider owned the active model, the current model and session are + * no longer valid, so clear them as a logout would; otherwise just refresh the + * available model/provider lists. + */ + async refreshAfterProviderRemoval(removedProviderId: string): Promise { + const currentModel = this.host.state.appState.model.trim(); + const currentProvider = this.host.state.appState.availableModels[currentModel]?.provider; + if (removedProviderId === currentProvider) { + await this.refreshConfigAfterLogout(); + await this.clearActiveSessionAfterLogout(); + } else { + await this.refreshAvailableModels(); + } + } } diff --git a/apps/kimi-code/test/tui/components/dialogs/catalog-model-multi-select.test.ts b/apps/kimi-code/test/tui/components/dialogs/catalog-model-multi-select.test.ts index 63dbba63..c64cc408 100644 --- a/apps/kimi-code/test/tui/components/dialogs/catalog-model-multi-select.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/catalog-model-multi-select.test.ts @@ -49,7 +49,11 @@ describe('CatalogModelMultiSelectComponent', () => { } function makeSelector( - initial: { selectedAliases?: readonly string[]; defaultAlias?: string } = {}, + initial: { + selectedAliases?: readonly string[]; + defaultAlias?: string; + removable?: boolean; + } = {}, ) { const onSelect = vi.fn(); const onCancel = vi.fn(); @@ -59,6 +63,7 @@ describe('CatalogModelMultiSelectComponent', () => { colors: darkColors, selectedAliases: initial.selectedAliases, defaultAlias: initial.defaultAlias, + removable: initial.removable, searchable: true, onSelect, onCancel, @@ -166,6 +171,39 @@ describe('CatalogModelMultiSelectComponent', () => { expect(onCancel).not.toHaveBeenCalled(); }); + it('when removable, confirming with everything deselected requests channel removal', () => { + const { selector, onSelect, onCancel } = makeSelector({ + selectedAliases: ['prov/alpha', 'prov/beta'], + removable: true, + }); + + selector.handleInput(SPACE); // uncheck Alpha (highlighted) + selector.handleInput(DOWN); // highlight Beta + selector.handleInput(SPACE); // uncheck Beta + expect(rendered(selector)).toContain('press Enter to remove this channel'); + selector.handleInput(ENTER); + + expect(onCancel).not.toHaveBeenCalled(); + expect(onSelect).toHaveBeenCalledWith({ + aliases: [], + defaultAlias: undefined, + thinking: true, + }); + }); + + it('when not removable, Enter with everything deselected stays a no-op', () => { + const { selector, onSelect, onCancel } = makeSelector({ + selectedAliases: ['prov/alpha'], + }); + expect(rendered(selector)).not.toContain('remove this channel'); + + selector.handleInput(SPACE); // uncheck Alpha (highlighted) + selector.handleInput(ENTER); + + expect(onSelect).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + }); + it('Tab promotes the highlighted model to default, auto-checks it, and marks it in the render', () => { const { selector, onSelect } = makeSelector(); selector.handleInput(DOWN); // highlight Beta diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 0e5bf078..71ce0d15 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -2128,6 +2128,87 @@ describe('KimiTUI message flow', () => { } }); + it('/connect removes the active provider and clears the session when every model is deselected', async () => { + const catalog = { + acme: { + id: 'acme', + name: 'Acme', + api: 'https://api.acme.com/v1', + type: 'openai', + models: { + 'acme-nano': { + id: 'acme-nano', + name: 'Acme Nano', + limit: { context: 50000 }, + tool_call: true, + }, + }, + }, + }; + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify(catalog), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const originalFetch = global.fetch; + global.fetch = fetchMock as unknown as typeof fetch; + + const getConfig = vi.fn(async () => ({ + providers: { acme: { type: 'openai', apiKey: 'old-key' } }, + models: { + 'acme/acme-nano': { provider: 'acme', model: 'acme-nano', maxContextSize: 50000 }, + }, + defaultModel: 'acme/acme-nano', + defaultThinking: false, + })); + const removeProvider = vi.fn(async () => ({ providers: {}, models: {} })); + const setConfig = vi.fn(async () => ({ providers: {} })); + const session = makeSession(); + const { driver } = await makeDriver(session, { getConfig, removeProvider, setConfig }); + + // The active model belongs to the provider we are about to remove. + driver.state.appState.model = 'acme/acme-nano'; + driver.state.appState.availableModels = { + 'acme/acme-nano': { provider: 'acme', model: 'acme-nano', maxContextSize: 50000 }, + }; + + try { + driver.handleUserInput('/connect refresh'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); + }); + (driver.state.editorContainer.children[0] as ChoicePickerComponent).handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + CatalogModelMultiSelectComponent, + ); + }); + const modelPicker = driver.state.editorContainer + .children[0] as CatalogModelMultiSelectComponent; + modelPicker.handleInput(' '); // deselect the only (preselected) model + expect(stripSgr(modelPicker.render(120).join('\n'))).toContain( + 'press Enter to remove this channel', + ); + modelPicker.handleInput('\r'); + + await vi.waitFor(() => { + expect(removeProvider).toHaveBeenCalledWith('acme'); + }); + // Removing the current provider must tear down the stale session/model, not + // just refresh the available-model list. + expect(session.close).toHaveBeenCalled(); + expect(driver.state.appState.model).toBe(''); + expect(setConfig).not.toHaveBeenCalled(); + expect(stripSgr(renderTranscript(driver))).toContain('Removed: Acme'); + } finally { + global.fetch = originalFetch; + } + }); + it('deletes Kitty inline images when /new clears the transcript', async () => { setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true }); const { driver, harness } = await makeDriver(makeSession({ id: 'ses-1' })); diff --git a/docs/en/configuration/providers.md b/docs/en/configuration/providers.md index 72201928..2827b635 100644 --- a/docs/en/configuration/providers.md +++ b/docs/en/configuration/providers.md @@ -31,7 +31,7 @@ The most common ways to switch providers are: use the `/model` slash command ins ## `/connect` and the model catalog -Instead of writing `[providers.*]` and `[models.*]` tables by hand, run the `/connect` slash command inside the TUI to add a provider from a **model catalog**. The catalog lists known providers and models together with their context window, output limit, and capabilities. `/connect` prompts you to pick a provider, then to select one or more of its models — type to filter the list, press `Space` to check each model you want, and press `Tab` to make the highlighted model the default. If you reconnect a provider that is already configured, models from that provider are preselected when they still exist in the current catalog, and pressing `Enter` on an empty API key prompt keeps the existing key. After you enter or keep an API key, it writes the `[providers.]` block and a `[models.]` entry for every checked model to `config.toml`, using your chosen default — or, if you didn't pick one, the first checked model — as the default model. Only the models you check are written. +Instead of writing `[providers.*]` and `[models.*]` tables by hand, run the `/connect` slash command inside the TUI to add a provider from a **model catalog**. The catalog lists known providers and models together with their context window, output limit, and capabilities. `/connect` prompts you to pick a provider, then to select one or more of its models — type to filter the list, press `Space` to check each model you want, and press `Tab` to make the highlighted model the default. If you reconnect a provider that is already configured, models from that provider are preselected when they still exist in the current catalog, and pressing `Enter` on an empty API key prompt keeps the existing key. After you enter or keep an API key, it writes the `[providers.]` block and a `[models.]` entry for every checked model to `config.toml`, using your chosen default — or, if you didn't pick one, the first checked model — as the default model. Only the models you check are written. To remove an already-configured provider from the TUI, reconnect it, deselect every model, and press `Enter` with nothing checked — this deletes the provider and all of its models from `config.toml` (the same as `/logout`). The default catalog is bundled with the CLI, so `/connect` works offline. Two flags change the catalog source: diff --git a/docs/zh/configuration/providers.md b/docs/zh/configuration/providers.md index ccd3965f..12b77314 100644 --- a/docs/zh/configuration/providers.md +++ b/docs/zh/configuration/providers.md @@ -31,7 +31,7 @@ ANTHROPIC_BASE_URL = "https://my-proxy.example.com" ## `/connect` 与模型目录 -除了在 `config.toml` 中手写 `[providers.*]` 与 `[models.*]` 表,你也可以在 TUI 中运行 `/connect` 斜杠命令,从 **模型目录**(model catalog)添加供应商。模型目录记录了已知的供应商和模型,以及它们的上下文长度、输出长度和能力。`/connect` 会引导你选择供应商,然后选择一个或多个模型:输入关键词可过滤列表,按 `Space` 勾选想要的每个模型,按 `Tab` 将当前高亮的模型设为默认模型。如果重新连接一个已配置的供应商,且旧模型仍存在于当前模型目录中,它们会在选择器里预先勾选;如果 API 密钥输入框留空并按 `Enter`,则会保留已有密钥。输入或保留 API 密钥后,它会把 `[providers.]` 配置块,以及每个被勾选模型对应的 `[models.]` 写入 `config.toml`,并以你指定的默认模型(若未指定,则取第一个被勾选的模型)作为默认模型;未勾选的模型不会被写入。 +除了在 `config.toml` 中手写 `[providers.*]` 与 `[models.*]` 表,你也可以在 TUI 中运行 `/connect` 斜杠命令,从 **模型目录**(model catalog)添加供应商。模型目录记录了已知的供应商和模型,以及它们的上下文长度、输出长度和能力。`/connect` 会引导你选择供应商,然后选择一个或多个模型:输入关键词可过滤列表,按 `Space` 勾选想要的每个模型,按 `Tab` 将当前高亮的模型设为默认模型。如果重新连接一个已配置的供应商,且旧模型仍存在于当前模型目录中,它们会在选择器里预先勾选;如果 API 密钥输入框留空并按 `Enter`,则会保留已有密钥。输入或保留 API 密钥后,它会把 `[providers.]` 配置块,以及每个被勾选模型对应的 `[models.]` 写入 `config.toml`,并以你指定的默认模型(若未指定,则取第一个被勾选的模型)作为默认模型;未勾选的模型不会被写入。若要在 TUI 中移除一个已配置的供应商,重新连接它、取消勾选所有模型,并在没有任何勾选的情况下按 `Enter`——这会从 `config.toml` 中删除该供应商及其全部模型(效果等同 `/logout`)。 CLI 已经内置了默认的模型目录,因此 `/connect` 无需联网即可使用。如果想换用别的来源,可以传入以下参数: