mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 12:11:09 +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
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