Merge pull request #2923 from QwenLM/feature/status-line-customization
Some checks are pending
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

feat(ui): add customizable status line with /statusline command
This commit is contained in:
Edenman 2026-04-09 19:23:08 +08:00 committed by GitHub
commit 4d2d4432d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1565 additions and 64 deletions

View file

@ -86,7 +86,7 @@ describe('writeServiceInfo + readServiceInfo', () => {
writeServiceInfo(['telegram']);
// Now simulate dead process
process.kill = vi.fn(() => {
throw new Error('ESRCH');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -122,7 +122,6 @@ describe('signalService', () => {
});
it('returns false when process is not found', () => {
process.kill = vi.fn(() => {
throw new Error('ESRCH');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -140,7 +139,6 @@ describe('signalService', () => {
describe('waitForExit', () => {
it('returns true immediately if process is already dead', async () => {
process.kill = vi.fn(() => {
throw new Error('ESRCH');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -152,7 +150,7 @@ describe('waitForExit', () => {
it('returns true when process dies within timeout', async () => {
let alive = true;
process.kill = vi.fn(() => {
if (!alive) throw new Error('ESRCH');
return true;

View file

@ -429,6 +429,15 @@ const SETTINGS_SCHEMA = {
description: 'The color theme for the UI.',
showInDialog: true,
},
statusLine: {
type: 'object',
label: 'Status Line',
category: 'UI',
requiresRestart: false,
default: undefined as { type: 'command'; command: string } | undefined,
description: 'Custom status line display configuration.',
showInDialog: false,
},
customThemes: {
type: 'object',
label: 'Custom Themes',

View file

@ -1986,4 +1986,6 @@ export default {
'Already in plan mode. Use "/plan exit" to exit plan mode.',
'Not in plan mode. Use "/plan" to enter plan mode first.':
'Not in plan mode. Use "/plan" to enter plan mode first.',
"Set up Qwen Code's status line UI": "Set up Qwen Code's status line UI",
};

View file

@ -2026,4 +2026,6 @@ export default {
'Already in plan mode. Use "/plan exit" to exit plan mode.',
'Not in plan mode. Use "/plan" to enter plan mode first.':
'Not in plan mode. Use "/plan" to enter plan mode first.',
"Set up Qwen Code's status line UI": "Set up Qwen Code's status line UI",
};

View file

@ -1477,4 +1477,6 @@ export default {
'Already in plan mode. Use "/plan exit" to exit plan mode.',
'Not in plan mode. Use "/plan" to enter plan mode first.':
'Not in plan mode. Use "/plan" to enter plan mode first.',
"Set up Qwen Code's status line UI": "Set up Qwen Code's status line UI",
};

View file

@ -1976,4 +1976,6 @@ export default {
'Already in plan mode. Use "/plan exit" to exit plan mode.',
'Not in plan mode. Use "/plan" to enter plan mode first.':
'Not in plan mode. Use "/plan" to enter plan mode first.',
"Set up Qwen Code's status line UI": "Set up Qwen Code's status line UI",
};

View file

@ -1983,4 +1983,6 @@ export default {
'Already in plan mode. Use "/plan exit" to exit plan mode.',
'Not in plan mode. Use "/plan" to enter plan mode first.':
'Not in plan mode. Use "/plan" to enter plan mode first.',
"Set up Qwen Code's status line UI": "Set up Qwen Code's status line UI",
};

View file

@ -1828,4 +1828,6 @@ export default {
'已处于计划模式。使用 "/plan exit" 退出计划模式。',
'Not in plan mode. Use "/plan" to enter plan mode first.':
'未处于计划模式。请先使用 "/plan" 进入计划模式。',
"Set up Qwen Code's status line UI": '配置 Qwen Code 的状态栏',
};

View file

@ -48,6 +48,7 @@ import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { insightCommand } from '../ui/commands/insightCommand.js';
import { statuslineCommand } from '../ui/commands/statuslineCommand.js';
const builtinDebugLogger = createDebugLogger('BUILTIN_COMMAND_LOADER');
@ -120,6 +121,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
setupGithubCommand,
terminalSetupCommand,
insightCommand,
statuslineCommand,
];
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);

View file

@ -243,6 +243,7 @@ describe('AppContainer State Management', () => {
addMessage: vi.fn(),
clearQueue: vi.fn(),
getQueuedMessagesText: vi.fn().mockReturnValue(''),
drainQueue: vi.fn().mockReturnValue([]),
});
mockedUseAutoAcceptIndicator.mockReturnValue(false);
mockedUseGitBranchName.mockReturnValue('main');
@ -455,6 +456,7 @@ describe('AppContainer State Management', () => {
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
getQueuedMessagesText: vi.fn().mockReturnValue(''),
drainQueue: vi.fn().mockReturnValue([]),
});
render(

View file

@ -287,7 +287,6 @@ export const AppContainer = (props: AppContainerProps) => {
const { stats: sessionStats, startNewSession } = useSessionStats();
const logger = useLogger(config.storage, sessionStats.sessionId);
const branchName = useGitBranchName(config.getTargetDir());
// Layout measurements
const mainControlsRef = useRef<DOMElement>(null);
const originalTitleRef = useRef(
@ -777,24 +776,22 @@ export const AppContainer = (props: AppContainerProps) => {
disabled: agentViewState.activeView !== 'main',
});
const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } =
useMessageQueue({
isConfigInitialized,
streamingState,
submitQuery,
});
const {
messageQueue,
addMessage,
clearQueue,
getQueuedMessagesText,
drainQueue,
} = useMessageQueue({
isConfigInitialized,
streamingState,
submitQuery,
});
// Bridge message queue to mid-turn drain via ref.
// Sync ref on every render so the drain callback always reads latest state.
const messageQueueRef = useRef(messageQueue);
messageQueueRef.current = messageQueue;
midTurnDrainRef.current = () => {
const queue = messageQueueRef.current;
if (queue.length === 0) return [];
messageQueueRef.current = [];
clearQueue();
return [...queue];
};
// drainQueue reads from the synchronous queueRef inside useMessageQueue,
// so it always sees the latest state even between renders.
midTurnDrainRef.current = drainQueue;
// Callback for handling final submit (must be after addMessage from useMessageQueue)
const handleFinalSubmit = useCallback(

View file

@ -0,0 +1,78 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { statuslineCommand } from './statuslineCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('statuslineCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should have the correct name and description', () => {
expect(statuslineCommand.name).toBe('statusline');
expect(statuslineCommand.description).toBeDefined();
});
it('should return submit_prompt with default prompt when no args', () => {
if (!statuslineCommand.action) {
throw new Error('statusline command must have an action');
}
const result = statuslineCommand.action(mockContext, '');
expect(result).toEqual({
type: 'submit_prompt',
content: [
{
text: expect.stringContaining('statusline-setup'),
},
],
});
// Default prompt should mention PS1
expect(result).toHaveProperty(
'content.0.text',
expect.stringContaining('PS1'),
);
});
it('should use user-provided args as the prompt', () => {
if (!statuslineCommand.action) {
throw new Error('statusline command must have an action');
}
const result = statuslineCommand.action(
mockContext,
'show model name and git branch',
);
expect(result).toEqual({
type: 'submit_prompt',
content: [
{
text: expect.stringContaining('show model name and git branch'),
},
],
});
});
it('should trim whitespace-only args and use default prompt', () => {
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'),
);
});
});

View file

@ -0,0 +1,29 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand, SubmitPromptActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const statuslineCommand: SlashCommand = {
name: 'statusline',
get description() {
return t("Set up Qwen Code's status line UI");
},
kind: CommandKind.BUILT_IN,
action: (_context, args): SubmitPromptActionReturn => {
const prompt =
args.trim() || 'Configure my statusLine from my shell PS1 configuration';
return {
type: 'submit_prompt',
content: [
{
text: `Use the Agent tool with subagent_type: "statusline-setup" and this prompt:\n\n${prompt}`,
},
],
};
},
};

View file

@ -5,7 +5,7 @@
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
@ -48,11 +48,9 @@ export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
}
return (
<Box>
<Text color={textColor}>
{textContent}
{subText && <Text color={theme.text.secondary}>{subText}</Text>}
</Text>
</Box>
<Text color={textColor}>
{textContent}
{subText && <Text color={theme.text.secondary}>{subText}</Text>}
</Text>
);
};

View file

@ -11,6 +11,7 @@ import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../config/settings.js';
vi.mock('../hooks/useTerminalSize.js');
@ -33,7 +34,22 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
({
sessionStats: {
lastPromptTokenCount: 100,
sessionId: 'test-session',
metrics: {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 },
byName: {},
},
files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
},
},
currentModel: 'gemini-pro',
branchName: undefined,
geminiMdFileCount: 0,
contextFileNames: [],
showToolDescriptions: false,
@ -52,14 +68,17 @@ const createMockSettings = (): LoadedSettings =>
const renderWithWidth = (width: number, uiState: UIState) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
const mockSettings = createMockSettings();
return render(
<ConfigContext.Provider value={createMockConfig() as never}>
<VimModeProvider settings={createMockSettings()}>
<UIStateContext.Provider value={uiState}>
<Footer />
</UIStateContext.Provider>
</VimModeProvider>
</ConfigContext.Provider>,
<SettingsContext.Provider value={mockSettings}>
<ConfigContext.Provider value={createMockConfig() as never}>
<VimModeProvider settings={mockSettings}>
<UIStateContext.Provider value={uiState}>
<Footer />
</UIStateContext.Provider>
</VimModeProvider>
</ConfigContext.Provider>
</SettingsContext.Provider>,
);
};

View file

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
@ -13,6 +13,7 @@ import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useStatusLine } from '../hooks/useStatusLine.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
@ -24,6 +25,7 @@ export const Footer: React.FC = () => {
const uiState = useUIState();
const config = useConfig();
const { vimEnabled, vimMode } = useVimMode();
const { text: statusLineText } = useStatusLine();
const { verboseMode } = useVerboseMode();
const { promptTokenCount, showAutoAcceptIndicator } = {
@ -50,8 +52,12 @@ export const Footer: React.FC = () => {
const contextWindowSize =
config.getContentGeneratorConfig()?.contextWindowSize;
// Left section should show exactly ONE thing at any time, in priority order.
const leftContent = uiState.ctrlCPressedOnce ? (
// Hide "? for shortcuts" when a custom status line is active (it already
// occupies the top row, so the hint is redundant). Matches upstream behavior.
const suppressHint = !!statusLineText;
// Left bottom row: high-priority messages > approval mode > hint.
const leftBottomContent = uiState.ctrlCPressedOnce ? (
<Text color={theme.status.warning}>{t('Press Ctrl+C again to exit.')}</Text>
) : uiState.ctrlDPressedOnce ? (
<Text color={theme.status.warning}>{t('Press Ctrl+D again to exit.')}</Text>
@ -64,7 +70,7 @@ export const Footer: React.FC = () => {
) : showAutoAcceptIndicator !== undefined &&
showAutoAcceptIndicator !== ApprovalMode.DEFAULT ? (
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
) : (
) : suppressHint ? null : (
<Text color={theme.text.secondary}>{t('? for shortcuts')}</Text>
);
@ -101,25 +107,31 @@ export const Footer: React.FC = () => {
node: <Text color={theme.text.accent}>{t('verbose')}</Text>,
});
}
// Layout matches upstream: left column has status line (top) + hints/mode
// (bottom), right section has indicators. Status line and hints coexist.
return (
<Box
justifyContent="space-between"
flexDirection={isNarrow ? 'column' : 'row'}
justifyContent={isNarrow ? 'flex-start' : 'space-between'}
width="100%"
flexDirection="row"
alignItems="center"
paddingX={2}
gap={isNarrow ? 0 : 1}
>
{/* Left Section: Exactly one status line (exit prompts / mode indicator / default hint) */}
<Box
marginLeft={2}
justifyContent="flex-start"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{leftContent}
{/* Left column — status line on top, hints/mode on bottom */}
<Box flexDirection="column" flexShrink={isNarrow ? 0 : 1}>
{statusLineText &&
!uiState.ctrlCPressedOnce &&
!uiState.ctrlDPressedOnce && (
<Text dimColor wrap="truncate">
{statusLineText}
</Text>
)}
<Text wrap="truncate">{leftBottomContent}</Text>
</Box>
{/* Right Section: Sandbox Info, Debug Mode, Context Usage, and Console Summary */}
<Box alignItems="center" justifyContent="flex-end" marginRight={2}>
{/* Right Section — never compressed */}
<Box flexShrink={0} gap={1}>
{rightItems.map(({ key, node }, index) => (
<Box key={key} alignItems="center">
{index > 0 && <Text color={theme.text.secondary}> | </Text>}

View file

@ -5,14 +5,12 @@
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
export const ShellModeIndicator: React.FC = () => (
<Box>
<Text color={theme.ui.symbol}>
shell mode enabled
<Text color={theme.text.secondary}> (esc to disable)</Text>
</Text>
</Box>
<Text color={theme.ui.symbol}>
shell mode enabled
<Text color={theme.text.secondary}> (esc to disable)</Text>
</Text>
);

View file

@ -1,5 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on narrow terminal > complete-footer-narrow 1`] = `" ? for shortcuts 0.1% used | verbose"`;
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on narrow terminal > complete-footer-narrow 1`] = `
" ? for shortcuts
0.1% used | verbose"
`;
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on wide terminal > complete-footer-wide 1`] = `" ? for shortcuts 0.1% context used | verbose"`;
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on wide terminal > complete-footer-wide 1`] = `" ? for shortcuts 0.1% context used | verbose"`;

View file

@ -0,0 +1,584 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import * as child_process from 'child_process';
// --- Mock child_process (auto-mock, then override exec in beforeEach) ---
vi.mock('child_process');
// --- Mock context hooks ---
const mockSettings = {
merged: {} as Record<string, unknown>,
};
vi.mock('../contexts/SettingsContext.js', () => ({
useSettings: () => mockSettings,
}));
const mockUIState = {
sessionStats: {
sessionId: 'test-session',
lastPromptTokenCount: 100,
metrics: {
models: {},
tools: { totalCalls: 0 },
files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
},
},
currentModel: 'test-model',
branchName: 'main' as string | undefined,
};
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: () => mockUIState,
}));
const mockConfig = {
getTargetDir: vi.fn(() => '/test/dir'),
getModel: vi.fn(() => 'test-model'),
getCliVersion: vi.fn(() => '1.0.0'),
getContentGeneratorConfig: vi.fn(() => ({ contextWindowSize: 131072 })),
};
vi.mock('../contexts/ConfigContext.js', () => ({
useConfig: () => mockConfig,
}));
const mockVimMode = {
vimEnabled: false,
vimMode: 'INSERT' as string,
};
vi.mock('../contexts/VimModeContext.js', () => ({
useVimMode: () => mockVimMode,
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...original,
createDebugLogger: () => ({
log: vi.fn(),
error: vi.fn(),
}),
};
});
// --- exec mock state ---
type ExecCallback = (
error: Error | null,
stdout: string,
stderr: string,
) => void;
let execCallback: ExecCallback;
let lastExecCommand: string | undefined;
let stdinWrittenData: string;
let stdinErrorHandler: ((err: Error) => void) | undefined;
let mockKill: ReturnType<typeof vi.fn>;
function setStatusLineConfig(
config: { type: string; command: string } | undefined,
) {
mockSettings.merged = config ? { ui: { statusLine: config } } : {};
}
describe('useStatusLine', () => {
// Must import dynamically after mocks are set up
let useStatusLine: typeof import('./useStatusLine.js').useStatusLine;
beforeEach(async () => {
vi.useFakeTimers();
vi.clearAllMocks();
lastExecCommand = undefined;
stdinWrittenData = '';
stdinErrorHandler = undefined;
mockKill = vi.fn();
// Set up exec mock implementation
vi.mocked(child_process.exec).mockImplementation(((
cmd: string,
_opts: unknown,
cb: ExecCallback,
) => {
lastExecCommand = cmd;
execCallback = cb;
stdinWrittenData = '';
stdinErrorHandler = undefined;
return {
stdin: {
on: vi.fn((_event: string, handler: (err: Error) => void) => {
stdinErrorHandler = handler;
}),
write: vi.fn((data: string) => {
stdinWrittenData += data;
return true;
}),
end: vi.fn(),
},
kill: mockKill,
killed: false,
};
}) as unknown as typeof child_process.exec);
// Reset mutable mock state
setStatusLineConfig(undefined);
mockUIState.sessionStats.lastPromptTokenCount = 100;
mockUIState.currentModel = 'test-model';
mockUIState.branchName = 'main';
mockUIState.sessionStats.metrics.tools.totalCalls = 0;
mockUIState.sessionStats.metrics.files.totalLinesAdded = 0;
mockUIState.sessionStats.metrics.files.totalLinesRemoved = 0;
mockVimMode.vimEnabled = false;
mockVimMode.vimMode = 'INSERT';
// Dynamic import to get fresh module after mocks
const mod = await import('./useStatusLine.js');
useStatusLine = mod.useStatusLine;
});
afterEach(() => {
vi.useRealTimers();
});
// --- getStatusLineConfig validation (tested through the hook) ---
describe('config validation', () => {
it('returns null when no statusLine config is set', () => {
const { result } = renderHook(() => useStatusLine());
expect(result.current.text).toBeNull();
expect(child_process.exec).not.toHaveBeenCalled();
});
it('returns null when statusLine type is not "command"', () => {
setStatusLineConfig({ type: 'invalid', command: 'echo hi' });
const { result } = renderHook(() => useStatusLine());
expect(result.current.text).toBeNull();
expect(child_process.exec).not.toHaveBeenCalled();
});
it('returns null when command is empty string', () => {
setStatusLineConfig({ type: 'command', command: '' });
const { result } = renderHook(() => useStatusLine());
expect(result.current.text).toBeNull();
expect(child_process.exec).not.toHaveBeenCalled();
});
it('returns null when command is whitespace only', () => {
setStatusLineConfig({ type: 'command', command: ' ' });
const { result } = renderHook(() => useStatusLine());
expect(result.current.text).toBeNull();
expect(child_process.exec).not.toHaveBeenCalled();
});
});
// --- Command execution ---
describe('command execution', () => {
it('executes configured command on mount', () => {
setStatusLineConfig({ type: 'command', command: 'echo hello' });
renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledOnce();
expect(lastExecCommand).toBe('echo hello');
});
it('passes correct options to exec', () => {
setStatusLineConfig({ type: 'command', command: 'echo hello' });
renderHook(() => useStatusLine());
const callArgs = vi.mocked(child_process.exec).mock.calls[0];
const opts = callArgs[1] as {
cwd: string;
timeout: number;
maxBuffer: number;
};
expect(opts.cwd).toBe('/test/dir');
expect(opts.timeout).toBe(5000);
expect(opts.maxBuffer).toBe(1024 * 10);
});
it('returns first line of stdout as text', async () => {
setStatusLineConfig({ type: 'command', command: 'echo hello' });
const { result } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, 'hello world\n', '');
});
expect(result.current.text).toBe('hello world');
});
it('returns only the first line when stdout has multiple lines', async () => {
setStatusLineConfig({ type: 'command', command: 'echo lines' });
const { result } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, 'first line\nsecond line\n', '');
});
expect(result.current.text).toBe('first line');
});
it('returns null when command fails', async () => {
setStatusLineConfig({ type: 'command', command: 'bad-cmd' });
const { result } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(new Error('command not found'), '', '');
});
expect(result.current.text).toBeNull();
});
it('returns null when stdout is empty', async () => {
setStatusLineConfig({ type: 'command', command: 'echo -n' });
const { result } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, '', '');
});
expect(result.current.text).toBeNull();
});
});
// --- stdin JSON input ---
describe('stdin JSON input', () => {
it('writes JSON to stdin with session context', () => {
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.session_id).toBe('test-session');
expect(input.version).toBe('1.0.0');
expect(input.model.display_name).toBe('test-model');
expect(input.workspace.current_dir).toBe('/test/dir');
});
it('includes git branch when available', () => {
mockUIState.branchName = 'feature/test';
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.git.branch).toBe('feature/test');
});
it('omits git when branchName is falsy', () => {
mockUIState.branchName = undefined;
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.git).toBeUndefined();
});
it('includes vim mode when enabled', () => {
mockVimMode.vimEnabled = true;
mockVimMode.vimMode = 'NORMAL';
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.vim.mode).toBe('NORMAL');
});
it('omits vim when not enabled', () => {
mockVimMode.vimEnabled = false;
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.vim).toBeUndefined();
});
it('includes context window usage data', () => {
mockUIState.sessionStats.lastPromptTokenCount = 65536;
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.context_window.context_window_size).toBe(131072);
expect(input.context_window.used_percentage).toBe(50);
expect(input.context_window.remaining_percentage).toBe(50);
expect(input.context_window.current_usage).toBe(65536);
});
});
// --- Stale generation handling ---
describe('stale generation', () => {
it('ignores callback from stale generation and accepts fresh one', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { result, rerender } = renderHook(() => useStatusLine());
// Capture first callback
const firstCallback = execCallback;
// Trigger a state change to cause re-execution
mockUIState.currentModel = 'new-model';
rerender();
// Advance debounce timer — triggers second exec
await act(async () => {
vi.advanceTimersByTime(300);
});
// Capture second (current) callback
const secondCallback = execCallback;
// Resolve the stale first callback — should be ignored
await act(async () => {
firstCallback(null, 'stale output\n', '');
});
expect(result.current.text).toBeNull();
// Resolve the fresh second callback — should be accepted
await act(async () => {
secondCallback(null, 'fresh output\n', '');
});
expect(result.current.text).toBe('fresh output');
});
});
// --- Debouncing ---
describe('debouncing', () => {
it('debounces rapid state changes to a single exec', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender } = renderHook(() => useStatusLine());
// Initial mount exec
expect(child_process.exec).toHaveBeenCalledTimes(1);
// Rapid state changes
mockUIState.currentModel = 'model-1';
rerender();
mockUIState.currentModel = 'model-2';
rerender();
mockUIState.currentModel = 'model-3';
rerender();
// Before debounce expires, no additional execs
expect(child_process.exec).toHaveBeenCalledTimes(1);
// After debounce
await act(async () => {
vi.advanceTimersByTime(300);
});
// Should have exactly one debounced exec (total 2: mount + debounced)
expect(child_process.exec).toHaveBeenCalledTimes(2);
});
});
// --- Config removal clears output ---
describe('config removal', () => {
it('clears output when config is removed', async () => {
setStatusLineConfig({ type: 'command', command: 'echo hello' });
const { result, rerender } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, 'hello\n', '');
});
expect(result.current.text).toBe('hello');
// Remove config
setStatusLineConfig(undefined);
rerender();
expect(result.current.text).toBeNull();
});
});
// --- Cleanup on unmount ---
describe('cleanup', () => {
it('kills active child process on unmount', () => {
setStatusLineConfig({ type: 'command', command: 'sleep 10' });
const { unmount } = renderHook(() => useStatusLine());
expect(mockKill).not.toHaveBeenCalled();
unmount();
expect(mockKill).toHaveBeenCalled();
});
it('clears debounce timer on unmount', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender, unmount } = renderHook(() => useStatusLine());
// Trigger a debounced update
mockUIState.currentModel = 'new-model';
rerender();
// Unmount before debounce fires
unmount();
// Advance past debounce — should not cause additional exec
const callsBefore = vi.mocked(child_process.exec).mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(vi.mocked(child_process.exec).mock.calls.length).toBe(callsBefore);
});
});
// --- stdin error handling ---
describe('stdin error handling', () => {
it('silently handles EPIPE errors', () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
renderHook(() => useStatusLine());
expect(stdinErrorHandler).toBeDefined();
const epipeError = new Error('EPIPE') as NodeJS.ErrnoException;
epipeError.code = 'EPIPE';
// Should not throw
expect(() => stdinErrorHandler!(epipeError)).not.toThrow();
});
it('logs non-EPIPE stdin errors', () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
renderHook(() => useStatusLine());
expect(stdinErrorHandler).toBeDefined();
const otherError = new Error('EIO') as NodeJS.ErrnoException;
otherError.code = 'EIO';
// Should not throw but should log (we can't easily check debugLog here,
// but we verify it doesn't crash)
expect(() => stdinErrorHandler!(otherError)).not.toThrow();
});
});
// --- Command change triggers immediate re-execution ---
describe('command change', () => {
it('re-executes immediately when command changes', async () => {
setStatusLineConfig({ type: 'command', command: 'echo first' });
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
// Resolve first exec
await act(async () => {
execCallback(null, 'first\n', '');
});
// Change command
setStatusLineConfig({ type: 'command', command: 'echo second' });
rerender();
// Should re-execute immediately (not debounced)
expect(child_process.exec).toHaveBeenCalledTimes(2);
expect(lastExecCommand).toBe('echo second');
});
});
// --- State change triggers ---
describe('state change triggers', () => {
it('triggers update when prompt token count changes', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
mockUIState.sessionStats.lastPromptTokenCount = 200;
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
});
it('triggers update when branch changes', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
mockUIState.branchName = 'feature/new';
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
});
it('triggers update when tool calls change', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
mockUIState.sessionStats.metrics.tools.totalCalls = 5;
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
});
it('triggers update when vim mode is toggled off', async () => {
mockVimMode.vimEnabled = true;
mockVimMode.vimMode = 'NORMAL';
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
// Disable vim — effectiveVim changes from 'NORMAL' to undefined
mockVimMode.vimEnabled = false;
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
});
it('triggers update when file lines change', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
mockUIState.sessionStats.metrics.files.totalLinesAdded = 50;
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
});
});
// --- Process killed on new update ---
describe('process management', () => {
it('kills previous process when starting new execution', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender } = renderHook(() => useStatusLine());
const firstKill = mockKill;
// Trigger re-execution via state change
mockUIState.currentModel = 'new-model';
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(firstKill).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,396 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { exec, type ChildProcess } from 'child_process';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import { useSettings } from '../contexts/SettingsContext.js';
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';
/**
* Structured JSON input passed to the status line command via stdin.
* This allows status line commands to display context-aware information
* (model, token usage, session, etc.) without running extra queries.
*/
export interface StatusLineCommandInput {
session_id: string;
version: string;
model: {
display_name: string;
};
context_window: {
context_window_size: number;
used_percentage: number;
remaining_percentage: number;
current_usage: number;
total_input_tokens: number;
total_output_tokens: number;
};
workspace: {
current_dir: string;
};
git?: {
branch: string;
};
metrics: {
models: Record<
string,
{
api: {
total_requests: number;
total_errors: number;
total_latency_ms: number;
};
tokens: {
prompt: number;
completion: number;
total: number;
cached: number;
thoughts: number;
};
}
>;
files: {
total_lines_added: number;
total_lines_removed: number;
};
};
vim?: {
mode: string;
};
}
interface StatusLineConfig {
type: 'command';
command: string;
}
const debugLog = createDebugLogger('STATUS_LINE');
function getStatusLineConfig(
settings: ReturnType<typeof useSettings>,
): StatusLineConfig | undefined {
const raw = settings.merged.ui?.statusLine;
if (
raw &&
typeof raw === 'object' &&
'type' in raw &&
raw.type === 'command' &&
'command' in raw &&
typeof raw.command === 'string' &&
raw.command.trim().length > 0
) {
const config: StatusLineConfig = {
type: 'command',
command: raw.command,
};
return config;
}
return undefined;
}
function buildMetricsPayload(
m: SessionMetrics,
): StatusLineCommandInput['metrics'] {
const models: StatusLineCommandInput['metrics']['models'] = {};
for (const [id, mm] of Object.entries(m.models)) {
models[id] = {
api: {
total_requests: mm.api.totalRequests,
total_errors: mm.api.totalErrors,
total_latency_ms: mm.api.totalLatencyMs,
},
tokens: {
prompt: mm.tokens.prompt,
completion: mm.tokens.candidates,
total: mm.tokens.total,
cached: mm.tokens.cached,
thoughts: mm.tokens.thoughts,
},
};
}
return {
models,
files: {
total_lines_added: m.files.totalLinesAdded,
total_lines_removed: m.files.totalLinesRemoved,
},
};
}
/**
* Hook that executes a user-configured shell command and returns its output
* for display in the status line. The command receives structured JSON context
* via stdin.
*
* Updates are debounced (300ms) and triggered by state changes (model switch,
* new messages, vim mode toggle) rather than blind polling.
*/
export function useStatusLine(): {
text: string | null;
} {
const settings = useSettings();
const uiState = useUIState();
const config = useConfig();
const { vimEnabled, vimMode } = useVimMode();
const statusLineConfig = getStatusLineConfig(settings);
const statusLineCommand = statusLineConfig?.command;
const [output, setOutput] = useState<string | null>(null);
// Keep latest values in refs so the stable doUpdate callback can read them
// without being recreated on every render.
const uiStateRef = useRef(uiState);
uiStateRef.current = uiState;
const configRef = useRef(config);
configRef.current = config;
const vimEnabledRef = useRef(vimEnabled);
vimEnabledRef.current = vimEnabled;
const vimModeRef = useRef(vimMode);
vimModeRef.current = vimMode;
const statusLineCommandRef = useRef(statusLineCommand);
statusLineCommandRef.current = statusLineCommand;
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
// Track previous trigger values to detect actual changes.
// Initialized with current values so the state-change effect
// does not fire redundantly on mount.
const { lastPromptTokenCount } = uiState.sessionStats;
const { currentModel, branchName } = uiState;
const totalToolCalls = uiState.sessionStats.metrics.tools.totalCalls;
const totalLinesAdded = uiState.sessionStats.metrics.files.totalLinesAdded;
const totalLinesRemoved =
uiState.sessionStats.metrics.files.totalLinesRemoved;
const effectiveVim = vimEnabled ? vimMode : undefined;
const prevStateRef = useRef<{
promptTokenCount: number;
currentModel: string;
effectiveVim: string | undefined;
branchName: string | undefined;
totalToolCalls: number;
totalLinesAdded: number;
totalLinesRemoved: number;
}>({
promptTokenCount: lastPromptTokenCount,
currentModel,
effectiveVim,
branchName,
totalToolCalls,
totalLinesAdded,
totalLinesRemoved,
});
// Guard: when true, the mount effect has already called doUpdate so the
// command-change effect should skip its first run to avoid a double exec.
const hasMountedRef = useRef(false);
// Track the active child process so we can kill it on new updates / unmount.
const activeChildRef = useRef<ChildProcess | undefined>(undefined);
const generationRef = useRef(0);
const doUpdate = useCallback(() => {
const cmd = statusLineCommandRef.current;
if (!cmd) {
setOutput(null);
return;
}
const ui = uiStateRef.current;
const cfg = configRef.current;
const stats = ui.sessionStats;
const m = stats.metrics;
const contextWindowSize =
cfg.getContentGeneratorConfig()?.contextWindowSize || 0;
const usedPercentage =
contextWindowSize > 0
? Math.min(
100,
Math.max(
0,
Math.round(
(stats.lastPromptTokenCount / contextWindowSize) * 1000,
) / 10,
),
)
: 0;
let totalInputTokens = 0;
let totalOutputTokens = 0;
for (const mm of Object.values(m.models)) {
totalInputTokens += mm.tokens.prompt;
totalOutputTokens += mm.tokens.candidates;
}
const input: StatusLineCommandInput = {
session_id: stats.sessionId,
version: cfg.getCliVersion() || 'unknown',
model: {
display_name: ui.currentModel || cfg.getModel() || 'unknown',
},
context_window: {
context_window_size: contextWindowSize,
used_percentage: usedPercentage,
remaining_percentage: Math.round((100 - usedPercentage) * 10) / 10,
current_usage: stats.lastPromptTokenCount,
total_input_tokens: totalInputTokens,
total_output_tokens: totalOutputTokens,
},
workspace: {
current_dir: cfg.getTargetDir(),
},
...(ui.branchName && {
git: {
branch: ui.branchName,
},
}),
metrics: buildMetricsPayload(m),
...(vimEnabledRef.current && {
vim: { mode: vimModeRef.current },
}),
};
// Kill the previous child process if still running.
if (activeChildRef.current) {
activeChildRef.current.kill();
activeChildRef.current = undefined;
}
// Bump generation so earlier in-flight callbacks are ignored.
const gen = ++generationRef.current;
const child = exec(
cmd,
{ cwd: cfg.getTargetDir(), timeout: 5000, maxBuffer: 1024 * 10 },
(error, stdout) => {
if (gen !== generationRef.current) return; // stale
activeChildRef.current = undefined;
if (!error && stdout) {
// Strip only the trailing newline to preserve intentional whitespace.
const line = stdout.replace(/\r?\n$/, '').split(/\r?\n/, 1)[0];
setOutput(line || null);
} else {
setOutput(null);
}
},
);
activeChildRef.current = child;
// Pass structured JSON context via stdin.
// Guard against EPIPE if the child exits before we finish writing.
if (child.stdin) {
child.stdin.on('error', (err) => {
if ((err as NodeJS.ErrnoException).code !== 'EPIPE') {
debugLog.error('statusline stdin error:', err.message);
}
});
child.stdin.write(JSON.stringify(input));
child.stdin.end();
}
}, []); // No deps — reads everything from refs
const scheduleUpdate = useCallback(() => {
if (debounceTimerRef.current !== undefined) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = undefined;
doUpdate();
}, 300);
}, [doUpdate]);
// Trigger update when meaningful state changes
useEffect(() => {
if (!statusLineCommand) {
// Command removed — kill any in-flight process and discard callbacks.
activeChildRef.current?.kill();
activeChildRef.current = undefined;
generationRef.current++;
if (debounceTimerRef.current !== undefined) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = undefined;
}
setOutput(null);
return;
}
const prev = prevStateRef.current;
if (
lastPromptTokenCount !== prev.promptTokenCount ||
currentModel !== prev.currentModel ||
effectiveVim !== prev.effectiveVim ||
branchName !== prev.branchName ||
totalToolCalls !== prev.totalToolCalls ||
totalLinesAdded !== prev.totalLinesAdded ||
totalLinesRemoved !== prev.totalLinesRemoved
) {
prev.promptTokenCount = lastPromptTokenCount;
prev.currentModel = currentModel;
prev.effectiveVim = effectiveVim;
prev.branchName = branchName;
prev.totalToolCalls = totalToolCalls;
prev.totalLinesAdded = totalLinesAdded;
prev.totalLinesRemoved = totalLinesRemoved;
scheduleUpdate();
}
}, [
statusLineCommand,
lastPromptTokenCount,
currentModel,
effectiveVim,
branchName,
totalToolCalls,
totalLinesAdded,
totalLinesRemoved,
scheduleUpdate,
]);
// 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) {
// Clear any pending debounce so we don't get a redundant second run.
if (debounceTimerRef.current !== undefined) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = undefined;
}
doUpdate();
}
// Cleanup when command is removed is handled by the state-change effect.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusLineCommand]);
// Initial execution + cleanup
useEffect(() => {
hasMountedRef.current = true;
const genRef = generationRef;
const debounceRef = debounceTimerRef;
const childRef = activeChildRef;
doUpdate();
return () => {
// Kill active child process and invalidate callbacks
childRef.current?.kill();
childRef.current = undefined;
genRef.current++;
if (debounceRef.current !== undefined) {
clearTimeout(debounceRef.current);
debounceRef.current = undefined;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { text: output };
}