diff --git a/packages/cli/src/ui/components/StatusLineDialog.test.tsx b/packages/cli/src/ui/components/StatusLineDialog.test.tsx index 8364d1e4510..3f3f601115d 100644 --- a/packages/cli/src/ui/components/StatusLineDialog.test.tsx +++ b/packages/cli/src/ui/components/StatusLineDialog.test.tsx @@ -49,7 +49,10 @@ const config = { getCliVersion: () => '1.2.3', getModel: () => 'qwen3-code-plus', getTargetDir: () => '/repo/project', - getContentGeneratorConfig: () => ({ contextWindowSize: 1000 }), + getContentGeneratorConfig: () => ({ + contextWindowSize: 1000, + reasoning: { effort: 'high' }, + }), } as Config; const uiState = { @@ -84,8 +87,26 @@ describe('StatusLineDialog', () => { expect(lastFrame()).toContain('Configure Status Line'); expect(lastFrame()).toContain('Type to search'); + const frame = lastFrame() ?? ''; + expect(frame).toContain('model-with-reasoning'); + expect(frame).toContain('model-only'); + expect(frame).toContain('git-branch'); + expect(frame).toContain('context-remaining'); + expect(frame).toContain('current-dir'); + expect(frame.indexOf('model-with-reasoning')).toBeLessThan( + frame.indexOf('model-only'), + ); + expect(frame.indexOf('model-only')).toBeLessThan( + frame.indexOf('git-branch'), + ); + expect(frame.indexOf('git-branch')).toBeLessThan( + frame.indexOf('context-remaining'), + ); + expect(frame.indexOf('context-remaining')).toBeLessThan( + frame.indexOf('current-dir'), + ); expect(lastFrame()).toContain('Preview'); - expect(lastFrame()).toContain('qwen3-code-plus'); + expect(lastFrame()).toContain('qwen3-code-plus high'); }); it('persists selected presets on enter', async () => { @@ -117,10 +138,10 @@ describe('StatusLineDialog', () => { useThemeColors: true, items: [ 'model-with-reasoning', + 'git-branch', 'context-remaining', 'current-dir', 'context-used', - 'git-branch', ], }); expect( @@ -137,6 +158,53 @@ describe('StatusLineDialog', () => { expect(onClose).toHaveBeenCalled(); }); + it('keeps preset priority order after an item is toggled off and on', async () => { + const settings = createSettings(); + const { stdin, lastFrame } = render( + + + , + ); + + const press = async (input: string) => { + act(() => { + stdin.write(input); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }; + + await press('j'); + await press('j'); + await press('j'); + await press(' '); + await press(' '); + + expect(lastFrame()).toContain( + 'qwen3-code-plus high | feature/pr-4087-statusline | Context 75% left', + ); + + await press('\r'); + + expect(settings.merged.ui?.statusLine).toEqual({ + type: 'preset', + useThemeColors: true, + items: [ + 'model-with-reasoning', + 'git-branch', + 'context-remaining', + 'current-dir', + 'context-used', + ], + }); + }); + it('saves back to workspace settings when workspace config is effective', async () => { const settings = createSettings(); settings.workspace.settings.ui = { diff --git a/packages/cli/src/ui/components/StatusLineDialog.tsx b/packages/cli/src/ui/components/StatusLineDialog.tsx index 92637dffdbc..6368d1bc7c2 100644 --- a/packages/cli/src/ui/components/StatusLineDialog.tsx +++ b/packages/cli/src/ui/components/StatusLineDialog.tsx @@ -22,6 +22,7 @@ import { buildStatusLinePresetLines, DEFAULT_STATUS_LINE_PRESET_CONFIG, normalizeStatusLinePresetConfig, + orderStatusLinePresetItems, STATUS_LINE_PRESET_ITEMS, type StatusLinePresetConfig, type StatusLinePresetItemId, @@ -57,19 +58,11 @@ function buildInitialSelectedKeys(settings: LoadedSettings): string[] { function buildConfigFromKeys(keys: readonly string[]): StatusLinePresetConfig { const selected = new Set(keys); - const validItemIds = new Set(STATUS_LINE_PRESET_ITEMS.map((item) => item.id)); - const items = [ - ...new Set( - keys.filter((key): key is StatusLinePresetItemId => - validItemIds.has(key as StatusLinePresetItemId), - ), - ), - ]; return { type: 'preset', useThemeColors: selected.has(THEME_COLORS_KEY), - items, + items: orderStatusLinePresetItems(keys), }; } @@ -102,15 +95,16 @@ function getPreviewData(config: Config, uiState: UIState) { const stats = uiState.sessionStats; const metrics = stats.metrics; const { totalInputTokens, totalOutputTokens } = aggregateModelTokens(metrics); + const contentGeneratorConfig = config.getContentGeneratorConfig(); return buildStatusLinePresetData({ sessionId: stats.sessionId, version: config.getCliVersion(), modelDisplayName: uiState.currentModel || config.getModel(), + reasoning: contentGeneratorConfig?.reasoning, currentDir: config.getTargetDir(), branch: uiState.branchName, - contextWindowSize: - config.getContentGeneratorConfig()?.contextWindowSize || 0, + contextWindowSize: contentGeneratorConfig?.contextWindowSize || 0, currentUsage: stats.lastPromptTokenCount, totalInputTokens, totalOutputTokens, diff --git a/packages/cli/src/ui/hooks/useStatusLine.test.ts b/packages/cli/src/ui/hooks/useStatusLine.test.ts index fafe1908cf7..daf61874874 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.test.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import * as child_process from 'child_process'; import { StreamingState } from '../types.js'; +import type { StatusLinePresetReasoning } from '../statusLinePresets.js'; const debugLogMock = vi.hoisted(() => ({ log: vi.fn(), @@ -49,11 +50,20 @@ vi.mock('../contexts/UIStateContext.js', () => ({ useUIState: () => mockUIState, })); +type MockContentGeneratorConfig = { + contextWindowSize: number; + reasoning?: StatusLinePresetReasoning; +}; + +const getMockContentGeneratorConfig = (): MockContentGeneratorConfig => ({ + contextWindowSize: 131072, +}); + const mockConfig = { getTargetDir: vi.fn(() => '/test/dir'), getModel: vi.fn(() => 'test-model'), getCliVersion: vi.fn(() => '1.0.0'), - getContentGeneratorConfig: vi.fn(() => ({ contextWindowSize: 131072 })), + getContentGeneratorConfig: vi.fn(getMockContentGeneratorConfig), }; vi.mock('../contexts/ConfigContext.js', () => ({ useConfig: () => mockConfig, @@ -149,6 +159,9 @@ describe('useStatusLine', () => { mockUIState.sessionStats.metrics.files.totalLinesRemoved = 0; mockVimMode.vimEnabled = false; mockVimMode.vimMode = 'INSERT'; + mockConfig.getContentGeneratorConfig.mockReturnValue({ + contextWindowSize: 131072, + }); // Dynamic import to get fresh module after mocks const mod = await import('./useStatusLine.js'); @@ -236,6 +249,21 @@ describe('useStatusLine', () => { expect(result.current.lines).toEqual(['test-model']); }); + it('renders model-with-reasoning and model-only together', () => { + mockConfig.getContentGeneratorConfig.mockReturnValue({ + contextWindowSize: 131072, + reasoning: { effort: 'high' }, + }); + setStatusLineConfig({ + type: 'preset', + items: ['model', 'model-with-reasoning'], + }); + const { result } = renderHook(() => useStatusLine()); + + expect(child_process.exec).not.toHaveBeenCalled(); + expect(result.current.lines).toEqual(['test-model high | test-model']); + }); + it('refreshes when status line settings are saved in the same process', async () => { mockUIState.branchName = 'dragon/feat-reproduce-skill'; setStatusLineConfig({ diff --git a/packages/cli/src/ui/hooks/useStatusLine.ts b/packages/cli/src/ui/hooks/useStatusLine.ts index 26b533d9da3..20fb7c9dec4 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.ts @@ -373,12 +373,13 @@ export function useStatusLine(): { const { totalInputTokens, totalOutputTokens } = aggregateModelTokens(m); - const contextWindowSize = - cfg.getContentGeneratorConfig()?.contextWindowSize || 0; + const contentGeneratorConfig = cfg.getContentGeneratorConfig(); + const contextWindowSize = contentGeneratorConfig?.contextWindowSize || 0; const data = buildStatusLinePresetData({ sessionId: stats.sessionId, version: cfg.getCliVersion(), modelDisplayName: ui.currentModel || cfg.getModel(), + reasoning: contentGeneratorConfig?.reasoning, currentDir, branch: ui.branchName, pullRequestNumber: pullRequestNumberRef.current, diff --git a/packages/cli/src/ui/statusLinePresets.test.ts b/packages/cli/src/ui/statusLinePresets.test.ts index 4e6178dc697..6a1a8b32264 100644 --- a/packages/cli/src/ui/statusLinePresets.test.ts +++ b/packages/cli/src/ui/statusLinePresets.test.ts @@ -11,15 +11,18 @@ import { buildStatusLinePresetData, buildStatusLinePresetLines, DEFAULT_STATUS_LINE_PRESET_CONFIG, + formatModelWithReasoning, formatTokenCount, getRunStateLabel, inferPullRequestNumber, normalizeStatusLinePresetConfig, + orderStatusLinePresetItems, STATUS_LINE_PRESET_ITEM_IDS, + STATUS_LINE_PRESET_ITEMS, } from './statusLinePresets.js'; describe('statusLinePresets', () => { - it('normalizes valid preset configs and drops unknown items', () => { + it('normalizes valid preset configs and orders items by priority', () => { expect( normalizeStatusLinePresetConfig({ type: 'preset', @@ -54,7 +57,51 @@ describe('statusLinePresets', () => { ).toEqual(DEFAULT_STATUS_LINE_PRESET_CONFIG); }); - it('renders available preset items and omits unavailable optional fields', () => { + it('keeps default preset items in priority order', () => { + expect(DEFAULT_STATUS_LINE_PRESET_CONFIG.items).toEqual( + orderStatusLinePresetItems( + [...DEFAULT_STATUS_LINE_PRESET_CONFIG.items].reverse(), + ), + ); + }); + + it('orders preset items directly', () => { + expect(orderStatusLinePresetItems([])).toEqual([]); + expect(orderStatusLinePresetItems(['bogus'])).toEqual([]); + expect(orderStatusLinePresetItems([42, null])).toEqual([]); + expect( + orderStatusLinePresetItems([ + 'run-state', + 'model', + 'git-branch', + 'model', + 'context-remaining', + ]), + ).toEqual(['model', 'git-branch', 'context-remaining', 'run-state']); + }); + + it('formats model reasoning directly', () => { + expect(formatModelWithReasoning('qwen3-code-plus', false)).toBe( + 'qwen3-code-plus reasoning off', + ); + expect( + formatModelWithReasoning('qwen3-code-plus', { effort: 'high' }), + ).toBe('qwen3-code-plus high'); + expect( + formatModelWithReasoning('qwen3-code-plus', { effort: undefined }), + ).toBe('qwen3-code-plus'); + expect(formatModelWithReasoning('qwen3-code-plus', undefined)).toBe( + 'qwen3-code-plus', + ); + }); + + it('labels the plain model preset as model-only', () => { + expect( + STATUS_LINE_PRESET_ITEMS.find((item) => item.id === 'model')?.label, + ).toBe('model-only'); + }); + + it('renders available preset items in priority order', () => { const data = buildStatusLinePresetData({ sessionId: 'session-123', version: '1.2.3', @@ -75,12 +122,12 @@ describe('statusLinePresets', () => { { type: 'preset', items: [ + 'run-state', 'model', - 'context-remaining', - 'current-dir', - 'pull-request-number', 'branch-changes', - 'run-state', + 'pull-request-number', + 'current-dir', + 'context-remaining', ], }, data, @@ -95,6 +142,7 @@ describe('statusLinePresets', () => { sessionId: 'session-123', version: '1.2.3', modelDisplayName: 'qwen3-code-plus', + reasoning: { effort: 'high' }, currentDir: '/repo/project', branch: 'feature/pr-4087-statusline', contextWindowSize: 1000, @@ -115,11 +163,67 @@ describe('statusLinePresets', () => { data, ), ).toEqual([ - 'qwen3-code-plus | Context 75% left | /repo/project | Context 25% used | feature/pr-4087-statusline | project | #4087 | +12 -3 | Ready | v1.2.3 | 1.0k window | 250 used | 1.2k in | 340 out | session-123', + 'qwen3-code-plus high | qwen3-code-plus | feature/pr-4087-statusline | Context 75% left | 1.2k in | 340 out | /repo/project | project | #4087 | +12 -3 | Context 25% used | Ready | v1.2.3 | 1.0k window | 250 used | session-123', ]); }); - it('treats model and model-with-reasoning as mutually exclusive', () => { + it('renders model and model-with-reasoning together', () => { + const data = buildStatusLinePresetData({ + sessionId: 'session-123', + version: '1.2.3', + modelDisplayName: 'qwen3-code-plus', + reasoning: { effort: 'high' }, + currentDir: '/repo/project', + branch: undefined, + contextWindowSize: 0, + currentUsage: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + streamingState: StreamingState.Idle, + }); + + expect( + buildStatusLinePresetLines( + { + type: 'preset', + items: ['model', 'model-with-reasoning'], + }, + data, + ), + ).toEqual(['qwen3-code-plus high | qwen3-code-plus']); + }); + + it('shows when reasoning is disabled', () => { + const data = buildStatusLinePresetData({ + sessionId: 'session-123', + version: '1.2.3', + modelDisplayName: 'qwen3-code-plus', + reasoning: false, + currentDir: '/repo/project', + branch: undefined, + contextWindowSize: 0, + currentUsage: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + streamingState: StreamingState.Idle, + }); + + expect( + buildStatusLinePresetLines( + { + type: 'preset', + items: ['model-with-reasoning'], + }, + data, + ), + ).toEqual(['qwen3-code-plus reasoning off']); + }); + + it('falls back to the model name when reasoning is unset', () => { const data = buildStatusLinePresetData({ sessionId: 'session-123', version: '1.2.3', @@ -139,7 +243,7 @@ describe('statusLinePresets', () => { buildStatusLinePresetLines( { type: 'preset', - items: ['model-with-reasoning', 'model'], + items: ['model-with-reasoning'], }, data, ), diff --git a/packages/cli/src/ui/statusLinePresets.ts b/packages/cli/src/ui/statusLinePresets.ts index 4b548dd7f3f..d82b20c8713 100644 --- a/packages/cli/src/ui/statusLinePresets.ts +++ b/packages/cli/src/ui/statusLinePresets.ts @@ -9,20 +9,20 @@ import { StreamingState } from './types.js'; export const STATUS_LINE_PRESET_ITEM_IDS = [ 'model-with-reasoning', + 'model', + 'git-branch', 'context-remaining', + 'total-input-tokens', + 'total-output-tokens', 'current-dir', - 'context-used', - 'git-branch', - 'model', 'project-name', 'pull-request-number', 'branch-changes', + 'context-used', 'run-state', 'qwen-version', 'context-window-size', 'used-tokens', - 'total-input-tokens', - 'total-output-tokens', 'session-id', ] as const; @@ -42,10 +42,18 @@ export interface StatusLinePresetConfig { useThemeColors?: boolean; } +export type StatusLinePresetReasoning = + | false + | { + effort?: 'low' | 'medium' | 'high' | 'max'; + } + | undefined; + export interface StatusLinePresetData { sessionId: string; version: string; modelDisplayName: string; + reasoning: StatusLinePresetReasoning; currentDir: string; projectName: string | undefined; branch: string | undefined; @@ -80,6 +88,17 @@ export const STATUS_LINE_PRESET_ITEMS: readonly StatusLinePresetItem[] = [ description: 'Current model name with reasoning level when available', defaultSelected: true, }, + { + id: 'model', + label: 'model-only', + description: 'Current model name without reasoning level', + }, + { + id: 'git-branch', + label: 'git-branch', + description: 'Current Git branch when available', + defaultSelected: true, + }, { id: 'context-remaining', label: 'context-remaining', @@ -87,28 +106,21 @@ export const STATUS_LINE_PRESET_ITEMS: readonly StatusLinePresetItem[] = [ defaultSelected: true, }, { - id: 'current-dir', - label: 'current-dir', - description: 'Current working directory', - defaultSelected: true, + id: 'total-input-tokens', + label: 'total-input-tokens', + description: 'Total input tokens used in session', }, { - id: 'context-used', - label: 'context-used', - description: 'Percentage of context window used', - defaultSelected: true, + id: 'total-output-tokens', + label: 'total-output-tokens', + description: 'Total output tokens used in session', }, { - id: 'git-branch', - label: 'git-branch', - description: 'Current Git branch when available', + id: 'current-dir', + label: 'current-dir', + description: 'Current working directory', defaultSelected: true, }, - { - id: 'model', - label: 'model', - description: 'Current model name', - }, { id: 'project-name', label: 'project-name', @@ -124,6 +136,12 @@ export const STATUS_LINE_PRESET_ITEMS: readonly StatusLinePresetItem[] = [ label: 'branch-changes', description: 'Session file changes added and removed', }, + { + id: 'context-used', + label: 'context-used', + description: 'Percentage of context window used', + defaultSelected: true, + }, { id: 'run-state', label: 'run-state', @@ -144,16 +162,6 @@ export const STATUS_LINE_PRESET_ITEMS: readonly StatusLinePresetItem[] = [ label: 'used-tokens', description: 'Current prompt tokens used', }, - { - id: 'total-input-tokens', - label: 'total-input-tokens', - description: 'Total input tokens used in session', - }, - { - id: 'total-output-tokens', - label: 'total-output-tokens', - description: 'Total output tokens used in session', - }, { id: 'session-id', label: 'session-id', @@ -165,11 +173,26 @@ const STATUS_LINE_PRESET_ITEM_ID_SET = new Set( STATUS_LINE_PRESET_ITEM_IDS, ); +export function orderStatusLinePresetItems( + items: readonly unknown[], +): StatusLinePresetItemId[] { + const selectedItems = new Set( + items.filter( + (item): item is StatusLinePresetItemId => + typeof item === 'string' && STATUS_LINE_PRESET_ITEM_ID_SET.has(item), + ), + ); + + return STATUS_LINE_PRESET_ITEM_IDS.filter((item) => selectedItems.has(item)); +} + export const DEFAULT_STATUS_LINE_PRESET_CONFIG: StatusLinePresetConfig = { type: 'preset', useThemeColors: true, - items: STATUS_LINE_PRESET_ITEMS.filter((item) => item.defaultSelected).map( - (item) => item.id, + items: orderStatusLinePresetItems( + STATUS_LINE_PRESET_ITEMS.filter((item) => item.defaultSelected).map( + (item) => item.id, + ), ), }; @@ -186,12 +209,8 @@ export function normalizeStatusLinePresetConfig( } const hasItemsArray = Array.isArray(candidate['items']); - const rawItems = hasItemsArray ? (candidate['items'] as unknown[]) : []; const items = hasItemsArray - ? rawItems.filter( - (item): item is StatusLinePresetItemId => - typeof item === 'string' && STATUS_LINE_PRESET_ITEM_ID_SET.has(item), - ) + ? orderStatusLinePresetItems(candidate['items'] as unknown[]) : []; return { @@ -200,9 +219,7 @@ export function normalizeStatusLinePresetConfig( typeof candidate['useThemeColors'] === 'boolean' ? candidate['useThemeColors'] : true, - items: hasItemsArray - ? [...new Set(items)] - : [...DEFAULT_STATUS_LINE_PRESET_CONFIG.items], + items: hasItemsArray ? items : [...DEFAULT_STATUS_LINE_PRESET_CONFIG.items], }; } @@ -240,6 +257,19 @@ export function getRunStateLabel(state: StreamingState): string { } } +export function formatModelWithReasoning( + modelDisplayName: string, + reasoning: StatusLinePresetReasoning, +): string { + if (reasoning === false) { + return `${modelDisplayName} reasoning off`; + } + if (reasoning?.effort) { + return `${modelDisplayName} ${reasoning.effort}`; + } + return modelDisplayName; +} + export function inferPullRequestNumber( branch: string | undefined, ): string | undefined { @@ -256,6 +286,7 @@ export function buildStatusLinePresetData(params: { sessionId: string; version: string | undefined; modelDisplayName: string | undefined; + reasoning?: StatusLinePresetReasoning; currentDir: string; branch: string | undefined; pullRequestNumber?: string | undefined; @@ -284,6 +315,7 @@ export function buildStatusLinePresetData(params: { sessionId: params.sessionId, version: params.version || 'unknown', modelDisplayName: params.modelDisplayName || 'unknown', + reasoning: params.reasoning, currentDir: params.currentDir, projectName: nodePath.basename(params.currentDir) || undefined, branch: params.branch, @@ -305,20 +337,16 @@ export function buildStatusLinePresetParts( data: StatusLinePresetData, ): string[] { const parts: string[] = []; - const seen = new Set(); - - for (const item of config.items) { - if (seen.has(item)) { - continue; - } - seen.add(item); + for (const item of orderStatusLinePresetItems(config.items)) { switch (item) { case 'model-with-reasoning': + parts.push( + formatModelWithReasoning(data.modelDisplayName, data.reasoning), + ); + break; case 'model': parts.push(data.modelDisplayName); - seen.add('model'); - seen.add('model-with-reasoning'); break; case 'context-remaining': if (data.contextWindowSize > 0) {