mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
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
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:
commit
4d2d4432d5
26 changed files with 1565 additions and 64 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 的状态栏',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
78
packages/cli/src/ui/commands/statuslineCommand.test.ts
Normal file
78
packages/cli/src/ui/commands/statuslineCommand.test.ts
Normal 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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
29
packages/cli/src/ui/commands/statuslineCommand.ts
Normal file
29
packages/cli/src/ui/commands/statuslineCommand.ts
Normal 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}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"`;
|
||||
|
|
|
|||
584
packages/cli/src/ui/hooks/useStatusLine.test.ts
Normal file
584
packages/cli/src/ui/hooks/useStatusLine.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
396
packages/cli/src/ui/hooks/useStatusLine.ts
Normal file
396
packages/cli/src/ui/hooks/useStatusLine.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue