diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 07af4b0a77..24394a0132 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -614,14 +614,21 @@ const SETTINGS_SCHEMA = { category: 'UI', requiresRestart: false, default: undefined as - | { - type: 'command'; - command: string; - refreshInterval?: number; - } + | ( + | { + type: 'command'; + command: string; + refreshInterval?: number; + } + | { + type: 'preset'; + items: string[]; + useThemeColors?: boolean; + } + ) | undefined, description: - 'Custom status line display configuration. Optional `refreshInterval` (seconds, >= 1) re-runs the command on a timer so external data stays fresh.', + 'Status line display configuration. Use `type: "preset"` with built-in item ids, or `type: "command"` with a shell command. Optional command `refreshInterval` (seconds, >= 1) re-runs the command on a timer so external data stays fresh.', showInDialog: false, }, customThemes: { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d6cbfd82ed..48ea880947 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -131,6 +131,7 @@ import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useSessionStats } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; +import type { StatusLinePresetConfig } from './statusLinePresets.js'; import { useExtensionUpdates, useConfirmUpdateRequests, @@ -675,6 +676,26 @@ export const AppContainer = (props: AppContainerProps) => { const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = useSettingsCommand(); + const [isStatusLineDialogOpen, setStatusLineDialogOpen] = useState(false); + const openStatusLineDialog = useCallback( + () => setStatusLineDialogOpen(true), + [], + ); + const closeStatusLineDialog = useCallback( + () => setStatusLineDialogOpen(false), + [], + ); + const [statusLineSettingsVersion, setStatusLineSettingsVersion] = useState(0); + const [statusLineConfigOverride, setStatusLineConfigOverride] = useState< + StatusLinePresetConfig | undefined + >(undefined); + const notifyStatusLineSettingsChanged = useCallback( + (newConfig: StatusLinePresetConfig) => { + setStatusLineConfigOverride(newConfig); + setStatusLineSettingsVersion((version) => version + 1); + }, + [], + ); const { isMemoryDialogOpen, openMemoryDialog, closeMemoryDialog } = useMemoryDialog(); @@ -769,6 +790,7 @@ export const AppContainer = (props: AppContainerProps) => { openEditorDialog, openMemoryDialog, openSettingsDialog, + openStatusLineDialog, openModelDialog, openManageModelsDialog, openTrustDialog, @@ -803,6 +825,7 @@ export const AppContainer = (props: AppContainerProps) => { openEditorDialog, openMemoryDialog, openSettingsDialog, + openStatusLineDialog, openModelDialog, openManageModelsDialog, openArenaDialog, @@ -1846,6 +1869,7 @@ export const AppContainer = (props: AppContainerProps) => { !!loopDetectionConfirmationRequest || isThemeDialogOpen || isSettingsDialogOpen || + isStatusLineDialogOpen || isMemoryDialogOpen || isModelDialogOpen || isManageModelsDialogOpen || @@ -2191,6 +2215,8 @@ export const AppContainer = (props: AppContainerProps) => { exitEditorDialog, isSettingsDialogOpen, closeSettingsDialog, + isStatusLineDialogOpen, + closeStatusLineDialog, isMemoryDialogOpen, closeMemoryDialog, activeArenaDialog, @@ -2622,6 +2648,9 @@ export const AppContainer = (props: AppContainerProps) => { debugMessage, quittingMessages, isSettingsDialogOpen, + isStatusLineDialogOpen, + statusLineSettingsVersion, + statusLineConfigOverride, isMemoryDialogOpen, isModelDialogOpen, isFastModelMode, @@ -2740,6 +2769,9 @@ export const AppContainer = (props: AppContainerProps) => { debugMessage, quittingMessages, isSettingsDialogOpen, + isStatusLineDialogOpen, + statusLineSettingsVersion, + statusLineConfigOverride, isMemoryDialogOpen, isModelDialogOpen, isFastModelMode, @@ -2863,6 +2895,8 @@ export const AppContainer = (props: AppContainerProps) => { handleEditorSelect, exitEditorDialog, closeSettingsDialog, + closeStatusLineDialog, + notifyStatusLineSettingsChanged, closeMemoryDialog, closeModelDialog, openModelDialog, @@ -2937,6 +2971,8 @@ export const AppContainer = (props: AppContainerProps) => { handleEditorSelect, exitEditorDialog, closeSettingsDialog, + closeStatusLineDialog, + notifyStatusLineSettingsChanged, closeMemoryDialog, closeModelDialog, openModelDialog, diff --git a/packages/cli/src/ui/commands/statuslineCommand.test.ts b/packages/cli/src/ui/commands/statuslineCommand.test.ts index 05ac69d985..5b7bd6024f 100644 --- a/packages/cli/src/ui/commands/statuslineCommand.test.ts +++ b/packages/cli/src/ui/commands/statuslineCommand.test.ts @@ -21,7 +21,7 @@ describe('statuslineCommand', () => { expect(statuslineCommand.description).toBeDefined(); }); - it('should return submit_prompt with default prompt when no args', () => { + it('should open the preset dialog when no args are provided', () => { if (!statuslineCommand.action) { throw new Error('statusline command must have an action'); } @@ -29,18 +29,9 @@ describe('statuslineCommand', () => { const result = statuslineCommand.action(mockContext, ''); expect(result).toEqual({ - type: 'submit_prompt', - content: [ - { - text: expect.stringContaining('statusline-setup'), - }, - ], + type: 'dialog', + dialog: 'statusline', }); - // Default prompt should mention PS1 - expect(result).toHaveProperty( - 'content.0.text', - expect.stringContaining('PS1'), - ); }); it('should use user-provided args as the prompt', () => { @@ -63,16 +54,16 @@ describe('statuslineCommand', () => { }); }); - it('should trim whitespace-only args and use default prompt', () => { + it('should open the preset dialog when args are whitespace only', () => { if (!statuslineCommand.action) { throw new Error('statusline command must have an action'); } const result = statuslineCommand.action(mockContext, ' '); - expect(result).toHaveProperty( - 'content.0.text', - expect.stringContaining('PS1'), - ); + expect(result).toEqual({ + type: 'dialog', + dialog: 'statusline', + }); }); }); diff --git a/packages/cli/src/ui/commands/statuslineCommand.ts b/packages/cli/src/ui/commands/statuslineCommand.ts index 7e2a1fdeb6..dd40ca5238 100644 --- a/packages/cli/src/ui/commands/statuslineCommand.ts +++ b/packages/cli/src/ui/commands/statuslineCommand.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SlashCommand, SubmitPromptActionReturn } from './types.js'; +import type { + OpenDialogActionReturn, + SlashCommand, + SubmitPromptActionReturn, +} from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; @@ -15,9 +19,18 @@ export const statuslineCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, - action: (_context, args): SubmitPromptActionReturn => { - const prompt = - args.trim() || 'Configure my statusLine from my shell PS1 configuration'; + action: ( + _context, + args, + ): OpenDialogActionReturn | SubmitPromptActionReturn => { + const prompt = args.trim(); + if (!prompt) { + return { + type: 'dialog', + dialog: 'statusline', + }; + } + return { type: 'submit_prompt', content: [ diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 3df7dc212b..c16246c518 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -173,6 +173,7 @@ export interface OpenDialogActionReturn { | 'theme' | 'editor' | 'settings' + | 'statusline' | 'memory' | 'model' | 'fast-model' diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 6e7cdd641e..3c2103853e 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -16,6 +16,7 @@ import { SettingInputPrompt } from './SettingInputPrompt.js'; import { PluginChoicePrompt } from './PluginChoicePrompt.js'; import { ThemeDialog } from './ThemeDialog.js'; import { SettingsDialog } from './SettingsDialog.js'; +import { StatusLineDialog } from './StatusLineDialog.js'; import { QwenOAuthProgress } from './QwenOAuthProgress.js'; import { ExternalAuthProgress } from './ExternalAuthProgress.js'; import { AuthDialog } from '../auth/AuthDialog.js'; @@ -254,6 +255,19 @@ export const DialogManager = ({ ); } + if (uiState.isStatusLineDialogOpen) { + return ( + + ); + } if (uiState.isMemoryDialogOpen) { return ; } diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index b0539f3609..761c289122 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -28,7 +28,7 @@ export const Footer: React.FC = () => { const uiState = useUIState(); const config = useConfig(); const { vimEnabled, vimMode } = useVimMode(); - const { lines: statusLineLines } = useStatusLine(); + const { lines: statusLineLines, useThemeColors } = useStatusLine(); const configInitMessage = useConfigInitMessage(uiState.isConfigInitialized); const { promptTokenCount, showAutoAcceptIndicator } = { @@ -141,7 +141,12 @@ export const Footer: React.FC = () => { !uiState.ctrlCPressedOnce && !uiState.ctrlDPressedOnce && statusLineLines.map((line, i) => ( - + {line} ))} diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index cfe1acd11d..73d97608c3 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -90,6 +90,7 @@ const createUIState = (overrides: Partial = {}): UIState => debugMessage: '', quittingMessages: null, isSettingsDialogOpen: false, + isStatusLineDialogOpen: false, isMemoryDialogOpen: false, isModelDialogOpen: false, isFastModelMode: false, diff --git a/packages/cli/src/ui/components/StatusLineDialog.test.tsx b/packages/cli/src/ui/components/StatusLineDialog.test.tsx new file mode 100644 index 0000000000..8364d1e451 --- /dev/null +++ b/packages/cli/src/ui/components/StatusLineDialog.test.tsx @@ -0,0 +1,213 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act } from 'react'; +import { render } from 'ink-testing-library'; +import { describe, expect, it, vi } from 'vitest'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { LoadedSettings, SettingScope } from '../../config/settings.js'; +import type { UIState } from '../contexts/UIStateContext.js'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import { MessageType, StreamingState } from '../types.js'; +import { StatusLineDialog } from './StatusLineDialog.js'; + +function createSettings(): LoadedSettings { + const dir = mkdtempSync(path.join(tmpdir(), 'qwen-statusline-')); + return new LoadedSettings( + { + settings: {}, + originalSettings: {}, + path: path.join(dir, 'system-settings.json'), + }, + { + settings: {}, + originalSettings: {}, + path: path.join(dir, 'system-defaults.json'), + }, + { + settings: {}, + originalSettings: {}, + path: path.join(dir, 'user-settings.json'), + }, + { + settings: {}, + originalSettings: {}, + path: path.join(dir, 'workspace-settings.json'), + }, + true, + new Set(), + ); +} + +const config = { + getCliVersion: () => '1.2.3', + getModel: () => 'qwen3-code-plus', + getTargetDir: () => '/repo/project', + getContentGeneratorConfig: () => ({ contextWindowSize: 1000 }), +} as Config; + +const uiState = { + currentModel: 'qwen3-code-plus', + branchName: 'feature/pr-4087-statusline', + streamingState: StreamingState.Idle, + sessionStats: { + sessionId: 'session-123', + lastPromptTokenCount: 250, + metrics: { + models: {}, + files: { totalLinesAdded: 12, totalLinesRemoved: 3 }, + }, + }, +} as UIState; + +describe('StatusLineDialog', () => { + it('renders a searchable preset picker with preview', () => { + const settings = createSettings(); + const { lastFrame } = render( + + + , + ); + + expect(lastFrame()).toContain('Configure Status Line'); + expect(lastFrame()).toContain('Type to search'); + expect(lastFrame()).toContain('Preview'); + expect(lastFrame()).toContain('qwen3-code-plus'); + }); + + it('persists selected presets on enter', async () => { + const settings = createSettings(); + const addItem = vi.fn(); + const onClose = vi.fn(); + const onSaved = vi.fn(); + const { stdin } = render( + + + , + ); + + act(() => { + stdin.write('\r'); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(settings.merged.ui?.statusLine).toEqual({ + type: 'preset', + useThemeColors: true, + items: [ + 'model-with-reasoning', + 'context-remaining', + 'current-dir', + 'context-used', + 'git-branch', + ], + }); + expect( + settings.forScope(SettingScope.User).settings.ui?.statusLine, + ).toEqual(settings.merged.ui?.statusLine); + expect(addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Status line preset saved to user settings.', + }, + expect.any(Number), + ); + expect(onSaved).toHaveBeenCalledWith(settings.merged.ui?.statusLine); + expect(onClose).toHaveBeenCalled(); + }); + + it('saves back to workspace settings when workspace config is effective', async () => { + const settings = createSettings(); + settings.workspace.settings.ui = { + statusLine: { + type: 'preset', + useThemeColors: false, + items: ['model'], + }, + }; + settings.workspace.originalSettings.ui = settings.workspace.settings.ui; + settings.recomputeMerged(); + const addItem = vi.fn(); + const { stdin } = render( + + + , + ); + + act(() => { + stdin.write('\r'); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(settings.forScope(SettingScope.User).settings.ui).toBeUndefined(); + expect(settings.forScope(SettingScope.Workspace).settings.ui).toEqual({ + statusLine: { + type: 'preset', + useThemeColors: false, + items: ['model'], + }, + }); + expect(addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Status line preset saved to workspace settings.', + }, + expect.any(Number), + ); + }); + + it('does not append navigation keys to the search query', async () => { + const settings = createSettings(); + const { stdin, lastFrame } = render( + + + , + ); + + act(() => { + stdin.write('m'); + stdin.write('j'); + stdin.write('k'); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(lastFrame()).toContain('> m'); + expect(lastFrame()).not.toContain('> mj'); + expect(lastFrame()).not.toContain('> mk'); + }); +}); diff --git a/packages/cli/src/ui/components/StatusLineDialog.tsx b/packages/cli/src/ui/components/StatusLineDialog.tsx new file mode 100644 index 0000000000..92637dffdb --- /dev/null +++ b/packages/cli/src/ui/components/StatusLineDialog.tsx @@ -0,0 +1,307 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { Box, Text } from 'ink'; +import type { Config } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { SettingScope } from '../../config/settings.js'; +import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { theme } from '../semantic-colors.js'; +import { MessageType } from '../types.js'; +import type { UIState } from '../contexts/UIStateContext.js'; +import { MultiSelect, type MultiSelectItem } from './shared/MultiSelect.js'; +import { + aggregateModelTokens, + buildStatusLinePresetData, + buildStatusLinePresetLines, + DEFAULT_STATUS_LINE_PRESET_CONFIG, + normalizeStatusLinePresetConfig, + STATUS_LINE_PRESET_ITEMS, + type StatusLinePresetConfig, + type StatusLinePresetItemId, +} from '../statusLinePresets.js'; + +type StatusLineOption = + | { kind: 'theme-colors' } + | { kind: 'separator' } + | { kind: 'item'; id: StatusLinePresetItemId }; + +interface StatusLineDialogProps { + settings: LoadedSettings; + config: Config; + uiState: UIState; + addItem: UseHistoryManagerReturn['addItem']; + onSaved?: (config: StatusLinePresetConfig) => void; + onClose: () => void; + availableTerminalHeight?: number; +} + +const THEME_COLORS_KEY = 'theme-colors'; +const DESCRIPTION_COLUMN = 24; + +function buildInitialSelectedKeys(settings: LoadedSettings): string[] { + const preset = + normalizeStatusLinePresetConfig(settings.merged.ui?.statusLine) ?? + DEFAULT_STATUS_LINE_PRESET_CONFIG; + return [ + ...(preset.useThemeColors ? [THEME_COLORS_KEY] : []), + ...preset.items, + ]; +} + +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, + }; +} + +function getEffectiveStatusLineScope(settings: LoadedSettings): SettingScope { + if (settings.forScope(SettingScope.System).settings.ui?.statusLine) { + return SettingScope.System; + } + if ( + settings.isTrusted && + settings.forScope(SettingScope.Workspace).settings.ui?.statusLine + ) { + return SettingScope.Workspace; + } + return SettingScope.User; +} + +function getOptionSearchText( + option: MultiSelectItem, +): string { + const value = + option.value.kind === 'theme-colors' + ? 'theme colors active theme' + : option.value.kind === 'separator' + ? '' + : option.value.id; + return `${option.label} ${value}`.toLowerCase(); +} + +function getPreviewData(config: Config, uiState: UIState) { + const stats = uiState.sessionStats; + const metrics = stats.metrics; + const { totalInputTokens, totalOutputTokens } = aggregateModelTokens(metrics); + + return buildStatusLinePresetData({ + sessionId: stats.sessionId, + version: config.getCliVersion(), + modelDisplayName: uiState.currentModel || config.getModel(), + currentDir: config.getTargetDir(), + branch: uiState.branchName, + contextWindowSize: + config.getContentGeneratorConfig()?.contextWindowSize || 0, + currentUsage: stats.lastPromptTokenCount, + totalInputTokens, + totalOutputTokens, + totalLinesAdded: metrics.files.totalLinesAdded, + totalLinesRemoved: metrics.files.totalLinesRemoved, + streamingState: uiState.streamingState, + }); +} + +export function StatusLineDialog({ + settings, + config, + uiState, + addItem, + onSaved, + onClose, + availableTerminalHeight, +}: StatusLineDialogProps): React.JSX.Element { + const [query, setQuery] = useState(''); + const [selectedKeys, setSelectedKeys] = useState(() => + buildInitialSelectedKeys(settings), + ); + + const options = useMemo>>( + () => [ + { + key: THEME_COLORS_KEY, + value: { kind: 'theme-colors' }, + label: `${'Use theme colors'.padEnd(DESCRIPTION_COLUMN)} Apply colors from the active /theme`, + }, + { + key: 'statusline-separator', + value: { kind: 'separator' }, + label: '───────────────────────', + disabled: true, + separator: true, + }, + ...STATUS_LINE_PRESET_ITEMS.map((item) => ({ + key: item.id, + value: { kind: 'item' as const, id: item.id }, + label: `${item.label.padEnd(DESCRIPTION_COLUMN)} ${item.description}`, + })), + ], + [], + ); + + const filteredOptions = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return options; + } + return options.filter((option) => + getOptionSearchText(option).includes(normalizedQuery), + ); + }, [options, query]); + + const presetConfig = useMemo( + () => buildConfigFromKeys(selectedKeys), + [selectedKeys], + ); + const previewData = useMemo( + () => getPreviewData(config, uiState), + [config, uiState], + ); + const previewLines = useMemo( + () => buildStatusLinePresetLines(presetConfig, previewData), + [presetConfig, previewData], + ); + + const handleConfirm = useCallback(() => { + const effectiveScope = getEffectiveStatusLineScope(settings); + settings.setValue(effectiveScope, 'ui.statusLine', presetConfig); + onSaved?.(presetConfig); + addItem( + { + type: MessageType.INFO, + text: `Status line preset saved to ${effectiveScope.toLowerCase()} settings.`, + }, + Date.now(), + ); + onClose(); + }, [addItem, onClose, onSaved, presetConfig, settings]); + + useKeypress( + (key) => { + if (key.name === 'escape') { + if (query) { + setQuery(''); + return; + } + onClose(); + return; + } + + if (key.name === 'backspace' || key.name === 'delete') { + setQuery((current) => current.slice(0, -1)); + return; + } + + if ( + key.name === 'j' || + key.name === 'k' || + key.name === 'up' || + key.name === 'down' || + key.name === 'return' + ) { + return; + } + + if ( + !key.ctrl && + !key.meta && + key.sequence.length === 1 && + key.sequence >= '!' && + key.sequence <= '~' + ) { + setQuery((current) => `${current}${key.sequence}`); + } + }, + { isActive: true }, + ); + + const maxItemsToShow = Math.max( + 5, + Math.min(10, (availableTerminalHeight ?? 18) - 8), + ); + + return ( + + Configure Status Line + + Select which items to display in the status line. + + + + Type to search + {query ? `> ${query}` : '>'} + + + + {filteredOptions.length > 0 ? ( + + ) : ( + No preset items match. + )} + + + + Preview + {previewLines.length > 0 ? ( + previewLines.map((line, index) => ( + + {line} + + )) + ) : ( + + Select at least one item to show a status line. + + )} + + + + + Use up/down to navigate, space to select, enter to confirm, esc to + cancel + + + + ); +} diff --git a/packages/cli/src/ui/components/shared/MultiSelect.tsx b/packages/cli/src/ui/components/shared/MultiSelect.tsx index 7191d4fd63..b618953108 100644 --- a/packages/cli/src/ui/components/shared/MultiSelect.tsx +++ b/packages/cli/src/ui/components/shared/MultiSelect.tsx @@ -14,6 +14,7 @@ import type { SelectionListItem } from '../../hooks/useSelectionList.js'; export interface MultiSelectItem extends SelectionListItem { label: string; + separator?: boolean; } export interface MultiSelectProps { @@ -28,6 +29,8 @@ export interface MultiSelectProps { showNumbers?: boolean; showScrollArrows?: boolean; maxItemsToShow?: number; + checkedText?: string; + showActiveMarker?: boolean; } const EMPTY_SELECTED_KEYS: string[] = []; @@ -53,6 +56,8 @@ export function MultiSelect({ showNumbers = true, showScrollArrows = false, maxItemsToShow = 10, + checkedText = '[✓]', + showActiveMarker = false, }: MultiSelectProps): React.JSX.Element { const [scrollOffset, setScrollOffset] = useState(0); const selectedKeySet = useMemo(() => new Set(selectedKeys), [selectedKeys]); @@ -136,11 +141,16 @@ export function MultiSelect({ const itemIndex = scrollOffset + index; const isActive = activeIndex === itemIndex; const isChecked = selectedKeySet.has(item.key); + const activeMarker = isActive ? '›' : ' '; const itemNumberText = `${String(itemIndex + 1).padStart( numberColumnWidth, )}.`; - const checkboxText = item.disabled ? '[x]' : isChecked ? '[✓]' : '[ ]'; + const checkboxText = item.disabled + ? '[x]' + : isChecked + ? checkedText + : '[ ]'; let textColor = theme.text.primary; if (item.disabled) { @@ -151,8 +161,31 @@ export function MultiSelect({ textColor = theme.text.accent; } + if (item.separator) { + return ( + + {showActiveMarker && ( + + {activeMarker} + + )} + + + + + {item.label} + + + ); + } + return ( + {showActiveMarker && ( + + {activeMarker} + + )} {checkboxText} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index c1f1c7b8e2..3b30fe0273 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -14,6 +14,7 @@ import { type SettingScope } from '../../config/settings.js'; import type { AuthController } from '../auth/useAuth.js'; import type { HistoryItem } from '../types.js'; import { type ArenaDialogType } from '../hooks/useArenaCommand.js'; +import type { StatusLinePresetConfig } from '../statusLinePresets.js'; export type HelpTab = 'general' | 'commands' | 'custom-commands'; @@ -37,6 +38,8 @@ export interface UIActions { ) => void; exitEditorDialog: () => void; closeSettingsDialog: () => void; + closeStatusLineDialog: () => void; + notifyStatusLineSettingsChanged: (config: StatusLinePresetConfig) => void; closeMemoryDialog: () => void; closeModelDialog: () => void; openModelDialog: (options?: { fastModelMode?: boolean }) => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 07eb1a9365..25569a64e5 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -38,6 +38,7 @@ import { type HelpTab } from './UIActionsContext.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; import { type ProviderUpdateRequest } from '../hooks/useProviderUpdates.js'; import { type ArenaDialogType } from '../hooks/useArenaCommand.js'; +import type { StatusLinePresetConfig } from '../statusLinePresets.js'; export interface UIState { history: HistoryItem[]; @@ -51,6 +52,9 @@ export interface UIState { debugMessage: string; quittingMessages: HistoryItem[] | null; isSettingsDialogOpen: boolean; + isStatusLineDialogOpen: boolean; + statusLineSettingsVersion?: number; + statusLineConfigOverride?: StatusLinePresetConfig; isMemoryDialogOpen: boolean; isModelDialogOpen: boolean; isFastModelMode: boolean; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 461c55844d..7dc514fe66 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -140,6 +140,7 @@ describe('useSlashCommandProcessor', () => { openEditorDialog: vi.fn(), openMemoryDialog: mockOpenMemoryDialog, openSettingsDialog: vi.fn(), + openStatusLineDialog: vi.fn(), openModelDialog: mockOpenModelDialog, openManageModelsDialog: vi.fn(), openTrustDialog: vi.fn(), diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index c5e30948ae..e779427687 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -91,6 +91,7 @@ export interface SlashCommandProcessorActions { openEditorDialog: () => void; openMemoryDialog: () => void; openSettingsDialog: () => void; + openStatusLineDialog: () => void; openModelDialog: (options?: { fastModelMode?: boolean }) => void; openManageModelsDialog: () => void; openTrustDialog: () => void; @@ -683,6 +684,9 @@ export const useSlashCommandProcessor = ( case 'settings': actions.openSettingsDialog(); return { type: 'handled' }; + case 'statusline': + actions.openStatusLineDialog(); + return { type: 'handled' }; case 'memory': actions.openMemoryDialog(); return { type: 'handled' }; diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index 11a165e93f..0be40def8e 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -43,6 +43,10 @@ export interface DialogCloseOptions { isSettingsDialogOpen: boolean; closeSettingsDialog: () => void; + // Status line dialog + isStatusLineDialogOpen: boolean; + closeStatusLineDialog: () => void; + // Memory dialog isMemoryDialogOpen: boolean; closeMemoryDialog: () => void; @@ -100,6 +104,11 @@ export function useDialogClose(options: DialogCloseOptions) { return true; } + if (options.isStatusLineDialogOpen) { + options.closeStatusLineDialog(); + return true; + } + if (options.isHelpDialogOpen && options.closeHelpDialog) { options.closeHelpDialog(); return true; diff --git a/packages/cli/src/ui/hooks/useStatusLine.test.ts b/packages/cli/src/ui/hooks/useStatusLine.test.ts index ca2776b5c0..fafe1908cf 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.test.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.test.ts @@ -7,6 +7,13 @@ 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'; + +const debugLogMock = vi.hoisted(() => ({ + log: vi.fn(), + error: vi.fn(), + warn: vi.fn(), +})); // --- Mock child_process (auto-mock, then override exec in beforeEach) --- vi.mock('child_process'); @@ -32,6 +39,11 @@ const mockUIState = { }, currentModel: 'test-model', branchName: 'main' as string | undefined, + streamingState: StreamingState.Idle, + statusLineSettingsVersion: 0, + statusLineConfigOverride: undefined as + | { type: 'preset'; items: string[]; useThemeColors?: boolean } + | undefined, }; vi.mock('../contexts/UIStateContext.js', () => ({ useUIState: () => mockUIState, @@ -60,10 +72,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { await importOriginal(); return { ...original, - createDebugLogger: () => ({ - log: vi.fn(), - error: vi.fn(), - }), + createDebugLogger: () => debugLogMock, }; }); @@ -84,6 +93,7 @@ let mockKill: ReturnType; function setStatusLineConfig( config: | { type: string; command: string; refreshInterval?: number } + | { type: 'preset'; items: string[]; useThemeColors?: boolean } | undefined, ) { mockSettings.merged = config ? { ui: { statusLine: config } } : {}; @@ -132,6 +142,8 @@ describe('useStatusLine', () => { mockUIState.sessionStats.lastPromptTokenCount = 100; mockUIState.currentModel = 'test-model'; mockUIState.branchName = 'main'; + mockUIState.statusLineSettingsVersion = 0; + mockUIState.statusLineConfigOverride = undefined; mockUIState.sessionStats.metrics.tools.totalCalls = 0; mockUIState.sessionStats.metrics.files.totalLinesAdded = 0; mockUIState.sessionStats.metrics.files.totalLinesRemoved = 0; @@ -178,6 +190,146 @@ describe('useStatusLine', () => { }); }); + describe('preset status line', () => { + it('returns the preset theme color preference', () => { + setStatusLineConfig({ + type: 'preset', + useThemeColors: true, + items: ['model'], + }); + const { result } = renderHook(() => useStatusLine()); + + expect(result.current.useThemeColors).toBe(true); + expect(result.current.lines).toEqual(['test-model']); + }); + + it('looks up the current branch pull request number with gh', async () => { + mockUIState.branchName = 'dragon/feat-reproduce-skill'; + setStatusLineConfig({ + type: 'preset', + items: ['pull-request-number'], + }); + const { result } = renderHook(() => useStatusLine()); + + expect(child_process.exec).toHaveBeenCalledOnce(); + expect(lastExecCommand).toBe('gh pr view --json number --jq .number'); + expect(result.current.lines).toEqual([]); + + await act(async () => { + execCallback(null, '4118\n', ''); + }); + await act(async () => { + vi.advanceTimersByTime(300); + }); + + expect(result.current.lines).toEqual(['#4118']); + }); + + it('does not run gh when pull request number is not selected', () => { + setStatusLineConfig({ + type: 'preset', + items: ['model'], + }); + const { result } = renderHook(() => useStatusLine()); + + expect(child_process.exec).not.toHaveBeenCalled(); + expect(result.current.lines).toEqual(['test-model']); + }); + + it('refreshes when status line settings are saved in the same process', async () => { + mockUIState.branchName = 'dragon/feat-reproduce-skill'; + setStatusLineConfig({ + type: 'preset', + items: ['model-with-reasoning'], + }); + const { result, rerender } = renderHook(() => useStatusLine()); + + expect(child_process.exec).not.toHaveBeenCalled(); + expect(result.current.lines).toEqual(['test-model']); + + setStatusLineConfig({ + type: 'preset', + items: ['model-with-reasoning', 'pull-request-number'], + }); + mockUIState.statusLineConfigOverride = { + type: 'preset', + items: ['model-with-reasoning', 'pull-request-number'], + }; + mockUIState.statusLineSettingsVersion += 1; + rerender(); + + expect(child_process.exec).toHaveBeenCalledOnce(); + expect(lastExecCommand).toBe('gh pr view --json number --jq .number'); + + await act(async () => { + execCallback(null, '4118\n', ''); + }); + await act(async () => { + vi.advanceTimersByTime(300); + }); + + expect(result.current.lines).toEqual(['test-model | #4118']); + }); + + it('uses command settings when a stale preset override no longer matches the settings type', () => { + setStatusLineConfig({ + type: 'command', + command: 'echo from-settings', + }); + mockUIState.statusLineConfigOverride = { + type: 'preset', + items: ['model'], + }; + + renderHook(() => useStatusLine()); + + expect(child_process.exec).toHaveBeenCalledOnce(); + expect(lastExecCommand).toBe('echo from-settings'); + }); + + it('ignores a stale preset override when settings no longer have status line config', () => { + setStatusLineConfig(undefined); + mockUIState.statusLineConfigOverride = { + type: 'preset', + items: ['model'], + }; + + const { result } = renderHook(() => useStatusLine()); + + expect(result.current.lines).toEqual([]); + expect(child_process.exec).not.toHaveBeenCalled(); + }); + + it('logs and retries pull request lookup failures after state changes', async () => { + mockUIState.branchName = 'dragon/feat-reproduce-skill'; + setStatusLineConfig({ + type: 'preset', + items: ['pull-request-number'], + }); + const { rerender } = renderHook(() => useStatusLine()); + + expect(child_process.exec).toHaveBeenCalledOnce(); + + await act(async () => { + execCallback(new Error('gh not authenticated'), '', ''); + }); + + expect(debugLogMock.warn).toHaveBeenCalledWith( + 'statusline: gh pr view failed:', + 'gh not authenticated', + ); + + mockUIState.sessionStats.lastPromptTokenCount = 101; + rerender(); + await act(async () => { + vi.advanceTimersByTime(300); + }); + + expect(child_process.exec).toHaveBeenCalledTimes(2); + expect(lastExecCommand).toBe('gh pr view --json number --jq .number'); + }); + }); + // --- Command execution --- describe('command execution', () => { diff --git a/packages/cli/src/ui/hooks/useStatusLine.ts b/packages/cli/src/ui/hooks/useStatusLine.ts index 6944811824..7be99e4e3a 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.ts @@ -12,6 +12,13 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import type { SessionMetrics } from '../contexts/SessionContext.js'; +import { + aggregateModelTokens, + buildStatusLinePresetData, + buildStatusLinePresetLines, + normalizeStatusLinePresetConfig, + type StatusLinePresetConfig, +} from '../statusLinePresets.js'; /** * Structured JSON input passed to the status line command via stdin. @@ -66,7 +73,7 @@ export interface StatusLineCommandInput { }; } -interface StatusLineConfig { +interface StatusLineCommandConfig { type: 'command'; command: string; // Re-run the command every N seconds so external data (git branch, quota, @@ -75,10 +82,18 @@ interface StatusLineConfig { refreshInterval?: number; } +type StatusLineConfig = StatusLineCommandConfig | StatusLinePresetConfig; + const debugLog = createDebugLogger('STATUS_LINE'); // Footer's bottom row (hint/mode indicator) occupies 1 line, so the status // line gets at most 2 to keep the total footer height at 3 rows max. export const MAX_STATUS_LINES = 2; +const PULL_REQUEST_LOOKUP_COMMAND = 'gh pr view --json number --jq .number'; + +function parsePullRequestNumber(stdout: string): string | undefined { + const prNumber = stdout.trim(); + return /^\d+$/.test(prNumber) ? prNumber : undefined; +} function getStatusLineConfig( settings: ReturnType, @@ -106,7 +121,7 @@ function getStatusLineConfig( } return config; } - return undefined; + return normalizeStatusLinePresetConfig(raw); } function buildMetricsPayload( @@ -151,17 +166,39 @@ function buildMetricsPayload( */ export function useStatusLine(): { lines: string[]; + useThemeColors: boolean; } { const settings = useSettings(); const uiState = useUIState(); const config = useConfig(); const { vimEnabled, vimMode } = useVimMode(); - const statusLineConfig = getStatusLineConfig(settings); - const statusLineCommand = statusLineConfig?.command; - const refreshInterval = statusLineConfig?.refreshInterval; + const settingsStatusLineConfig = getStatusLineConfig(settings); + const statusLineConfigOverride = uiState.statusLineConfigOverride; + const statusLineConfig = + statusLineConfigOverride && + settingsStatusLineConfig && + statusLineConfigOverride.type === settingsStatusLineConfig.type + ? statusLineConfigOverride + : settingsStatusLineConfig; + const statusLineCommand = + statusLineConfig?.type === 'command' ? statusLineConfig.command : undefined; + const statusLinePreset = + statusLineConfig?.type === 'preset' ? statusLineConfig : undefined; + const statusLineSettingsVersion = uiState.statusLineSettingsVersion ?? 0; + const hasStatusLinePreset = statusLinePreset !== undefined; + const statusLinePresetUseThemeColors = + statusLinePreset?.useThemeColors ?? false; + const statusLinePresetItemsKey = statusLinePreset?.items.join('\0') ?? ''; + const refreshInterval = + statusLineConfig?.type === 'command' + ? statusLineConfig.refreshInterval + : undefined; const [output, setOutput] = useState([]); + const [pullRequestNumber, setPullRequestNumber] = useState< + string | undefined + >(undefined); // Keep latest values in refs so the stable doUpdate callback can read them // without being recreated on every render. @@ -175,6 +212,10 @@ export function useStatusLine(): { vimModeRef.current = vimMode; const statusLineCommandRef = useRef(statusLineCommand); statusLineCommandRef.current = statusLineCommand; + const statusLinePresetRef = useRef(statusLinePreset); + statusLinePresetRef.current = statusLinePreset; + const pullRequestNumberRef = useRef(pullRequestNumber); + pullRequestNumberRef.current = pullRequestNumber; const debounceTimerRef = useRef | undefined>( undefined, @@ -184,7 +225,7 @@ export function useStatusLine(): { // Initialized with current values so the state-change effect // does not fire redundantly on mount. const { lastPromptTokenCount } = uiState.sessionStats; - const { currentModel, branchName } = uiState; + const { currentModel, branchName, streamingState } = uiState; const totalToolCalls = uiState.sessionStats.metrics.tools.totalCalls; const totalLinesAdded = uiState.sessionStats.metrics.files.totalLinesAdded; const totalLinesRemoved = @@ -198,6 +239,7 @@ export function useStatusLine(): { totalToolCalls: number; totalLinesAdded: number; totalLinesRemoved: number; + streamingState: string; }>({ promptTokenCount: lastPromptTokenCount, currentModel, @@ -206,6 +248,7 @@ export function useStatusLine(): { totalToolCalls, totalLinesAdded, totalLinesRemoved, + streamingState, }); // Guard: when true, the mount effect has already called doUpdate so the @@ -215,8 +258,126 @@ export function useStatusLine(): { // Track the active child process so we can kill it on new updates / unmount. const activeChildRef = useRef(undefined); const generationRef = useRef(0); + const pullRequestLookupChildRef = useRef(undefined); + const pullRequestLookupGenerationRef = useRef(0); + const pullRequestLookupKeyRef = useRef(undefined); + + const updatePullRequestNumber = useCallback( + (nextPullRequestNumber: string | undefined) => { + if (pullRequestNumberRef.current === nextPullRequestNumber) { + return; + } + pullRequestNumberRef.current = nextPullRequestNumber; + setPullRequestNumber(nextPullRequestNumber); + }, + [], + ); + + const clearPullRequestLookup = useCallback(() => { + pullRequestLookupChildRef.current?.kill(); + pullRequestLookupChildRef.current = undefined; + pullRequestLookupGenerationRef.current++; + pullRequestLookupKeyRef.current = undefined; + updatePullRequestNumber(undefined); + }, [updatePullRequestNumber]); + + const ensurePullRequestNumber = useCallback( + ( + preset: StatusLinePresetConfig, + currentDir: string, + branch: string | undefined, + ) => { + if (!preset.items.includes('pull-request-number') || !branch) { + clearPullRequestLookup(); + return; + } + + const lookupKey = `${currentDir}\0${branch}`; + if (pullRequestLookupKeyRef.current === lookupKey) { + return; + } + + pullRequestLookupChildRef.current?.kill(); + pullRequestLookupChildRef.current = undefined; + updatePullRequestNumber(undefined); + + const generation = ++pullRequestLookupGenerationRef.current; + let child: ChildProcess; + try { + child = exec( + PULL_REQUEST_LOOKUP_COMMAND, + { cwd: currentDir, timeout: 2000, maxBuffer: 1024 }, + (error, stdout) => { + if ( + generation !== pullRequestLookupGenerationRef.current || + pullRequestLookupKeyRef.current !== lookupKey + ) { + return; + } + pullRequestLookupChildRef.current = undefined; + if (error) { + debugLog.warn('statusline: gh pr view failed:', error.message); + pullRequestLookupKeyRef.current = undefined; + updatePullRequestNumber(undefined); + return; + } + updatePullRequestNumber(parsePullRequestNumber(stdout)); + }, + ); + } catch (err) { + debugLog.warn('statusline: gh pr view failed:', (err as Error).message); + pullRequestLookupKeyRef.current = undefined; + updatePullRequestNumber(undefined); + return; + } + + pullRequestLookupChildRef.current = child; + pullRequestLookupKeyRef.current = lookupKey; + }, + [clearPullRequestLookup, updatePullRequestNumber], + ); const doUpdate = useCallback(() => { + const preset = statusLinePresetRef.current; + if (preset) { + if (activeChildRef.current) { + activeChildRef.current.kill(); + activeChildRef.current = undefined; + generationRef.current++; + } + + const ui = uiStateRef.current; + const cfg = configRef.current; + const stats = ui.sessionStats; + const m = stats.metrics; + const currentDir = cfg.getTargetDir(); + ensurePullRequestNumber(preset, currentDir, ui.branchName); + + const { totalInputTokens, totalOutputTokens } = aggregateModelTokens(m); + + const contextWindowSize = + cfg.getContentGeneratorConfig()?.contextWindowSize || 0; + const data = buildStatusLinePresetData({ + sessionId: stats.sessionId, + version: cfg.getCliVersion(), + modelDisplayName: ui.currentModel || cfg.getModel(), + currentDir, + branch: ui.branchName, + pullRequestNumber: pullRequestNumberRef.current, + contextWindowSize, + currentUsage: stats.lastPromptTokenCount, + totalInputTokens, + totalOutputTokens, + totalLinesAdded: m.files.totalLinesAdded, + totalLinesRemoved: m.files.totalLinesRemoved, + streamingState: ui.streamingState, + }); + setOutput(buildStatusLinePresetLines(preset, data)); + return; + } + + clearPullRequestLookup(); + const cmd = statusLineCommandRef.current; if (!cmd) { setOutput([]); @@ -243,12 +404,7 @@ export function useStatusLine(): { ) : 0; - let totalInputTokens = 0; - let totalOutputTokens = 0; - for (const mm of Object.values(m.models)) { - totalInputTokens += mm.tokens.prompt; - totalOutputTokens += mm.tokens.candidates; - } + const { totalInputTokens, totalOutputTokens } = aggregateModelTokens(m); const input: StatusLineCommandInput = { session_id: stats.sessionId, @@ -342,7 +498,7 @@ export function useStatusLine(): { child.stdin.write(JSON.stringify(input)); child.stdin.end(); } - }, []); // No deps — reads everything from refs + }, [clearPullRequestLookup, ensurePullRequestNumber]); const scheduleUpdate = useCallback(() => { if (debounceTimerRef.current !== undefined) { @@ -356,11 +512,16 @@ export function useStatusLine(): { // Trigger update when meaningful state changes useEffect(() => { - if (!statusLineCommand) { + if (!statusLineCommand && !hasStatusLinePreset) { // Command removed — kill any in-flight process and discard callbacks. activeChildRef.current?.kill(); activeChildRef.current = undefined; generationRef.current++; + pullRequestLookupChildRef.current?.kill(); + pullRequestLookupChildRef.current = undefined; + pullRequestLookupGenerationRef.current++; + pullRequestLookupKeyRef.current = undefined; + updatePullRequestNumber(undefined); if (debounceTimerRef.current !== undefined) { clearTimeout(debounceTimerRef.current); debounceTimerRef.current = undefined; @@ -377,7 +538,8 @@ export function useStatusLine(): { branchName !== prev.branchName || totalToolCalls !== prev.totalToolCalls || totalLinesAdded !== prev.totalLinesAdded || - totalLinesRemoved !== prev.totalLinesRemoved + totalLinesRemoved !== prev.totalLinesRemoved || + streamingState !== prev.streamingState ) { prev.promptTokenCount = lastPromptTokenCount; prev.currentModel = currentModel; @@ -386,10 +548,15 @@ export function useStatusLine(): { prev.totalToolCalls = totalToolCalls; prev.totalLinesAdded = totalLinesAdded; prev.totalLinesRemoved = totalLinesRemoved; + prev.streamingState = streamingState; scheduleUpdate(); } }, [ statusLineCommand, + hasStatusLinePreset, + statusLinePresetUseThemeColors, + statusLinePresetItemsKey, + statusLineSettingsVersion, lastPromptTokenCount, currentModel, effectiveVim, @@ -397,14 +564,16 @@ export function useStatusLine(): { totalToolCalls, totalLinesAdded, totalLinesRemoved, + streamingState, scheduleUpdate, + updatePullRequestNumber, ]); // Re-execute immediately when the command itself changes (hot reload). // Skip the first run — the mount effect below already handles it. useEffect(() => { if (!hasMountedRef.current) return; - if (statusLineCommand) { + if (statusLineCommand || hasStatusLinePreset) { // Clear any pending debounce so we don't get a redundant second run. if (debounceTimerRef.current !== undefined) { clearTimeout(debounceTimerRef.current); @@ -414,7 +583,26 @@ export function useStatusLine(): { } // Cleanup when command is removed is handled by the state-change effect. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [statusLineCommand]); + }, [ + statusLineCommand, + hasStatusLinePreset, + statusLinePresetUseThemeColors, + statusLinePresetItemsKey, + statusLineSettingsVersion, + ]); + + // Re-render preset output once the async GitHub PR lookup returns. + useEffect(() => { + if (!hasMountedRef.current || !hasStatusLinePreset) return; + scheduleUpdate(); + }, [ + pullRequestNumber, + hasStatusLinePreset, + statusLinePresetUseThemeColors, + statusLinePresetItemsKey, + statusLineSettingsVersion, + scheduleUpdate, + ]); // Periodic refresh — re-run the command every `refreshInterval` seconds. // The tick yields if a previous exec is still running: unlike state-change @@ -440,12 +628,17 @@ export function useStatusLine(): { const genRef = generationRef; const debounceRef = debounceTimerRef; const childRef = activeChildRef; + const pullRequestChildRef = pullRequestLookupChildRef; + const pullRequestGenerationRef = pullRequestLookupGenerationRef; doUpdate(); return () => { // Kill active child process and invalidate callbacks childRef.current?.kill(); childRef.current = undefined; genRef.current++; + pullRequestChildRef.current?.kill(); + pullRequestChildRef.current = undefined; + pullRequestGenerationRef.current++; if (debounceRef.current !== undefined) { clearTimeout(debounceRef.current); debounceRef.current = undefined; @@ -454,5 +647,8 @@ export function useStatusLine(): { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - return { lines: output }; + return { + lines: output, + useThemeColors: statusLinePreset?.useThemeColors === true, + }; } diff --git a/packages/cli/src/ui/statusLinePresets.test.ts b/packages/cli/src/ui/statusLinePresets.test.ts new file mode 100644 index 0000000000..4e6178dc69 --- /dev/null +++ b/packages/cli/src/ui/statusLinePresets.test.ts @@ -0,0 +1,209 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { StreamingState } from './types.js'; +import { + aggregateModelTokens, + buildStatusLinePresetData, + buildStatusLinePresetLines, + DEFAULT_STATUS_LINE_PRESET_CONFIG, + formatTokenCount, + getRunStateLabel, + inferPullRequestNumber, + normalizeStatusLinePresetConfig, + STATUS_LINE_PRESET_ITEM_IDS, +} from './statusLinePresets.js'; + +describe('statusLinePresets', () => { + it('normalizes valid preset configs and drops unknown items', () => { + expect( + normalizeStatusLinePresetConfig({ + type: 'preset', + useThemeColors: false, + items: ['model', 'bogus', 'git-branch', 'model'], + }), + ).toEqual({ + type: 'preset', + useThemeColors: false, + items: ['model', 'git-branch'], + }); + }); + + it('keeps an explicit empty item list', () => { + expect( + normalizeStatusLinePresetConfig({ + type: 'preset', + items: [], + }), + ).toEqual({ + type: 'preset', + useThemeColors: true, + items: [], + }); + }); + + it('falls back to defaults when preset items are missing', () => { + expect( + normalizeStatusLinePresetConfig({ + type: 'preset', + }), + ).toEqual(DEFAULT_STATUS_LINE_PRESET_CONFIG); + }); + + it('renders available preset items and omits unavailable optional fields', () => { + const data = buildStatusLinePresetData({ + sessionId: 'session-123', + version: '1.2.3', + modelDisplayName: 'qwen3-code-plus', + currentDir: '/repo/project', + branch: 'feature/pr-4087-statusline', + contextWindowSize: 1000, + currentUsage: 250, + totalInputTokens: 1200, + totalOutputTokens: 340, + totalLinesAdded: 12, + totalLinesRemoved: 3, + streamingState: StreamingState.Idle, + }); + + expect( + buildStatusLinePresetLines( + { + type: 'preset', + items: [ + 'model', + 'context-remaining', + 'current-dir', + 'pull-request-number', + 'branch-changes', + 'run-state', + ], + }, + data, + ), + ).toEqual([ + 'qwen3-code-plus | Context 75% left | /repo/project | #4087 | +12 -3 | Ready', + ]); + }); + + it('renders every preset item with representative data', () => { + const data = buildStatusLinePresetData({ + sessionId: 'session-123', + version: '1.2.3', + modelDisplayName: 'qwen3-code-plus', + currentDir: '/repo/project', + branch: 'feature/pr-4087-statusline', + contextWindowSize: 1000, + currentUsage: 250, + totalInputTokens: 1200, + totalOutputTokens: 340, + totalLinesAdded: 12, + totalLinesRemoved: 3, + streamingState: StreamingState.Idle, + }); + + expect( + buildStatusLinePresetLines( + { + type: 'preset', + items: [...STATUS_LINE_PRESET_ITEM_IDS], + }, + 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', + ]); + }); + + it('treats model and model-with-reasoning as mutually exclusive', () => { + const data = buildStatusLinePresetData({ + sessionId: 'session-123', + version: '1.2.3', + modelDisplayName: 'qwen3-code-plus', + 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', 'model'], + }, + data, + ), + ).toEqual(['qwen3-code-plus']); + }); + + it('renders an explicit pull request number before branch-name inference', () => { + const data = buildStatusLinePresetData({ + sessionId: 'session-123', + version: '1.2.3', + modelDisplayName: 'qwen3-code-plus', + currentDir: '/repo/project', + branch: 'feature/pr-1', + pullRequestNumber: '4087', + contextWindowSize: 1000, + currentUsage: 250, + totalInputTokens: 1200, + totalOutputTokens: 340, + totalLinesAdded: 0, + totalLinesRemoved: 0, + streamingState: StreamingState.Idle, + }); + + expect( + buildStatusLinePresetLines( + { + type: 'preset', + items: ['pull-request-number'], + }, + data, + ), + ).toEqual(['#4087']); + }); + + it('aggregates model token counts', () => { + expect( + aggregateModelTokens({ + models: { + qwen: { tokens: { prompt: 100, candidates: 20 } }, + coder: { tokens: { prompt: 300, candidates: 40 } }, + }, + }), + ).toEqual({ totalInputTokens: 400, totalOutputTokens: 60 }); + }); + + it('formats token counts compactly', () => { + expect(formatTokenCount(Number.NaN)).toBe('0'); + expect(formatTokenCount(999)).toBe('999'); + expect(formatTokenCount(1200)).toBe('1.2k'); + expect(formatTokenCount(2_400_000)).toBe('2.4m'); + }); + + it('labels run states', () => { + expect(getRunStateLabel(StreamingState.Idle)).toBe('Ready'); + expect(getRunStateLabel(StreamingState.Responding)).toBe('Working'); + expect(getRunStateLabel(StreamingState.WaitingForConfirmation)).toBe( + 'Confirm', + ); + }); + + it('infers pull request numbers from branch names', () => { + expect(inferPullRequestNumber('feature/pr-4087-statusline')).toBe('4087'); + expect(inferPullRequestNumber('dragon/pull-request_99')).toBe('99'); + expect(inferPullRequestNumber('main')).toBeUndefined(); + expect(inferPullRequestNumber(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/ui/statusLinePresets.ts b/packages/cli/src/ui/statusLinePresets.ts new file mode 100644 index 0000000000..4b548dd7f3 --- /dev/null +++ b/packages/cli/src/ui/statusLinePresets.ts @@ -0,0 +1,402 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import nodePath from 'node:path'; +import { StreamingState } from './types.js'; + +export const STATUS_LINE_PRESET_ITEM_IDS = [ + 'model-with-reasoning', + 'context-remaining', + 'current-dir', + 'context-used', + 'git-branch', + 'model', + 'project-name', + 'pull-request-number', + 'branch-changes', + 'run-state', + 'qwen-version', + 'context-window-size', + 'used-tokens', + 'total-input-tokens', + 'total-output-tokens', + 'session-id', +] as const; + +export type StatusLinePresetItemId = + (typeof STATUS_LINE_PRESET_ITEM_IDS)[number]; + +export interface StatusLinePresetItem { + id: StatusLinePresetItemId; + label: string; + description: string; + defaultSelected?: boolean; +} + +export interface StatusLinePresetConfig { + type: 'preset'; + items: StatusLinePresetItemId[]; + useThemeColors?: boolean; +} + +export interface StatusLinePresetData { + sessionId: string; + version: string; + modelDisplayName: string; + currentDir: string; + projectName: string | undefined; + branch: string | undefined; + pullRequestNumber: string | undefined; + contextWindowSize: number; + usedPercentage: number; + remainingPercentage: number; + currentUsage: number; + totalInputTokens: number; + totalOutputTokens: number; + totalLinesAdded: number; + totalLinesRemoved: number; + streamingState: StreamingState; +} + +export function aggregateModelTokens(metrics: { + models: Record; +}): { totalInputTokens: number; totalOutputTokens: number } { + let totalInputTokens = 0; + let totalOutputTokens = 0; + for (const modelMetrics of Object.values(metrics.models)) { + totalInputTokens += modelMetrics.tokens.prompt; + totalOutputTokens += modelMetrics.tokens.candidates; + } + return { totalInputTokens, totalOutputTokens }; +} + +export const STATUS_LINE_PRESET_ITEMS: readonly StatusLinePresetItem[] = [ + { + id: 'model-with-reasoning', + label: 'model-with-reasoning', + description: 'Current model name with reasoning level when available', + defaultSelected: true, + }, + { + id: 'context-remaining', + label: 'context-remaining', + description: 'Percentage of context window remaining', + defaultSelected: true, + }, + { + id: 'current-dir', + label: 'current-dir', + description: 'Current working directory', + defaultSelected: true, + }, + { + id: 'context-used', + label: 'context-used', + description: 'Percentage of context window used', + defaultSelected: true, + }, + { + id: 'git-branch', + label: 'git-branch', + description: 'Current Git branch when available', + defaultSelected: true, + }, + { + id: 'model', + label: 'model', + description: 'Current model name', + }, + { + id: 'project-name', + label: 'project-name', + description: 'Project name when available', + }, + { + id: 'pull-request-number', + label: 'pull-request-number', + description: 'Open pull request number for the current branch', + }, + { + id: 'branch-changes', + label: 'branch-changes', + description: 'Session file changes added and removed', + }, + { + id: 'run-state', + label: 'run-state', + description: 'Compact session run-state text', + }, + { + id: 'qwen-version', + label: 'qwen-version', + description: 'Qwen Code application version', + }, + { + id: 'context-window-size', + label: 'context-window-size', + description: 'Total context window size in tokens', + }, + { + id: 'used-tokens', + 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', + description: 'Current session identifier', + }, +]; + +const STATUS_LINE_PRESET_ITEM_ID_SET = new Set( + STATUS_LINE_PRESET_ITEM_IDS, +); + +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, + ), +}; + +export function normalizeStatusLinePresetConfig( + raw: unknown, +): StatusLinePresetConfig | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return undefined; + } + + const candidate = raw as Record; + if (candidate['type'] !== 'preset') { + return undefined; + } + + 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), + ) + : []; + + return { + type: 'preset', + useThemeColors: + typeof candidate['useThemeColors'] === 'boolean' + ? candidate['useThemeColors'] + : true, + items: hasItemsArray + ? [...new Set(items)] + : [...DEFAULT_STATUS_LINE_PRESET_CONFIG.items], + }; +} + +function formatPercent(value: number): string { + if (!Number.isFinite(value)) { + return '0%'; + } + const rounded = Math.round(value * 10) / 10; + return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded}%`; +} + +export function formatTokenCount(value: number): string { + if (!Number.isFinite(value) || value <= 0) { + return '0'; + } + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}m`; + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}k`; + } + return String(Math.round(value)); +} + +export function getRunStateLabel(state: StreamingState): string { + switch (state) { + case StreamingState.Idle: + return 'Ready'; + case StreamingState.Responding: + return 'Working'; + case StreamingState.WaitingForConfirmation: + return 'Confirm'; + default: + return 'Working'; + } +} + +export function inferPullRequestNumber( + branch: string | undefined, +): string | undefined { + if (!branch) { + return undefined; + } + const match = branch.match( + /(?:^|[/_-])(?:pr|pull|pull-request)[/_-]?#?(\d+)(?:$|[/_-])/i, + ); + return match?.[1]; +} + +export function buildStatusLinePresetData(params: { + sessionId: string; + version: string | undefined; + modelDisplayName: string | undefined; + currentDir: string; + branch: string | undefined; + pullRequestNumber?: string | undefined; + contextWindowSize: number; + currentUsage: number; + totalInputTokens: number; + totalOutputTokens: number; + totalLinesAdded: number; + totalLinesRemoved: number; + streamingState: StreamingState; +}): StatusLinePresetData { + const usedPercentage = + params.contextWindowSize > 0 + ? Math.min( + 100, + Math.max( + 0, + Math.round( + (params.currentUsage / params.contextWindowSize) * 1000, + ) / 10, + ), + ) + : 0; + + return { + sessionId: params.sessionId, + version: params.version || 'unknown', + modelDisplayName: params.modelDisplayName || 'unknown', + currentDir: params.currentDir, + projectName: nodePath.basename(params.currentDir) || undefined, + branch: params.branch, + pullRequestNumber: params.pullRequestNumber, + contextWindowSize: params.contextWindowSize, + usedPercentage, + remainingPercentage: Math.round((100 - usedPercentage) * 10) / 10, + currentUsage: params.currentUsage, + totalInputTokens: params.totalInputTokens, + totalOutputTokens: params.totalOutputTokens, + totalLinesAdded: params.totalLinesAdded, + totalLinesRemoved: params.totalLinesRemoved, + streamingState: params.streamingState, + }; +} + +export function buildStatusLinePresetParts( + config: StatusLinePresetConfig, + data: StatusLinePresetData, +): string[] { + const parts: string[] = []; + const seen = new Set(); + + for (const item of config.items) { + if (seen.has(item)) { + continue; + } + seen.add(item); + + switch (item) { + case 'model-with-reasoning': + case 'model': + parts.push(data.modelDisplayName); + seen.add('model'); + seen.add('model-with-reasoning'); + break; + case 'context-remaining': + if (data.contextWindowSize > 0) { + parts.push(`Context ${formatPercent(data.remainingPercentage)} left`); + } + break; + case 'current-dir': + parts.push(data.currentDir); + break; + case 'context-used': + if (data.contextWindowSize > 0 && data.usedPercentage > 0) { + parts.push(`Context ${formatPercent(data.usedPercentage)} used`); + } + break; + case 'git-branch': + if (data.branch) { + parts.push(data.branch); + } + break; + case 'project-name': + if (data.projectName) { + parts.push(data.projectName); + } + break; + case 'pull-request-number': { + const prNumber = + data.pullRequestNumber ?? inferPullRequestNumber(data.branch); + if (prNumber) { + parts.push(`#${prNumber}`); + } + break; + } + case 'branch-changes': + if (data.totalLinesAdded > 0 || data.totalLinesRemoved > 0) { + parts.push(`+${data.totalLinesAdded} -${data.totalLinesRemoved}`); + } + break; + case 'run-state': + parts.push(getRunStateLabel(data.streamingState)); + break; + case 'qwen-version': + parts.push(`v${data.version}`); + break; + case 'context-window-size': + if (data.contextWindowSize > 0) { + parts.push(`${formatTokenCount(data.contextWindowSize)} window`); + } + break; + case 'used-tokens': + if (data.currentUsage > 0) { + parts.push(`${formatTokenCount(data.currentUsage)} used`); + } + break; + case 'total-input-tokens': + parts.push(`${formatTokenCount(data.totalInputTokens)} in`); + break; + case 'total-output-tokens': + parts.push(`${formatTokenCount(data.totalOutputTokens)} out`); + break; + case 'session-id': + if (data.sessionId) { + parts.push(data.sessionId); + } + break; + default: { + item satisfies never; + break; + } + } + } + + return parts; +} + +export function buildStatusLinePresetLines( + config: StatusLinePresetConfig, + data: StatusLinePresetData, +): string[] { + const line = buildStatusLinePresetParts(config, data).join(' | '); + return line ? [line] : []; +} diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 7a6bb99c31..7558594730 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -182,7 +182,7 @@ "default": "Qwen Dark" }, "statusLine": { - "description": "Custom status line display configuration. Optional `refreshInterval` (seconds, >= 1) re-runs the command on a timer so external data stays fresh.", + "description": "Status line display configuration. Use `type: \"preset\"` with built-in item ids, or `type: \"command\"` with a shell command. Optional command `refreshInterval` (seconds, >= 1) re-runs the command on a timer so external data stays fresh.", "type": "object", "additionalProperties": true },