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/refresh-provider-manager.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Add an explicit refresh action to the `/provider` manager. Press `R` to refresh provider models.
28 changes: 28 additions & 0 deletions apps/kimi-code/src/tui/commands/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ function buildProviderManagerOptions(host: SlashCommandHost): ProviderManagerOpt
host.showError(`Add provider failed: ${formatErrorMessage(error)}`);
});
},
onRefresh: () => {
void handleProviderManagerRefresh(host).catch((error: unknown) => {
host.showError(`Refresh providers failed: ${formatErrorMessage(error)}`);
});
},
onDeleteSource: (providerIds) => {
void handleProviderManagerDeleteSource(host, providerIds).catch((error: unknown) => {
host.showError(`Remove provider failed: ${formatErrorMessage(error)}`);
Expand All @@ -71,6 +76,29 @@ function buildProviderManagerOptions(host: SlashCommandHost): ProviderManagerOpt
};
}

async function handleProviderManagerRefresh(host: SlashCommandHost): Promise<void> {
const managerAtRefreshStart = host.state.editorContainer.children[0];
const spinner = host.showProgressSpinner('Refreshing provider models...');
try {
const result = await host.authFlow.refreshProviderModels();
const ok = result.failed.length === 0;
spinner.stop({
ok,
label: ok ? 'Provider refresh finished.' : 'Provider refresh finished with warnings.',
});
for (const failure of result.failed) {
host.showStatus(`Skipped refreshing ${failure.provider}: ${failure.reason}`, 'warning');
}
} catch (error) {
spinner.stop({ ok: false, label: 'Provider refresh failed.' });
throw error;
} finally {
if (host.state.editorContainer.children[0] === managerAtRefreshStart) {
reopenProviderManager(host);
}
}
}

async function handleProviderManagerDeleteSource(
host: SlashCommandHost,
providerIds: readonly string[],
Expand Down
11 changes: 9 additions & 2 deletions apps/kimi-code/src/tui/components/dialogs/provider-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* - ↑ / ↓ move highlight
* - ← / → · PgUp/PgDn page
* - Enter on `[ Add New Platform ]` → `onAdd()`
* - R refresh provider models via `onRefresh()`
* - D delete with inline `[y/N]` confirmation
* on a source row → `onDeleteSource(providerIds)`
* on `[ Add New Platform ]` → ignored
Expand Down Expand Up @@ -61,6 +62,7 @@ export interface ProviderManagerOptions {
/** Provider id of the currently active model. */
readonly activeProviderId?: string;
readonly onAdd: () => void;
readonly onRefresh: () => void;
/** Delete all providers under a source (Open Platform / custom-registry
* fetch / standalone). Passed the full provider-id list so the host
* doesn't have to re-derive the source grouping. */
Expand Down Expand Up @@ -91,7 +93,7 @@ type Row = SourceRow | AddRow;

const ADD_ROW_LABEL = '[ Add New Platform ]';
const PAGE_SIZE = 8;
const HEADER_HINT = '↑↓ navigate · D delete · Esc cancel';
const HEADER_HINT = '↑↓ navigate · R refresh · D delete · Esc cancel';

// Narrows a `ProviderConfig` blob to a `CustomRegistrySource` payload.
// Mirrors `readCustomRegistrySource` in `kimi-tui.ts`. We can't import
Expand Down Expand Up @@ -312,8 +314,13 @@ export class ProviderManagerComponent extends Container implements Focusable {
return;
}

// Delete the highlighted provider with the D key.
const ch = printableChar(data);
if (ch === 'r' || ch === 'R') {
this.opts.onRefresh();
return;
}

// Delete the highlighted provider with the D key.
if (ch === 'd' || ch === 'D') {
this.armDeleteConfirm();
}
Expand Down
4 changes: 1 addition & 3 deletions apps/kimi-code/src/tui/controllers/auth-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,7 @@ export class AuthFlowController {

private async refreshProviderModelsWithScope(scope: RefreshProviderScope): Promise<RefreshResult> {
const result = await refreshAllProviderModels(this.buildRefreshHost(), { scope });
if (result.changed.length > 0) {
await this.refreshAvailableModels();
}
await this.refreshAvailableModels();
return result;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
/**
* Scenario: Provider Manager rendering, keyboard actions, and refresh lifecycle.
* Responsibilities: expose stable provider actions and preserve explicit dismissal during refresh.
* Wiring: real component and command handler with callback spies; run with this file through Vitest.
*/
import type { ProviderConfig } from '@moonshot-ai/kimi-code-sdk';
import chalk from 'chalk';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';

import { handleProviderCommand } from '#/tui/commands/provider';
import {
ProviderManagerComponent,
type ProviderManagerOptions,
Expand All @@ -25,6 +31,7 @@ function makeComponent(overrides: Partial<ProviderManagerOptions> = {}): Provide
return new ProviderManagerComponent({
providers: {} as Record<string, ProviderConfig>,
onAdd: vi.fn(),
onRefresh: vi.fn(),
onDeleteSource: vi.fn(),
onClose: vi.fn(),
...overrides,
Expand Down Expand Up @@ -123,6 +130,68 @@ describe('ProviderManagerComponent', () => {
expect(onDeleteSource).toHaveBeenCalledWith(['acme']);
});

it('dispatches refresh when R arrives through the Kitty keyboard protocol', () => {
const onRefresh = vi.fn();
const component = makeComponent({ onRefresh });

component.handleInput(`${ESC}[114u`);

expect(onRefresh).toHaveBeenCalledOnce();
});

it('advertises the R refresh shortcut in the header hint', () => {
expect(rendered(makeComponent())).toContain('R refresh');
});

it('keeps the editor active when provider refresh finishes after the manager is dismissed', async () => {
const editor = {};
let focused: unknown = editor;
const editorChildren: unknown[] = [editor];
let finishRefresh!: () => void;
const refreshProviderModels = vi.fn(
() =>
new Promise<{ changed: []; unchanged: []; failed: [] }>((resolve) => {
finishRefresh = () => {
resolve({ changed: [], unchanged: [], failed: [] });
};
}),
);
const host = {
state: {
appState: {
availableModels: {},
availableProviders: {},
model: '',
},
editorContainer: { children: editorChildren },
},
authFlow: { refreshProviderModels },
mountEditorReplacement: (component: unknown) => {
editorChildren.splice(0, editorChildren.length, component);
focused = component;
},
restoreEditor: () => {
editorChildren.splice(0, editorChildren.length, editor);
focused = editor;
},
showError: vi.fn(),
showProgressSpinner: () => ({ stop: vi.fn() }),
showStatus: vi.fn(),
} as unknown as Parameters<typeof handleProviderCommand>[0];
await handleProviderCommand(host);
const manager = focused as ProviderManagerComponent;

manager.handleInput('R');
expect(refreshProviderModels).toHaveBeenCalledOnce();
manager.handleInput(ESC);
expect(focused).toBe(editor);

finishRefresh();
await Promise.resolve();

expect(focused).toBe(editor);
});

it('closes on Esc', () => {
const onClose = vi.fn();
const component = makeComponent({
Expand Down
31 changes: 31 additions & 0 deletions apps/kimi-code/test/tui/kimi-tui-startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const copyTextToClipboardMock = vi.mocked(copyTextToClipboard);

interface StartupDriver {
state: TUIState;
readonly authFlow: KimiTUI['authFlow'];
init(): Promise<boolean>;
handleLoginCommand(): Promise<void>;
handleLogoutCommand(): Promise<void>;
Expand Down Expand Up @@ -1511,6 +1512,36 @@ describe('KimiTUI startup', () => {
expect(showStatus).toHaveBeenCalledWith("New Models · +2 models.");
});

it('reloads provider state when a refresh reports no model changes', async () => {
const getConfig = vi
.fn()
.mockResolvedValueOnce({ providers: {}, models: {} })
.mockResolvedValue({
providers: {
acme: {
type: 'openai',
baseUrl: 'https://api.example.test/v1',
apiKey: 'YOUR_API_KEY',
},
},
models: {
'acme/example-model': {
provider: 'acme',
model: 'example-model',
maxContextSize: 4096,
capabilities: [],
},
},
});
const harness = makeHarness(makeSession(), { getConfig });
const driver = makeDriver(harness, makeStartupInput());

await driver.authFlow.refreshProviderModels();

expect(driver.state.appState.availableProviders).toHaveProperty('acme');
expect(driver.state.appState.availableModels).toHaveProperty('acme/example-model');
});

it("stages provider-refresh removals and persists one atomic write on atomic-capable harnesses", async () => {
const registryUrl = "https://registry.example.test/v1/models/api.json";
const source = { kind: "apiJson", url: registryUrl, apiKey: "sk-test-token" };
Expand Down