diff --git a/docs/users/features/status-line.md b/docs/users/features/status-line.md index c8ccb0b58..780b387e9 100644 --- a/docs/users/features/status-line.md +++ b/docs/users/features/status-line.md @@ -60,10 +60,11 @@ Add a `statusLine` object under the `ui` key in `~/.qwen/settings.json`: } ``` -| Field | Type | Required | Description | -| --------- | ----------- | -------- | --------------------------------------------------------------------------------------- | -| `type` | `"command"` | Yes | Must be `"command"` | -| `command` | string | Yes | Shell command to execute. Receives JSON via stdin, stdout is displayed (up to 2 lines). | +| Field | Type | Required | Description | +| ----------------- | ----------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `type` | `"command"` | Yes | Must be `"command"` | +| `command` | string | Yes | Shell command to execute. Receives JSON via stdin, stdout is displayed (up to 2 lines). | +| `refreshInterval` | number | No | Re-run the command every N seconds (minimum 1). Useful for data that changes without an Agent state event (clock, quota, uptime). | ## JSON input @@ -188,6 +189,24 @@ Output: `my-project (main)` Output: `+120/-30 lines` +### Live clock and git branch + +Use `refreshInterval` when the statusline shows data that changes without an Agent event (e.g. the clock, uptime, or rate-limit counters): + +```json +{ + "ui": { + "statusLine": { + "type": "command", + "command": "input=$(cat); branch=$(echo \"$input\" | jq -r '.git.branch // \"no-git\"'); echo \"$(date +%H:%M:%S) ($branch)\"", + "refreshInterval": 1 + } + } +} +``` + +Output (refreshed every second): `14:32:07 (main)` + ### Script file for complex commands For longer commands, save a script file at `~/.qwen/statusline-command.sh`: @@ -225,7 +244,7 @@ Then reference it in settings: ## Behavior -- **Update triggers**: The status line updates when the model changes, a new message is sent (token count changes), vim mode is toggled, git branch changes, tool calls complete, or file changes occur. Updates are debounced (300ms). +- **Update triggers**: The status line updates when the model changes, a new message is sent (token count changes), vim mode is toggled, git branch changes, tool calls complete, or file changes occur. Updates are debounced (300ms). Set `refreshInterval` (seconds) to additionally re-run the command on a timer — useful for data that changes without an Agent event (clock, rate limits, build status). - **Timeout**: Commands that take longer than 5 seconds are killed. The status line clears on failure. - **Output**: Multi-line output is supported (up to 2 lines; extra lines are discarded). Each line is rendered as a separate row with dimmed colors in the footer's left section. Lines that exceed the available width are truncated. - **Hot reload**: Changes to `ui.statusLine` in settings take effect immediately — no restart required. @@ -238,5 +257,5 @@ Then reference it in settings: | ----------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Status line not showing | Config at wrong path | Must be under `ui.statusLine`, not root-level `statusLine` | | Empty output | Command fails silently | Test manually: `echo '{"session_id":"test","version":"0.14.1","model":{"display_name":"test"},"context_window":{"context_window_size":0,"used_percentage":0,"remaining_percentage":100,"current_usage":0,"total_input_tokens":0,"total_output_tokens":0},"workspace":{"current_dir":"/tmp"},"metrics":{"models":{},"files":{"total_lines_added":0,"total_lines_removed":0}}}' \| sh -c 'your_command'` | -| Stale data | No trigger fired | Send a message or switch models to trigger an update | +| Stale data | No trigger fired | Send a message or switch models to trigger an update — or set `refreshInterval` to re-run the command on a timer | | Command too slow | Complex script | Optimize the script or move heavy work to a background cache | diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 512efd905..5f0b6aeff 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -509,8 +509,15 @@ const SETTINGS_SCHEMA = { label: 'Status Line', category: 'UI', requiresRestart: false, - default: undefined as { type: 'command'; command: string } | undefined, - description: 'Custom status line display configuration.', + default: undefined as + | { + type: 'command'; + command: string; + refreshInterval?: number; + } + | undefined, + description: + 'Custom status line display configuration. Optional `refreshInterval` (seconds, >= 1) re-runs the command on a timer so external data stays fresh.', showInDialog: false, }, customThemes: { diff --git a/packages/cli/src/ui/hooks/useStatusLine.test.ts b/packages/cli/src/ui/hooks/useStatusLine.test.ts index 733feb1ab..ca2776b5c 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.test.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.test.ts @@ -82,7 +82,9 @@ let stdinErrorHandler: ((err: Error) => void) | undefined; let mockKill: ReturnType; function setStatusLineConfig( - config: { type: string; command: string } | undefined, + config: + | { type: string; command: string; refreshInterval?: number } + | undefined, ) { mockSettings.merged = config ? { ui: { statusLine: config } } : {}; } @@ -779,4 +781,254 @@ describe('useStatusLine', () => { expect(result.current.lines).toEqual(['recovered']); }); }); + + // --- Output deduplication (cuts unnecessary Footer re-renders) --- + + describe('output deduplication', () => { + it('preserves the same lines array reference when output is unchanged', async () => { + setStatusLineConfig({ type: 'command', command: 'echo same' }); + const { result, rerender } = renderHook(() => useStatusLine()); + + await act(async () => { + execCallback(null, 'same output\n', ''); + }); + const firstRef = result.current.lines; + expect(firstRef).toEqual(['same output']); + + // Trigger another exec with identical output (e.g. via state change). + mockUIState.currentModel = 'new-model'; + rerender(); + await act(async () => { + vi.advanceTimersByTime(300); + }); + await act(async () => { + execCallback(null, 'same output\n', ''); + }); + + // Reference preserved → React can skip the Footer re-render. + expect(result.current.lines).toBe(firstRef); + }); + + it('produces a new reference when output changes', async () => { + setStatusLineConfig({ type: 'command', command: 'echo tick' }); + const { result, rerender } = renderHook(() => useStatusLine()); + + await act(async () => { + execCallback(null, 'first\n', ''); + }); + const firstRef = result.current.lines; + expect(firstRef).toEqual(['first']); + + mockUIState.currentModel = 'new-model'; + rerender(); + await act(async () => { + vi.advanceTimersByTime(300); + }); + await act(async () => { + execCallback(null, 'second\n', ''); + }); + + expect(result.current.lines).not.toBe(firstRef); + expect(result.current.lines).toEqual(['second']); + }); + }); + + // --- refreshInterval (periodic refresh) --- + + describe('refreshInterval', () => { + it('re-executes the command every N seconds', async () => { + setStatusLineConfig({ + type: 'command', + command: 'echo tick', + refreshInterval: 2, + }); + renderHook(() => useStatusLine()); + + // Mount executes once immediately + expect(child_process.exec).toHaveBeenCalledTimes(1); + await act(async () => { + execCallback(null, 'tick 1\n', ''); + }); + + // First interval tick after 2s — previous exec has completed, so + // the tick is free to spawn a new one. + await act(async () => { + vi.advanceTimersByTime(2000); + }); + expect(child_process.exec).toHaveBeenCalledTimes(2); + await act(async () => { + execCallback(null, 'tick 2\n', ''); + }); + + // Second interval tick after another 2s + await act(async () => { + vi.advanceTimersByTime(2000); + }); + expect(child_process.exec).toHaveBeenCalledTimes(3); + }); + + it('does not start an interval when refreshInterval is omitted', async () => { + setStatusLineConfig({ type: 'command', command: 'echo static' }); + renderHook(() => useStatusLine()); + expect(child_process.exec).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(60_000); + }); + // Still only the mount exec — no periodic refresh + expect(child_process.exec).toHaveBeenCalledTimes(1); + }); + + it('rejects refreshInterval < 1 (no interval scheduled)', async () => { + setStatusLineConfig({ + type: 'command', + command: 'echo tick', + refreshInterval: 0.5, + }); + renderHook(() => useStatusLine()); + expect(child_process.exec).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(60_000); + }); + expect(child_process.exec).toHaveBeenCalledTimes(1); + }); + + it('rejects non-finite refreshInterval (no interval scheduled)', async () => { + setStatusLineConfig({ + type: 'command', + command: 'echo tick', + refreshInterval: Number.POSITIVE_INFINITY, + }); + renderHook(() => useStatusLine()); + expect(child_process.exec).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(60_000); + }); + expect(child_process.exec).toHaveBeenCalledTimes(1); + }); + + it('clears the interval when config is removed', async () => { + setStatusLineConfig({ + type: 'command', + command: 'echo tick', + refreshInterval: 1, + }); + const { rerender } = renderHook(() => useStatusLine()); + expect(child_process.exec).toHaveBeenCalledTimes(1); + + // Remove the config — the interval should be torn down. + setStatusLineConfig(undefined); + rerender(); + + const callsAfterRemoval = vi.mocked(child_process.exec).mock.calls.length; + + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + expect(vi.mocked(child_process.exec).mock.calls.length).toBe( + callsAfterRemoval, + ); + }); + + it('reschedules when refreshInterval changes', async () => { + setStatusLineConfig({ + type: 'command', + command: 'echo tick', + refreshInterval: 5, + }); + const { rerender } = renderHook(() => useStatusLine()); + expect(child_process.exec).toHaveBeenCalledTimes(1); + await act(async () => { + execCallback(null, 'tick\n', ''); + }); + + // 2s passes — not yet a tick on the 5s schedule. + await act(async () => { + vi.advanceTimersByTime(2000); + }); + expect(child_process.exec).toHaveBeenCalledTimes(1); + + // Swap to a 1s interval — the old 5s timer must be cleared, not kept. + setStatusLineConfig({ + type: 'command', + command: 'echo tick', + refreshInterval: 1, + }); + rerender(); + + // 1s later — fires on the new schedule. + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(child_process.exec).toHaveBeenCalledTimes(2); + }); + + it('clears the interval on unmount', async () => { + setStatusLineConfig({ + type: 'command', + command: 'echo tick', + refreshInterval: 1, + }); + const { unmount } = renderHook(() => useStatusLine()); + expect(child_process.exec).toHaveBeenCalledTimes(1); + + unmount(); + + const callsAfterUnmount = vi.mocked(child_process.exec).mock.calls.length; + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + expect(vi.mocked(child_process.exec).mock.calls.length).toBe( + callsAfterUnmount, + ); + }); + + it('skips periodic ticks while a previous exec is still running', async () => { + // Starvation regression (#3383 review): with refreshInterval < command + // latency, if every tick called doUpdate() it would kill the in-flight + // child, generation++ would stale the eventual callback, and the user + // would never see any output. This test asserts BOTH the guard (exec + // call count) AND the user-visible result (rendered lines). + setStatusLineConfig({ + type: 'command', + command: 'slow-command', + refreshInterval: 1, + }); + const { result } = renderHook(() => useStatusLine()); + + // Mount exec: child is spawned, callback NOT yet resolved — child is + // still "running" from the hook's perspective. + expect(child_process.exec).toHaveBeenCalledTimes(1); + const pendingCallback = execCallback; + + // Several interval ticks pass while the first exec is in flight. + // Each tick must detect the running child and skip doUpdate(). + await act(async () => { + vi.advanceTimersByTime(1000); + }); + await act(async () => { + vi.advanceTimersByTime(1000); + }); + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(child_process.exec).toHaveBeenCalledTimes(1); + + // First exec finally completes — activeChildRef clears. Without the + // guard, generationRef would have bumped 3 times above and the next + // line would be ignored as stale, leaving `lines` permanently empty. + await act(async () => { + pendingCallback(null, 'done\n', ''); + }); + expect(result.current.lines).toEqual(['done']); + + // Next tick is now free to spawn a new exec. + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(child_process.exec).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useStatusLine.ts b/packages/cli/src/ui/hooks/useStatusLine.ts index e60ad51bf..694481182 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.ts @@ -69,6 +69,10 @@ export interface StatusLineCommandInput { interface StatusLineConfig { type: 'command'; command: string; + // Re-run the command every N seconds so external data (git branch, quota, + // clock) stays fresh even when no Agent state changes. Values < 1 are + // rejected in getStatusLineConfig to avoid flooding the CLI with execs. + refreshInterval?: number; } const debugLog = createDebugLogger('STATUS_LINE'); @@ -93,6 +97,13 @@ function getStatusLineConfig( type: 'command', command: raw.command, }; + if ( + typeof raw.refreshInterval === 'number' && + Number.isFinite(raw.refreshInterval) && + raw.refreshInterval >= 1 + ) { + config.refreshInterval = raw.refreshInterval; + } return config; } return undefined; @@ -133,7 +144,10 @@ function buildMetricsPayload( * via stdin. * * Updates are debounced (300ms) and triggered by state changes (model switch, - * new messages, vim mode toggle) rather than blind polling. + * new messages, vim mode toggle) rather than blind polling. When the config + * sets `refreshInterval` (seconds, >= 1), the command is additionally re-run + * on a timer so external data (git branch, quota, clock) stays fresh even + * when no Agent state has changed. */ export function useStatusLine(): { lines: string[]; @@ -145,6 +159,7 @@ export function useStatusLine(): { const statusLineConfig = getStatusLineConfig(settings); const statusLineCommand = statusLineConfig?.command; + const refreshInterval = statusLineConfig?.refreshInterval; const [output, setOutput] = useState([]); @@ -285,15 +300,27 @@ export function useStatusLine(): { (error, stdout) => { if (gen !== generationRef.current) return; // stale activeChildRef.current = undefined; - if (!error && stdout) { - const lines = stdout - .replace(/\r?\n$/, '') - .split(/\r?\n/) - .filter(Boolean); - setOutput(lines.slice(0, MAX_STATUS_LINES)); - } else { - setOutput([]); - } + const nextLines = + !error && stdout + ? stdout + .replace(/\r?\n$/, '') + .split(/\r?\n/) + .filter(Boolean) + .slice(0, MAX_STATUS_LINES) + : []; + // Skip the state update if the output is unchanged — avoids a + // Footer re-render each periodic tick, which cuts wasted work + // and reduces the window for Ink to miscount rows in narrow + // terminals when `refreshInterval` runs at 1s (see #3383). + setOutput((prev) => { + if ( + prev.length === nextLines.length && + prev.every((v, i) => v === nextLines[i]) + ) { + return prev; + } + return nextLines; + }); }, ); } catch (err) { @@ -389,6 +416,24 @@ export function useStatusLine(): { // eslint-disable-next-line react-hooks/exhaustive-deps }, [statusLineCommand]); + // Periodic refresh — re-run the command every `refreshInterval` seconds. + // The tick yields if a previous exec is still running: unlike state-change + // triggers (which legitimately need to preempt stale data), the periodic + // tick exists only to keep external data fresh, so killing an in-flight + // child would starve commands that run longer than `refreshInterval` and + // the statusline would never update. The 5s exec timeout still caps the + // wait, and state-change triggers still go through `doUpdate` directly. + useEffect(() => { + if (!statusLineCommand || !refreshInterval) return; + const timer = setInterval(() => { + if (activeChildRef.current) return; + doUpdate(); + }, refreshInterval * 1000); + return () => { + clearInterval(timer); + }; + }, [statusLineCommand, refreshInterval, doUpdate]); + // Initial execution + cleanup useEffect(() => { hasMountedRef.current = true; diff --git a/packages/core/src/subagents/builtin-agents.ts b/packages/core/src/subagents/builtin-agents.ts index b40a5446c..d645548df 100644 --- a/packages/core/src/subagents/builtin-agents.ts +++ b/packages/core/src/subagents/builtin-agents.ts @@ -240,6 +240,15 @@ How to use the statusLine command: } Make sure to preserve any existing "ui" settings (theme, etc.) when updating. +4. Optionally add a "refreshInterval" field (number of seconds, minimum 1) to re-run + the command on a timer. Use this when the statusLine shows data that can change + WITHOUT an Agent event — examples: + - A clock / uptime / elapsed timer → refreshInterval: 1 + - Rate-limit or quota counters that tick down → refreshInterval: 5–10 + - CI / build status polled from a local cache file → refreshInterval: 10–30 + Do NOT set refreshInterval for commands that only show Agent-driven data + (model name, token usage, git branch) — those already refresh on state changes. + Guidelines: - The status line supports multi-line output (up to 2 lines) — each line of stdout is rendered as a separate row in the footer - Preserve existing settings when updating diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 2b640b5fd..8413aacec 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -149,7 +149,7 @@ "default": "Qwen Dark" }, "statusLine": { - "description": "Custom status line display configuration.", + "description": "Custom status line display configuration. Optional `refreshInterval` (seconds, >= 1) re-runs the command on a timer so external data stays fresh.", "type": "object", "additionalProperties": true },