diff --git a/.changeset/tui-status-line-provider-slot.md b/.changeset/tui-status-line-provider-slot.md new file mode 100644 index 0000000000..5d2c7c1a67 --- /dev/null +++ b/.changeset/tui-status-line-provider-slot.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add a `provider` slot to the TUI footer status line, so the status bar can show which provider serves the current model. List it in `[status_line].items` to enable it, and it is also available as `provider` in the `[status_line].command` JSON payload. diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 4bb8a75f9b..8ece54f8a3 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -16,6 +16,7 @@ import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/danc import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; +import { providerDisplayName } from '#/tui/utils/provider-label'; import { StatusLineCommandRunner, type StatusLinePayload, @@ -147,6 +148,19 @@ function modelDisplayName(state: AppState): string { return effective?.displayName ?? effective?.model ?? state.model; } +/** + * Provider serving the current model, as a display label. Empty when the + * active alias is not in the catalog, which drops the slot rather than + * echoing the raw alias — unlike the model name, an alias is no answer to + * "which provider". + */ +function modelProviderName(state: AppState): string { + const model = state.availableModels[state.model]; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + const provider = effective?.provider; + return provider === undefined || provider.length === 0 ? '' : providerDisplayName(provider); +} + function shortenCwd(path: string): string { if (!path) return path; const home = process.env['HOME'] ?? ''; @@ -362,6 +376,7 @@ export class FooterComponent implements Component { const slots: Record = { mode: [], goal: [], + provider: [], model: [], tasks: [], cwd: [], @@ -385,6 +400,11 @@ export class FooterComponent implements Component { const goalBadge = formatGoalBadge(state.goal, colors, this.goalWallClockMs(state.goal)); if (goalBadge !== null) slots['goal'] = [goalBadge]; + // Opt-in slot: the provider behind the current model, so a footer that + // lists several look-alike model names still says who serves them. + const provider = modelProviderName(state); + if (provider) slots['provider'] = [chalk.hex(colors.textDim)(provider)]; + const model = modelDisplayName(state); if (model) { const effort = state.thinkingEffort; @@ -439,6 +459,7 @@ export class FooterComponent implements Component { const state = this.state; return { model: modelDisplayName(state), + provider: modelProviderName(state), cwd: state.workDir, gitBranch: this.gitCache.getStatus()?.branch ?? null, permissionMode: state.permissionMode, 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 64537df22e..5c5b5afdee 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -9,9 +9,9 @@ import { type Focusable, } from '@moonshot-ai/pi-tui'; -import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; +import { providerDisplayName } from '#/tui/utils/provider-label'; import { SearchableList } from '#/tui/utils/searchable-list'; import type { ChoiceOption } from './choice-picker'; @@ -42,12 +42,6 @@ export function modelDisplayName(alias: string, model: ModelAlias | undefined): return effective?.displayName ?? effective?.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[] { diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index d94de3b06d..3c7a304341 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -23,11 +23,11 @@ import { } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; +import { providerDisplayName } from '#/tui/utils/provider-label'; import { renderTabStrip } from '#/tui/utils/tab-strip'; import { ModelSelectorComponent, - providerDisplayName, type ModelSelection, type ModelSelectorOptions, } from './model-selector'; diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index 36f43ba07b..6012795743 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -30,7 +30,16 @@ export const UpgradePreferencesSchema = z.object({ autoInstall: z.boolean(), }); -export const STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git', 'tips'] as const; +export const STATUS_LINE_ITEMS = [ + 'mode', + 'goal', + 'provider', + 'model', + 'tasks', + 'cwd', + 'git', + 'tips', +] as const; export type StatusLineItem = (typeof STATUS_LINE_ITEMS)[number]; export const StatusLineFileConfigSchema = z.object({ diff --git a/apps/kimi-code/src/tui/utils/provider-label.ts b/apps/kimi-code/src/tui/utils/provider-label.ts new file mode 100644 index 0000000000..19451bfa24 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/provider-label.ts @@ -0,0 +1,16 @@ +/** + * Readable label for a configured provider id. + * + * Config stores provider ids (`managed:kimi-code`, `managed:`, or a + * user-chosen name); every surface that shows one to the user — the model + * dialogs and the footer status line — renders it through here so the + * `managed:` plumbing never leaks into the UI. + */ + +import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; + +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; +} diff --git a/apps/kimi-code/src/tui/utils/status-line-command.ts b/apps/kimi-code/src/tui/utils/status-line-command.ts index 6482a4ac7c..ccbd501241 100644 --- a/apps/kimi-code/src/tui/utils/status-line-command.ts +++ b/apps/kimi-code/src/tui/utils/status-line-command.ts @@ -16,6 +16,8 @@ export const STATUS_LINE_MAX_CAPTURE_BYTES = 65_536; export interface StatusLinePayload { model: string; + /** Provider serving the current model; empty when the alias is unknown. */ + provider: string; cwd: string; gitBranch: string | null; permissionMode: string; diff --git a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts index a6be39adfb..02b77b3161 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; @@ -41,8 +42,15 @@ const baseState: AppState = { mcpServersSummary: null, }; +const catalogModel: ModelAlias = { + provider: 'moonshot', + model: 'kimi-k2-0905', + maxContextSize: 262_144, +}; + const payload: StatusLinePayload = { model: 'kimi-k2', + provider: 'moonshot', cwd: '/tmp/project', gitBranch: 'main', permissionMode: 'manual', @@ -122,6 +130,46 @@ describe('FooterComponent status_line items', () => { expect(tipsLast.indexOf('kimi-k2')).toBeLessThan(tipsLast.indexOf(tipsOnly)); }); + it('renders the provider ahead of the model when the slot is configured', () => { + const state: AppState = { + ...baseState, + availableModels: { 'kimi-k2': catalogModel }, + statusLine: { items: ['provider', 'model'], command: null }, + }; + + const line1 = plain(new FooterComponent(state).render(120)[0]!); + const providerAt = line1.indexOf('moonshot'); + const modelAt = line1.indexOf('kimi-k2-0905'); + expect(providerAt).toBeGreaterThanOrEqual(0); + expect(modelAt).toBeGreaterThan(providerAt); + }); + + it('keeps the provider out of the default layout', () => { + const state: AppState = { ...baseState, availableModels: { 'kimi-k2': catalogModel } }; + + expect(plain(new FooterComponent(state).render(200)[0]!)).not.toContain('moonshot'); + }); + + it('strips the managed: prefix from the provider label', () => { + const state: AppState = { + ...baseState, + availableModels: { 'kimi-k2': { ...catalogModel, provider: 'managed:openrouter' } }, + statusLine: { items: ['provider'], command: null }, + }; + + const line1 = plain(new FooterComponent(state).render(120)[0]!).trim(); + expect(line1).toBe('openrouter'); + }); + + it('drops the provider slot when the active model is not in the catalog', () => { + const state: AppState = { + ...baseState, + statusLine: { items: ['provider', 'model'], command: null }, + }; + + expect(plain(new FooterComponent(state).render(120)[0]!).trim()).toBe('kimi-k2'); + }); + it('renders nothing on line 1 for an empty items list', () => { const state: AppState = { ...baseState, @@ -140,6 +188,7 @@ describe('runStatusLineCommand', () => { expect(line).not.toBeNull(); const parsed = JSON.parse(line!); expect(parsed.model).toBe('kimi-k2'); + expect(parsed.provider).toBe('moonshot'); expect(parsed.gitBranch).toBe('main'); expect(parsed.cwd).toBe('/tmp/project'); }); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index b671922578..4c5dc255da 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -392,8 +392,12 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | `[notifications].enabled` | `boolean` | `true` | Whether desktop notifications are sent | | `[notifications].notification_condition` | `string` | `unfocused` | When to notify: `unfocused` (only when the terminal is not focused) or `always` | | `[upgrade].auto_install` | `boolean` | `true` | Whether new versions are installed automatically | -| `[status_line].items` | `string[]` | `[]` | Built-in slots to show on the first footer line and their order: `mode`, `goal`, `model`, `tasks`, `cwd`, `git`, `tips`. Unset keeps the default layout; unknown ids are skipped with a warning | -| `[status_line].command` | `string` | `""` | Custom status line command. Its first stdout line replaces the first footer line, with a JSON snapshot (model, cwd, git branch, permission mode, plan mode, context usage, session id, version) passed on stdin. Runs are capped at 300ms and throttled to once per second; failures fall back to the built-in layout | +| `[status_line].items` | `string[]` | `[]` | Built-in slots to show on the first footer line and their order: `mode`, `goal`, `provider`, `model`, `tasks`, `cwd`, `git`, `tips`. Unset keeps the default layout, which does not include `provider`; unknown ids are skipped with a warning | +| `[status_line].command` | `string` | `""` | Custom status line command. Its first stdout line replaces the first footer line, with a JSON snapshot (model, provider, cwd, git branch, permission mode, plan mode, context usage, session id, version) passed on stdin. Runs are capped at 300ms and throttled to once per second; failures fall back to the built-in layout | + +The `provider` slot shows which provider serves the current model (`managed:` ids +render without the prefix, e.g. `openrouter`), which disambiguates look-alike model +names across providers. It is opt-in: list it in `items` to enable it. ```toml # ~/.kimi-code/tui.toml @@ -411,7 +415,7 @@ notification_condition = "unfocused" # "unfocused" | "always" auto_install = true # [status_line] -# items = ["mode", "goal", "model", "tasks", "cwd", "git", "tips"] +# items = ["mode", "goal", "provider", "model", "tasks", "cwd", "git", "tips"] # command = "~/.kimi-code/statusline.sh" ``` diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 478959acf6..e13b0bedf5 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -392,8 +392,12 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod | `[notifications].enabled` | `boolean` | `true` | 是否发送桌面通知 | | `[notifications].notification_condition` | `string` | `unfocused` | 何时通知:`unfocused`(仅终端失去焦点时)或 `always`(总是) | | `[upgrade].auto_install` | `boolean` | `true` | 是否自动安装新版本 | -| `[status_line].items` | `string[]` | `[]` | 底部状态栏第一行展示哪些内置槽位及其顺序:`mode`、`goal`、`model`、`tasks`、`cwd`、`git`、`tips`。缺省保持默认布局;未知 id 跳过并告警 | -| `[status_line].command` | `string` | `""` | 自定义状态栏命令。其 stdout 第一行替换状态栏第一行,stdin 会收到 JSON 快照(model、cwd、git 分支、permission 模式、plan 模式、上下文用量、session id、版本)。运行上限 300ms、每秒最多一次;失败时回退内置布局 | +| `[status_line].items` | `string[]` | `[]` | 底部状态栏第一行展示哪些内置槽位及其顺序:`mode`、`goal`、`provider`、`model`、`tasks`、`cwd`、`git`、`tips`。缺省保持默认布局(默认不含 `provider`);未知 id 跳过并告警 | +| `[status_line].command` | `string` | `""` | 自定义状态栏命令。其 stdout 第一行替换状态栏第一行,stdin 会收到 JSON 快照(model、provider、cwd、git 分支、permission 模式、plan 模式、上下文用量、session id、版本)。运行上限 300ms、每秒最多一次;失败时回退内置布局 | + +`provider` 槽位显示当前模型由哪个提供商提供(`managed:` 前缀不展示,例如显示为 +`openrouter`),便于区分不同提供商下同名或相似的模型。该槽位需要显式在 `items` +中列出才会启用。 ```toml # ~/.kimi-code/tui.toml @@ -411,7 +415,7 @@ notification_condition = "unfocused" # "unfocused" | "always" auto_install = true # [status_line] -# items = ["mode", "goal", "model", "tasks", "cwd", "git", "tips"] +# items = ["mode", "goal", "provider", "model", "tasks", "cwd", "git", "tips"] # command = "~/.kimi-code/statusline.sh" ```