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

@ -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 };
}