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