diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index 392d9f74b0..cb15252a27 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -47,6 +47,7 @@ const createSettings = (options?: { const createMockConfig = (overrides = {}) => ({ getContentGeneratorConfig: vi.fn(() => ({ authType: undefined })), getModel: vi.fn(() => 'gemini-pro'), + getModelDisplayName: vi.fn(() => 'Gemini Pro'), getTargetDir: vi.fn(() => '/projects/qwen-code'), getMcpServers: vi.fn(() => ({})), getBlockedMcpServers: vi.fn(() => []), @@ -106,7 +107,7 @@ describe('', () => { it('shows the header with all info when banner is visible', () => { const { lastFrame } = renderWithProviders(createMockUIState()); expect(lastFrame()).toContain('>_ Qwen Code'); - expect(lastFrame()).toContain('gemini-pro'); + expect(lastFrame()).toContain('Gemini Pro'); expect(lastFrame()).toContain('/projects/qwen-code'); }); diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index 2bff0766b1..b8662dc37a 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -15,7 +15,6 @@ import { Header, AuthDisplayType } from './Header.js'; import { Tips } from './Tips.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; -import { useUIState } from '../contexts/UIStateContext.js'; import { resolveCustomBanner } from '../utils/customBanner.js'; interface AppHeaderProps { @@ -50,11 +49,9 @@ function getAuthDisplayType( export const AppHeader = ({ version }: AppHeaderProps) => { const settings = useSettings(); const config = useConfig(); - const uiState = useUIState(); - const contentGeneratorConfig = config.getContentGeneratorConfig(); const authType = contentGeneratorConfig?.authType; - const model = uiState.currentModel; + const model = config.getModelDisplayName(); const targetDir = config.getTargetDir(); const showBanner = !config.getScreenReader() && !settings.merged.ui?.hideBanner; diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index afb3ce8606..f8f9e17a16 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -290,6 +290,12 @@ export function ModelDialog({ [{t2}] {` ${model.label}`} + {model.id !== model.label && ( + + {' '} + ({model.id}) + + )} {isRuntime && ( (Runtime) )} diff --git a/packages/cli/src/ui/components/StatusLineDialog.test.tsx b/packages/cli/src/ui/components/StatusLineDialog.test.tsx index 3f3f601115..391e1d9b2c 100644 --- a/packages/cli/src/ui/components/StatusLineDialog.test.tsx +++ b/packages/cli/src/ui/components/StatusLineDialog.test.tsx @@ -48,6 +48,7 @@ function createSettings(): LoadedSettings { const config = { getCliVersion: () => '1.2.3', getModel: () => 'qwen3-code-plus', + getModelDisplayName: () => 'Qwen3 Code Plus', getTargetDir: () => '/repo/project', getContentGeneratorConfig: () => ({ contextWindowSize: 1000, @@ -106,7 +107,7 @@ describe('StatusLineDialog', () => { frame.indexOf('current-dir'), ); expect(lastFrame()).toContain('Preview'); - expect(lastFrame()).toContain('qwen3-code-plus high'); + expect(lastFrame()).toContain('Qwen3 Code Plus high'); }); it('persists selected presets on enter', async () => { @@ -187,7 +188,7 @@ describe('StatusLineDialog', () => { await press(' '); expect(lastFrame()).toContain( - 'qwen3-code-plus high | feature/pr-4087-statusline | Context 75% left', + 'Qwen3 Code Plus high | feature/pr-4087-statusline | Context 75% left', ); await press('\r'); diff --git a/packages/cli/src/ui/components/StatusLineDialog.tsx b/packages/cli/src/ui/components/StatusLineDialog.tsx index 6368d1bc7c..62aa9c7d89 100644 --- a/packages/cli/src/ui/components/StatusLineDialog.tsx +++ b/packages/cli/src/ui/components/StatusLineDialog.tsx @@ -100,7 +100,7 @@ function getPreviewData(config: Config, uiState: UIState) { return buildStatusLinePresetData({ sessionId: stats.sessionId, version: config.getCliVersion(), - modelDisplayName: uiState.currentModel || config.getModel(), + modelDisplayName: config.getModelDisplayName(), reasoning: contentGeneratorConfig?.reasoning, currentDir: config.getTargetDir(), branch: uiState.branchName, diff --git a/packages/cli/src/ui/hooks/useStatusLine.test.ts b/packages/cli/src/ui/hooks/useStatusLine.test.ts index c2b851b8a4..4918010712 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.test.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.test.ts @@ -68,6 +68,7 @@ const getMockContentGeneratorConfig = (): MockContentGeneratorConfig => ({ const mockConfig = { getTargetDir: vi.fn(() => '/test/dir'), getModel: vi.fn(() => 'test-model'), + getModelDisplayName: vi.fn(() => 'Test Model'), getCliVersion: vi.fn(() => '1.0.0'), getContentGeneratorConfig: vi.fn(getMockContentGeneratorConfig), }; @@ -282,7 +283,7 @@ describe('useStatusLine', () => { const { result } = renderHook(() => useStatusLine()); expect(result.current.useThemeColors).toBe(true); - expect(result.current.lines).toEqual(['test-model']); + expect(result.current.lines).toEqual(['Test Model']); }); it('looks up the current branch pull request number with gh', async () => { @@ -315,7 +316,7 @@ describe('useStatusLine', () => { const { result } = renderHook(() => useStatusLine()); expect(child_process.exec).not.toHaveBeenCalled(); - expect(result.current.lines).toEqual(['test-model']); + expect(result.current.lines).toEqual(['Test Model']); }); it('renders model-with-reasoning and model-only together', () => { @@ -330,7 +331,7 @@ describe('useStatusLine', () => { const { result } = renderHook(() => useStatusLine()); expect(child_process.exec).not.toHaveBeenCalled(); - expect(result.current.lines).toEqual(['test-model high | test-model']); + expect(result.current.lines).toEqual(['Test Model high | Test Model']); }); it('refreshes when status line settings are saved in the same process', async () => { @@ -342,7 +343,7 @@ describe('useStatusLine', () => { const { result, rerender } = renderHook(() => useStatusLine()); expect(child_process.exec).not.toHaveBeenCalled(); - expect(result.current.lines).toEqual(['test-model']); + expect(result.current.lines).toEqual(['Test Model']); setStatusLineConfig({ type: 'preset', @@ -365,7 +366,7 @@ describe('useStatusLine', () => { vi.advanceTimersByTime(300); }); - expect(result.current.lines).toEqual(['test-model | #4118']); + expect(result.current.lines).toEqual(['Test Model | #4118']); }); it('reloads status line settings from disk when streaming becomes idle', async () => { @@ -375,7 +376,7 @@ describe('useStatusLine', () => { }); const { result, rerender } = renderHook(() => useStatusLine()); - expect(result.current.lines).toEqual(['test-model']); + expect(result.current.lines).toEqual(['Test Model']); mockSettings.reloadScopeFromDisk.mockImplementationOnce(() => { setStatusLineConfig({ @@ -397,7 +398,7 @@ describe('useStatusLine', () => { }); expect(mockSettings.reloadScopeFromDisk).toHaveBeenCalledOnce(); - expect(result.current.lines).toEqual(['test-model high']); + expect(result.current.lines).toEqual(['Test Model high']); }); it('uses command settings when a stale preset override no longer matches the settings type', () => { @@ -582,7 +583,7 @@ describe('useStatusLine', () => { const input = JSON.parse(stdinWrittenData); expect(input.session_id).toBe('test-session'); expect(input.version).toBe('1.0.0'); - expect(input.model.display_name).toBe('test-model'); + expect(input.model.display_name).toBe('Test Model'); expect(input.workspace.current_dir).toBe('/test/dir'); }); @@ -687,7 +688,7 @@ describe('useStatusLine', () => { renderHook(() => useStatusLine()); const input = JSON.parse(stdinWrittenData); - expect(input.model.display_name).toBe('test-model'); + expect(input.model.display_name).toBe('Test Model'); }); }); diff --git a/packages/cli/src/ui/hooks/useStatusLine.ts b/packages/cli/src/ui/hooks/useStatusLine.ts index 504923be10..0d522c1f88 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.ts @@ -393,7 +393,7 @@ export function useStatusLine(): { const data = buildStatusLinePresetData({ sessionId: stats.sessionId, version: cfg.getCliVersion(), - modelDisplayName: ui.currentModel || cfg.getModel(), + modelDisplayName: cfg.getModelDisplayName(), reasoning: contentGeneratorConfig?.reasoning, currentDir, branch: ui.branchName, @@ -444,7 +444,7 @@ export function useStatusLine(): { session_id: stats.sessionId, version: cfg.getCliVersion() || 'unknown', model: { - display_name: ui.currentModel || cfg.getModel() || 'unknown', + display_name: cfg.getModelDisplayName(), }, context_window: { context_window_size: contextWindowSize, diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 1feb4947bf..3121856ef6 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -3561,4 +3561,58 @@ describe('Model Switching and Config Updates', () => { ); }); }); + + describe('getModelDisplayName', () => { + it('should return resolved name when model is in registry', () => { + const config = new Config({ + ...baseParams, + authType: AuthType.USE_OPENAI, + model: 'gpt-4o', + modelProvidersConfig: { + [AuthType.USE_OPENAI]: [ + { + id: 'gpt-4o', + name: 'GPT-4o', + baseUrl: 'https://api.openai.example.com/v1', + envKey: 'OPENAI_API_KEY', + }, + ], + }, + }); + + expect(config.getModelDisplayName()).toBe('GPT-4o'); + }); + + it('should return raw modelId when model is not in registry', () => { + const config = new Config({ + ...baseParams, + authType: AuthType.USE_OPENAI, + model: 'custom-runtime-model', + modelProvidersConfig: { + [AuthType.USE_OPENAI]: [ + { + id: 'gpt-4o', + name: 'GPT-4o', + baseUrl: 'https://api.openai.example.com/v1', + envKey: 'OPENAI_API_KEY', + }, + ], + }, + }); + + expect(config.getModelDisplayName()).toBe('custom-runtime-model'); + }); + + it('should return raw modelId when currentAuthType is falsy', () => { + const config = new Config({ + ...baseParams, + model: 'some-model', + // authType is not set + }); + + // getModel() returns 'some-model', getModelDisplayName returns it as-is + // because currentAuthType is falsy + expect(config.getModelDisplayName()).toBe('some-model'); + }); + }); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index f7c8f7c731..9e5f1a02ff 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2199,6 +2199,15 @@ export class Config { ); } + /** + * Get the human-readable display name for the currently selected model. + * Resolves the model id to its name from the model registry. + * Falls back to the raw model id when the model is not found. + */ + getModelDisplayName(): string { + return this.modelsConfig.getModelDisplayName(this.getModel()); + } + onModelChange(listener: (model: string) => void): () => void { this.modelChangeListeners.add(listener); return () => { diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index 937472543d..8801aa44b4 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -2421,4 +2421,79 @@ describe('ModelsConfig', () => { expect(gc.samplingParams).toBeUndefined(); }); }); + + describe('getModelDisplayName', () => { + it('should return resolved.name when model is found in registry', () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'gpt-4o', + name: 'GPT-4o', + baseUrl: 'https://api.openai.example.com/v1', + envKey: 'OPENAI_API_KEY', + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + }); + + expect(modelsConfig.getModelDisplayName('gpt-4o')).toBe('GPT-4o'); + }); + + it('should return raw modelId when currentAuthType is falsy', () => { + const modelsConfig = new ModelsConfig(); + // currentAuthType is undefined by default + + expect(modelsConfig.getModelDisplayName('some-model')).toBe('some-model'); + }); + + it('should return raw modelId when model is not found in registry', () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'gpt-4o', + name: 'GPT-4o', + baseUrl: 'https://api.openai.example.com/v1', + envKey: 'OPENAI_API_KEY', + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + }); + + // 'unknown-model' is not in the registry + expect(modelsConfig.getModelDisplayName('unknown-model')).toBe( + 'unknown-model', + ); + }); + + it('should return raw modelId when model.name equals model.id', () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'coder-model', + name: 'coder-model', + baseUrl: 'https://api.openai.example.com/v1', + envKey: 'OPENAI_API_KEY', + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + }); + + // name === id, so registry returns the id as name + expect(modelsConfig.getModelDisplayName('coder-model')).toBe( + 'coder-model', + ); + }); + }); }); diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index c10143888f..43caae852f 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -312,6 +312,18 @@ export class ModelsConfig { return this.modelRegistry.getModel(authType, modelId); } + /** + * Get the display name for a model by its id. + * Looks up the model in the registry using the current authType and returns + * its resolved name. Falls back to the raw model id when the model is not + * found in the registry (e.g. runtime models or unknown models). + */ + getModelDisplayName(modelId: string): string { + if (!this.currentAuthType) return modelId; + const resolved = this.modelRegistry.getModel(this.currentAuthType, modelId); + return resolved?.name ?? modelId; + } + /** * Set model programmatically (e.g., VLM auto-switch, fallback). * Supports both registry models and raw model IDs.