fix(cli): optimize theme polling, update units to seconds, and add backoff

This commit is contained in:
Abhijit Balaji
2026-01-30 16:37:15 -08:00
parent be348c6e1e
commit dc762c5df0
7 changed files with 100 additions and 46 deletions

View File

@@ -42,7 +42,7 @@ they appear in the UI.
| UI Label | Setting | Description | Default |
| ------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in milliseconds to poll the terminal background color. | `60000` |
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |

View File

@@ -176,9 +176,8 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **`ui.terminalBackgroundPollingInterval`** (number):
- **Description:** Interval in milliseconds to poll the terminal background
color.
- **Default:** `60000`
- **Description:** Interval in seconds to poll the terminal background color.
- **Default:** `60`
- **`ui.customThemes`** (object):
- **Description:** Custom theme definitions.

25
package-lock.json generated
View File

@@ -2251,7 +2251,6 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2432,7 +2431,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2466,7 +2464,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2835,7 +2832,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2869,7 +2865,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1"
@@ -2922,7 +2917,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1",
@@ -4128,7 +4122,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4406,7 +4399,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5399,7 +5391,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8409,7 +8400,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8950,7 +8940,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -10552,7 +10541,6 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -14311,7 +14299,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14322,7 +14309,6 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16559,7 +16545,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16783,8 +16768,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16792,7 +16776,6 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16965,7 +16948,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17173,7 +17155,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -17287,7 +17268,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17300,7 +17280,6 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -18005,7 +17984,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -18300,7 +18278,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},

View File

@@ -366,9 +366,9 @@ const SETTINGS_SCHEMA = {
label: 'Terminal Background Polling Interval',
category: 'UI',
requiresRestart: false,
default: 60000,
default: 60,
description:
'Interval in milliseconds to poll the terminal background color.',
'Interval in seconds to poll the terminal background color.',
showInDialog: true,
},
customThemes: {

View File

@@ -37,7 +37,7 @@ const mockSettings = {
ui: {
theme: 'default', // DEFAULT_THEME.name
autoThemeSwitching: true,
terminalBackgroundPollingInterval: 100, // Short interval for testing
terminalBackgroundPollingInterval: 1, // 1 second
},
},
};
@@ -76,6 +76,9 @@ describe('useTerminalTheme', () => {
mockUnsubscribe.mockClear();
mockHandleThemeSelect.mockClear();
mockSetTerminalBackground.mockClear();
// Reset any settings modifications
mockSettings.merged.ui.autoThemeSwitching = true;
mockSettings.merged.ui.theme = 'default';
});
afterEach(() => {
@@ -99,18 +102,82 @@ describe('useTerminalTheme', () => {
it('should poll for terminal background', () => {
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
// Fast-forward time
vi.advanceTimersByTime(100);
// Fast-forward time (1 second)
vi.advanceTimersByTime(1000);
expect(mockWrite).toHaveBeenCalledWith('\x1b]11;?\x1b\\');
});
it('should stop polling after 3 unacknowledged attempts', () => {
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
// Attempt 1
vi.advanceTimersByTime(1000);
expect(mockWrite).toHaveBeenCalledTimes(1);
// Attempt 2
vi.advanceTimersByTime(1000);
expect(mockWrite).toHaveBeenCalledTimes(2);
// Attempt 3
vi.advanceTimersByTime(1000);
expect(mockWrite).toHaveBeenCalledTimes(3);
// Attempt 4 (should be blocked)
vi.advanceTimersByTime(1000);
expect(mockWrite).toHaveBeenCalledTimes(3);
});
it('should reset failure count after successful response', () => {
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
const handler = mockSubscribe.mock.calls[0][0];
// Fail twice
vi.advanceTimersByTime(1000); // 1
vi.advanceTimersByTime(1000); // 2
expect(mockWrite).toHaveBeenCalledTimes(2);
// Succeed
handler('rgb:ffff/ffff/ffff');
// Should continue polling
vi.advanceTimersByTime(1000); // 3 (reset count, so this is new attempt 1)
expect(mockWrite).toHaveBeenCalledTimes(3);
// Fail 3 times from now
vi.advanceTimersByTime(1000); // 4 (new attempt 2)
vi.advanceTimersByTime(1000); // 5 (new attempt 3)
vi.advanceTimersByTime(1000); // 6 (new attempt 4 - blocked?)
// Total calls: 3 (before success) + 3 (after success) = 6.
// Wait, attempt 3 was the one after success.
// Let's trace carefully:
// 1. Poll (0->1)
// 2. Poll (1->2)
// <Success> (Reset to 0)
// 3. Poll (0->1)
// 4. Poll (1->2)
// 5. Poll (2->3)
// 6. Blocked (3->3)
// Total expecting 5 calls? No, 3 before success?
// Initial: 0.
// T=1000: Write (Polls=1). Calls=1.
// T=2000: Write (Polls=2). Calls=2.
// Handler called. Polls=0.
// T=3000: Write (Polls=1). Calls=3.
// T=4000: Write (Polls=2). Calls=4.
// T=5000: Write (Polls=3). Calls=5.
// T=6000: Blocked. Calls=5.
expect(mockWrite).toHaveBeenCalledTimes(5);
});
it('should switch to light theme when background is light', () => {
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
const handler = mockSubscribe.mock.calls[0][0];
// Simulate light background response (white)
// OSC 11 response format: rgb:rrrr/gggg/bbbb
handler('rgb:ffff/ffff/ffff');
expect(mockSetTerminalBackground).toHaveBeenCalledWith('#ffffff');
@@ -146,7 +213,7 @@ describe('useTerminalTheme', () => {
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
// Poll should not happen
vi.advanceTimersByTime(100);
vi.advanceTimersByTime(1000);
expect(mockWrite).not.toHaveBeenCalled();
mockSettings.merged.ui.autoThemeSwitching = true;

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useStdout } from 'ink';
import {
getLuminance,
@@ -26,24 +26,35 @@ export function useTerminalTheme(
const { stdout } = useStdout();
const settings = useSettings();
const { subscribe, unsubscribe } = useTerminalContext();
const unacknowledgedPolls = useRef(0);
useEffect(() => {
const pollInterval = setInterval(() => {
// Check if auto-switching is enabled
if (settings.merged.ui.autoThemeSwitching === false) {
return;
}
// Check if auto-switching is enabled
if (settings.merged.ui.autoThemeSwitching === false) {
return;
}
const pollIntervalId = setInterval(() => {
// Only poll if we are using one of the default themes
const currentThemeName = settings.merged.ui.theme;
if (!themeManager.isDefaultTheme(currentThemeName)) {
return;
}
// Stop polling if the terminal isn't responding
if (unacknowledgedPolls.current >= 3) {
clearInterval(pollIntervalId);
return;
}
unacknowledgedPolls.current += 1;
stdout.write('\x1b]11;?\x1b\\');
}, settings.merged.ui.terminalBackgroundPollingInterval);
}, settings.merged.ui.terminalBackgroundPollingInterval * 1000);
const handleTerminalBackground = (colorStr: string) => {
// Reset the counter since we got a response
unacknowledgedPolls.current = 0;
// Parse the response "rgb:rrrr/gggg/bbbb"
const match =
/^rgb:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})$/.exec(
@@ -72,7 +83,7 @@ export function useTerminalTheme(
subscribe(handleTerminalBackground);
return () => {
clearInterval(pollInterval);
clearInterval(pollIntervalId);
unsubscribe(handleTerminalBackground);
};
}, [

View File

@@ -179,9 +179,9 @@
},
"terminalBackgroundPollingInterval": {
"title": "Terminal Background Polling Interval",
"description": "Interval in milliseconds to poll the terminal background color.",
"markdownDescription": "Interval in milliseconds to poll the terminal background color.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `60000`",
"default": 60000,
"description": "Interval in seconds to poll the terminal background color.",
"markdownDescription": "Interval in seconds to poll the terminal background color.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `60`",
"default": 60,
"type": "number"
},
"customThemes": {