Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/connect-multi-select-models.md
Original file line number Diff line number Diff line change
@@ -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. Reconnecting an already-configured provider and deselecting every model now removes that provider from `config.toml`.
43 changes: 24 additions & 19 deletions apps/kimi-code/src/tui/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -248,17 +253,26 @@ 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);
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),
});
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);
}
Expand All @@ -269,8 +283,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,
});

Expand All @@ -282,8 +296,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<void> {
Expand Down Expand Up @@ -332,16 +346,7 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise<void>
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;
Expand Down
132 changes: 114 additions & 18 deletions apps/kimi-code/src/tui/commands/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string | undefined> {
Expand Down Expand Up @@ -69,7 +83,11 @@ export function promptFeedbackInput(host: SlashCommandHost): Promise<string | un
});
}

export function promptApiKey(host: SlashCommandHost, platformName: string): Promise<string | undefined> {
export function promptApiKey(
host: SlashCommandHost,
platformName: string,
options: ApiKeyInputDialogOptions = {},
): Promise<string | undefined> {
return new Promise((resolve) => {
const dialog = new ApiKeyInputDialogComponent(
platformName,
Expand All @@ -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<string | undefined> {
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<string | undefined> {
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,
Expand Down Expand Up @@ -140,19 +175,80 @@ 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[],
): Promise<{ model: CatalogModel; thinking: boolean } | undefined> {
config: KimiConfig,
): Promise<ConnectModelSelection | undefined> {
// 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<string, ModelAlias> = {};
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, removable);
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 =
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,
};
}

function runCatalogModelMultiSelect(
host: SlashCommandHost,
modelDict: Record<string, ModelAlias>,
initialSelection: CatalogModelSelectionInitialState,
removable: boolean,
): Promise<ModelMultiSelection | undefined> {
return new Promise((resolve) => {
const selector = new CatalogModelMultiSelectComponent({
models: modelDict,
currentThinking: initialSelection.thinking ?? true,
selectedAliases: initialSelection.selectedAliases,
defaultAlias: initialSelection.defaultAlias,
removable,
colors: host.state.theme.colors,
searchable: true,
onSelect: (selection) => {
host.restoreEditor();
resolve(selection);
},
onCancel: () => {
host.restoreEditor();
resolve(undefined);
},
});
host.mountEditorReplacement(selector);
});
}

export function runModelSelector(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -50,19 +54,27 @@ 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;

constructor(
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);
};
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading