fix(ui): display model name instead of id in statusline and startup banner (#4741)

* fix(ui): display model name instead of id in statusline and startup banner

* Update packages/cli/src/ui/components/ModelDialog.tsx

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(ui): remove duplicate model.id display in ModelDialog

* test(core): add unit tests for ModelsConfig/Config.getModelDisplayName()

* refactor(core): simplify Config.getModelDisplayName() by removing dead 'unknown' branch

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
yao 2026-06-04 21:41:59 +08:00 committed by GitHub
parent 871b9c1eb7
commit 6d083fad5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 175 additions and 19 deletions

View file

@ -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('<AppHeader />', () => {
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');
});

View file

@ -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;

View file

@ -290,6 +290,12 @@ export function ModelDialog({
[{t2}]
</Text>
<Text>{` ${model.label}`}</Text>
{model.id !== model.label && (
<Text color={theme.text.secondary} italic>
{' '}
({model.id})
</Text>
)}
{isRuntime && (
<Text color={theme.status.warning}> (Runtime)</Text>
)}

View file

@ -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');

View file

@ -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,

View file

@ -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');
});
});

View file

@ -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,

View file

@ -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');
});
});
});

View file

@ -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 () => {

View file

@ -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',
);
});
});
});

View file

@ -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.