feat(kimi-code): rework /logout into a provider picker and add /disconnect alias (#76)

* feat(kimi-code): pick provider when running /logout

/logout used to clear the credential tied to the currently selected
model, which made the target invisible and coupled logout semantics to
model selection. It now opens a picker over every credential currently
held and highlights the active model's provider so pressing Enter
matches the previous behavior.

* feat(kimi-code): alias /logout as /disconnect

So users who entered a provider via /connect can leave it with the
symmetric /disconnect.

* fix(kimi-code): keep /logout usable for stale managed config and unrelated providers

- Surface the Kimi Code OAuth entry in the picker whenever
  config.providers still references it, not only when a live token is
  present. auth.logout cleans the config either way, so stale entries
  can no longer become unreachable through /logout.
- When the picked provider is not the one backing the current model,
  just refresh the provider/model listing and leave the session and
  model selection alone. Full teardown stays for the case where the
  active provider was the one being removed.
This commit is contained in:
7Sageer 2026-05-26 19:12:04 +08:00 committed by GitHub
parent c0b63c1ea7
commit 6f22ae48f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 186 additions and 21 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
/logout now opens a picker so you can choose which provider to log out of, instead of always logging out the one tied to the current model. The current provider is highlighted by default, so pressing Enter matches the previous behavior. The command is also available as /disconnect.

View file

@ -129,8 +129,8 @@ export const BUILTIN_SLASH_COMMANDS = [
},
{
name: 'logout',
aliases: [],
description: 'Clear credentials for the current platform',
aliases: ['disconnect'],
description: 'Log out of a configured provider',
priority: 40,
},
{

View file

@ -1,6 +1,6 @@
import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app';
export { DEFAULT_OAUTH_PROVIDER_NAME, OAUTH_LOGIN_REQUIRED_CODE } from '#/constant/app';
export { DEFAULT_OAUTH_PROVIDER_NAME, OAUTH_LOGIN_REQUIRED_CODE, PRODUCT_NAME } from '#/constant/app';
export const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login';
export const NO_ACTIVE_SESSION_MESSAGE = 'No active session. Send /login to login.';

View file

@ -197,6 +197,7 @@ import {
NO_ACTIVE_SESSION_MESSAGE,
OAUTH_LOGIN_REQUIRED_CODE,
OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE,
PRODUCT_NAME,
} from './constant/kimi-tui';
import { STREAMING_UI_FLUSH_MS } from './constant/streaming';
import { adaptPanelResponse } from './reverse-rpc/approval/adapter';
@ -5389,34 +5390,102 @@ export class KimiTUI {
});
}
// Handles the /logout command.
// Handles the /logout command. Lists every credential currently held — the
// Kimi Code OAuth token (or a stale config entry for it) plus each configured
// API-key provider — and lets the user pick which one to drop. OAuth tokens
// go through auth.logout for proper revocation, everything else through
// removeProvider.
private async handleLogoutCommand(): Promise<void> {
const oauthStatus = await this.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME);
const hasOAuthToken = oauthStatus.providers.some(
(p) => p.providerName === DEFAULT_OAUTH_PROVIDER_NAME && p.hasToken,
);
const config = await this.harness.getConfig();
// Offer the managed provider whenever something points at it — either a
// live OAuth token or a stale providers[] entry left over from a previous
// login. auth.logout cleans the config regardless of whether the token
// is still present, so this avoids leaving residue with no way to reach it.
const hasManagedRemnant =
hasOAuthToken || config.providers[DEFAULT_OAUTH_PROVIDER_NAME] !== undefined;
const apiKeyProviderIds = Object.keys(config.providers ?? {})
.filter((id) => id !== DEFAULT_OAUTH_PROVIDER_NAME)
.toSorted();
const options: ChoiceOption[] = [];
if (hasManagedRemnant) {
options.push({
value: DEFAULT_OAUTH_PROVIDER_NAME,
label: PRODUCT_NAME,
description: 'OAuth login',
});
}
for (const id of apiKeyProviderIds) {
const baseUrl = config.providers[id]?.baseUrl;
options.push({
value: id,
label: id,
description: typeof baseUrl === 'string' && baseUrl.length > 0 ? baseUrl : undefined,
});
}
if (options.length === 0) {
this.showStatus('Nothing to logout.');
return;
}
const currentModel = this.state.appState.model.trim();
const currentProvider = this.state.appState.availableModels[currentModel]?.provider;
if (currentProvider === undefined || currentProvider === DEFAULT_OAUTH_PROVIDER_NAME) {
const target = await this.promptLogoutProviderSelection(options, currentProvider);
if (target === undefined) return;
if (target === DEFAULT_OAUTH_PROVIDER_NAME) {
await this.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME);
await this.refreshConfigAfterLogout();
await this.clearActiveSessionAfterLogout();
this.track('logout', { provider: DEFAULT_OAUTH_PROVIDER_NAME });
this.showStatus('Logged out.');
return;
} else {
await this.harness.removeProvider(target);
}
// Any other provider written into config — OpenPlatform OAuth targets and
// /connect-configured catalog providers both go through removeProvider,
// which drops the provider entry and its model aliases together.
const existingConfig = await this.harness.getConfig();
if (existingConfig.providers[currentProvider] !== undefined) {
await this.harness.removeProvider(currentProvider);
if (target === currentProvider) {
// The active session is backed by the provider we just removed, so it
// can no longer make requests — tear it down along with the model state.
await this.refreshConfigAfterLogout();
await this.clearActiveSessionAfterLogout();
this.track('logout', { provider: currentProvider });
this.showStatus(`Logged out from ${currentProvider}.`);
return;
} else {
// Refresh provider/model listings so the picker reflects the change,
// but leave the user's current session running.
const updated = await this.harness.getConfig({ reload: true });
this.setAppState({
availableModels: updated.models ?? {},
availableProviders: updated.providers ?? {},
});
}
this.showStatus('Nothing to logout.');
this.track('logout', { provider: target });
const label = target === DEFAULT_OAUTH_PROVIDER_NAME ? PRODUCT_NAME : target;
this.showStatus(`Logged out from ${label}.`);
}
private promptLogoutProviderSelection(
options: readonly ChoiceOption[],
currentValue: string | undefined,
): Promise<string | undefined> {
return new Promise((resolve) => {
const picker = new ChoicePickerComponent({
title: 'Select a provider to log out',
options,
currentValue,
colors: this.state.theme.colors,
onSelect: (value) => {
this.restoreEditor();
resolve(value);
},
onCancel: () => {
this.restoreEditor();
resolve(undefined);
},
});
this.mountEditorReplacement(picker);
});
}
// ---------------------------------------------------------------------------

View file

@ -567,12 +567,30 @@ describe("KimiTUI startup", () => {
it("tracks logout after managed credentials and session state are cleared", async () => {
const session = makeSession();
const harness = makeHarness(session);
const harness = makeHarness(session, {
getConfig: vi.fn(async () => ({
models: {
k2: { provider: "managed:kimi-code", model: "moonshot-v1", maxContextSize: 100 },
},
providers: { "managed:kimi-code": { type: "kimi" } },
})),
auth: {
status: vi.fn(async () => ({
providers: [{ providerName: "managed:kimi-code", hasToken: true }],
})),
login: vi.fn(async () => {}),
logout: vi.fn(),
getManagedUsage: vi.fn(),
},
});
const driver = makeDriver(harness, makeStartupInput());
await expect(driver.init()).resolves.toBe(false);
harness.track.mockClear();
vi.spyOn(driver as any, "promptLogoutProviderSelection").mockResolvedValue(
"managed:kimi-code",
);
await driver.handleLogoutCommand();
expect(harness.auth.logout).toHaveBeenCalledWith("managed:kimi-code");
@ -585,6 +603,79 @@ describe("KimiTUI startup", () => {
expect(harness.track).toHaveBeenCalledWith("logout", { provider: "managed:kimi-code" });
});
it("keeps the active session when logging out a different provider", async () => {
const session = makeSession();
const removeProvider = vi.fn(async () => {});
const harness = makeHarness(session, {
getConfig: vi.fn(async () => ({
models: {
k2: { provider: "managed:kimi-code", model: "moonshot-v1", maxContextSize: 100 },
},
providers: {
"managed:kimi-code": { type: "kimi" },
openai: { type: "openai", baseUrl: "https://api.openai.com/v1" },
},
})),
removeProvider,
auth: {
status: vi.fn(async () => ({
providers: [{ providerName: "managed:kimi-code", hasToken: true }],
})),
login: vi.fn(async () => {}),
logout: vi.fn(),
getManagedUsage: vi.fn(),
},
});
const driver = makeDriver(harness, makeStartupInput());
await expect(driver.init()).resolves.toBe(false);
harness.track.mockClear();
vi.spyOn(driver as any, "promptLogoutProviderSelection").mockResolvedValue("openai");
await driver.handleLogoutCommand();
expect(removeProvider).toHaveBeenCalledWith("openai");
expect(harness.auth.logout).not.toHaveBeenCalled();
expect(session.close).not.toHaveBeenCalled();
expect(driver.state.appState).toMatchObject({
sessionId: "ses-1",
model: "k2",
});
expect(harness.track).toHaveBeenCalledWith("logout", { provider: "openai" });
});
it("can log out a stale managed entry even after the OAuth token is gone", async () => {
const session = makeSession();
const harness = makeHarness(session, {
getConfig: vi.fn(async () => ({
models: {
k2: { provider: "managed:kimi-code", model: "moonshot-v1", maxContextSize: 100 },
},
providers: { "managed:kimi-code": { type: "kimi" } },
})),
auth: {
// Token gone (e.g. credentials file deleted) but the managed entry
// is still sitting in config.providers.
status: vi.fn(async () => ({
providers: [{ providerName: "managed:kimi-code", hasToken: false }],
})),
login: vi.fn(async () => {}),
logout: vi.fn(),
getManagedUsage: vi.fn(),
},
});
const driver = makeDriver(harness, makeStartupInput());
await expect(driver.init()).resolves.toBe(false);
vi.spyOn(driver as any, "promptLogoutProviderSelection").mockResolvedValue(
"managed:kimi-code",
);
await driver.handleLogoutCommand();
expect(harness.auth.logout).toHaveBeenCalledWith("managed:kimi-code");
});
it("starts TUI without replaying when --continue needs OAuth login", async () => {
const harness = makeHarness(makeSession(), {
listSessions: vi.fn(async () => [{ id: "ses-latest" }]),