feat(cli): implement dynamic terminal tab titles for CLI status (#16378)

This commit is contained in:
N. Taylor Mullen
2026-01-12 17:18:14 -08:00
committed by GitHub
parent c572b9e9ac
commit 2fc61685a3
10 changed files with 508 additions and 114 deletions
+2 -1
View File
@@ -42,7 +42,8 @@ they appear in the UI.
| UI Label | Setting | Description | Default | | UI Label | Setting | Description | Default |
| ------------------------------ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | ------------------------------ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` | | Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
| Show Status in Title | `ui.showStatusInTitle` | Show Gemini CLI status and thoughts in the terminal window title | `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` |
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` | | Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` | | Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` | | Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
+7 -2
View File
@@ -180,10 +180,15 @@ their corresponding top-level category object in your `settings.json` file.
- **Requires restart:** Yes - **Requires restart:** Yes
- **`ui.showStatusInTitle`** (boolean): - **`ui.showStatusInTitle`** (boolean):
- **Description:** Show Gemini CLI status and thoughts in the terminal window - **Description:** Show Gemini CLI model thoughts in the terminal window title
title during the working phase
- **Default:** `false` - **Default:** `false`
- **`ui.dynamicWindowTitle`** (boolean):
- **Description:** Update the terminal window title with current status icons
(Ready: ◇, Action Required: ✋, Working: ✦)
- **Default:** `true`
- **`ui.showHomeDirectoryWarning`** (boolean): - **`ui.showHomeDirectoryWarning`** (boolean):
- **Description:** Show a warning when running Gemini CLI in the home - **Description:** Show a warning when running Gemini CLI in the home
directory. directory.
+12 -2
View File
@@ -376,12 +376,22 @@ const SETTINGS_SCHEMA = {
}, },
showStatusInTitle: { showStatusInTitle: {
type: 'boolean', type: 'boolean',
label: 'Show Status in Title', label: 'Show Thoughts in Title',
category: 'UI', category: 'UI',
requiresRestart: false, requiresRestart: false,
default: false, default: false,
description: description:
'Show Gemini CLI status and thoughts in the terminal window title', 'Show Gemini CLI model thoughts in the terminal window title during the working phase',
showInDialog: true,
},
dynamicWindowTitle: {
type: 'boolean',
label: 'Dynamic Window Title',
category: 'UI',
requiresRestart: false,
default: true,
description:
'Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦)',
showInDialog: true, showInDialog: true,
}, },
showHomeDirectoryWarning: { showHomeDirectoryWarning: {
+10 -2
View File
@@ -75,9 +75,10 @@ import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from './utils/events.js'; import { appEvents, AppEvent } from './utils/events.js';
import { SessionSelector } from './utils/sessionUtils.js'; import { SessionSelector } from './utils/sessionUtils.js';
import { computeWindowTitle } from './utils/windowTitle.js';
import { SettingsContext } from './ui/contexts/SettingsContext.js'; import { SettingsContext } from './ui/contexts/SettingsContext.js';
import { MouseProvider } from './ui/contexts/MouseContext.js'; import { MouseProvider } from './ui/contexts/MouseContext.js';
import { StreamingState } from './ui/types.js';
import { computeTerminalTitle } from './utils/windowTitle.js';
import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js'; import { VimModeProvider } from './ui/contexts/VimModeContext.js';
@@ -711,7 +712,14 @@ export async function main() {
function setWindowTitle(title: string, settings: LoadedSettings) { function setWindowTitle(title: string, settings: LoadedSettings) {
if (!settings.merged.ui?.hideWindowTitle) { if (!settings.merged.ui?.hideWindowTitle) {
const windowTitle = computeWindowTitle(title); // Initial state before React loop starts
const windowTitle = computeTerminalTitle({
streamingState: StreamingState.Idle,
isConfirming: false,
folderName: title,
showThoughts: !!settings.merged.ui?.showStatusInTitle,
useDynamicTitle: settings.merged.ui?.dynamicWindowTitle ?? true,
});
writeToStdout(`\x1b]2;${windowTitle}\x07`); writeToStdout(`\x1b]2;${windowTitle}\x07`);
process.on('exit', () => { process.on('exit', () => {
+151 -34
View File
@@ -250,20 +250,13 @@ describe('AppContainer State Management', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockIdeClient.getInstance.mockReturnValue(new Promise(() => {}));
// Initialize mock stdout for terminal title tests // Initialize mock stdout for terminal title tests
mocks.mockStdout.write.mockClear(); mocks.mockStdout.write.mockClear();
// Mock computeWindowTitle function to centralize title logic testing
vi.mock('../utils/windowTitle.js', async () => ({
computeWindowTitle: vi.fn(
(folderName: string) =>
// Default behavior: return "Gemini - {folderName}" unless CLI_TITLE is set
process.env['CLI_TITLE'] || `Gemini - ${folderName}`,
),
}));
capturedUIState = null!; capturedUIState = null!;
capturedUIActions = null!;
// **Provide a default return value for EVERY mocked hook.** // **Provide a default return value for EVERY mocked hook.**
mockedUseQuotaAndFallback.mockReturnValue({ mockedUseQuotaAndFallback.mockReturnValue({
@@ -413,6 +406,7 @@ describe('AppContainer State Management', () => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.restoreAllMocks();
}); });
describe('Basic Rendering', () => { describe('Basic Rendering', () => {
@@ -995,7 +989,7 @@ describe('AppContainer State Management', () => {
expect(stdout).toBe(mocks.mockStdout); expect(stdout).toBe(mocks.mockStdout);
}); });
it('should not update terminal title when showStatusInTitle is false', () => { it('should update terminal title with Working… when showStatusInTitle is false', () => {
// Arrange: Set up mock settings with showStatusInTitle disabled // Arrange: Set up mock settings with showStatusInTitle disabled
const mockSettingsWithShowStatusFalse = { const mockSettingsWithShowStatusFalse = {
...mockSettings, ...mockSettings,
@@ -1009,17 +1003,71 @@ describe('AppContainer State Management', () => {
}, },
} as unknown as LoadedSettings; } as unknown as LoadedSettings;
// Mock the streaming state as Active
mockedUseGeminiStream.mockReturnValue({
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Some thought' },
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container // Act: Render the container
const { unmount } = renderAppContainer({ const { unmount } = renderAppContainer({
settings: mockSettingsWithShowStatusFalse, settings: mockSettingsWithShowStatusFalse,
}); });
// Assert: Check that no title-related writes occurred // Assert: Check that title was updated with "Working…"
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
call[0].includes('\x1b]2;'), call[0].includes('\x1b]2;'),
); );
expect(titleWrites).toHaveLength(0); expect(titleWrites).toHaveLength(1);
expect(titleWrites[0][0]).toBe(
`\x1b]2;${'✦ Working… (workspace)'.padEnd(80, ' ')}\x07`,
);
unmount();
});
it('should use legacy terminal title when dynamicWindowTitle is false', () => {
// Arrange: Set up mock settings with dynamicWindowTitle disabled
const mockSettingsWithDynamicTitleFalse = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
dynamicWindowTitle: false,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// Mock the streaming state
mockedUseGeminiStream.mockReturnValue({
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Some thought' },
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
const { unmount } = renderAppContainer({
settings: mockSettingsWithDynamicTitleFalse,
});
// Assert: Check that legacy title was used
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
call[0].includes('\x1b]2;'),
);
expect(titleWrites).toHaveLength(1);
expect(titleWrites[0][0]).toBe(
`\x1b]2;${'Gemini CLI (workspace)'.padEnd(80, ' ')}\x07`,
);
unmount(); unmount();
}); });
@@ -1081,14 +1129,14 @@ describe('AppContainer State Management', () => {
settings: mockSettingsWithTitleEnabled, settings: mockSettingsWithTitleEnabled,
}); });
// Assert: Check that title was updated with thought subject // Assert: Check that title was updated with thought subject and suffix
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
call[0].includes('\x1b]2;'), call[0].includes('\x1b]2;'),
); );
expect(titleWrites).toHaveLength(1); expect(titleWrites).toHaveLength(1);
expect(titleWrites[0][0]).toBe( expect(titleWrites[0][0]).toBe(
`\x1b]2;${thoughtSubject.padEnd(80, ' ')}\x07`, `\x1b]2;${`${thoughtSubject} (workspace)`.padEnd(80, ' ')}\x07`,
); );
unmount(); unmount();
}); });
@@ -1129,12 +1177,12 @@ describe('AppContainer State Management', () => {
expect(titleWrites).toHaveLength(1); expect(titleWrites).toHaveLength(1);
expect(titleWrites[0][0]).toBe( expect(titleWrites[0][0]).toBe(
`\x1b]2;${'Gemini - workspace'.padEnd(80, ' ')}\x07`, `\x1b]2;${'◇ Ready (workspace)'.padEnd(80, ' ')}\x07`,
); );
unmount(); unmount();
}); });
it('should update terminal title when in WaitingForConfirmation state with thought subject', () => { it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => {
// Arrange: Set up mock settings with showStatusInTitle enabled // Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = { const mockSettingsWithTitleEnabled = {
...mockSettings, ...mockSettings,
@@ -1151,7 +1199,7 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought // Mock the streaming state and thought
const thoughtSubject = 'Confirm tool execution'; const thoughtSubject = 'Confirm tool execution';
mockedUseGeminiStream.mockReturnValue({ mockedUseGeminiStream.mockReturnValue({
streamingState: 'waitingForConfirmation', streamingState: 'waiting_for_confirmation',
submitQuery: vi.fn(), submitQuery: vi.fn(),
initError: null, initError: null,
pendingHistoryItems: [], pendingHistoryItems: [],
@@ -1160,9 +1208,13 @@ describe('AppContainer State Management', () => {
}); });
// Act: Render the container // Act: Render the container
const { unmount } = renderAppContainer({ let unmount: () => void;
await act(async () => {
const result = renderAppContainer({
settings: mockSettingsWithTitleEnabled, settings: mockSettingsWithTitleEnabled,
}); });
unmount = result.unmount;
});
// Assert: Check that title was updated with confirmation text // Assert: Check that title was updated with confirmation text
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
@@ -1171,10 +1223,75 @@ describe('AppContainer State Management', () => {
expect(titleWrites).toHaveLength(1); expect(titleWrites).toHaveLength(1);
expect(titleWrites[0][0]).toBe( expect(titleWrites[0][0]).toBe(
`\x1b]2;${thoughtSubject.padEnd(80, ' ')}\x07`, `\x1b]2;${'✋ Action Required (workspace)'.padEnd(80, ' ')}\x07`,
); );
unmount!();
});
describe('Shell Focus Action Required', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should show Action Required in title after a delay when shell is awaiting focus', async () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// Mock an active shell pty but not focused
mockedUseGeminiStream.mockReturnValue({
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
cancelOngoingRequest: vi.fn(),
activePtyId: 'pty-1',
});
// Act: Render the container (embeddedShellFocused is false by default in state)
const { unmount } = renderAppContainer({
settings: mockSettingsWithTitleEnabled,
});
// Initially it should show the working status
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
call[0].includes('\x1b]2;'),
);
expect(titleWrites[titleWrites.length - 1][0]).toContain(
'✦ Executing shell command',
);
// Fast-forward time by 31 seconds
await act(async () => {
vi.advanceTimersByTime(31000);
});
// Now it should show Action Required
await waitFor(() => {
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
call[0].includes('\x1b]2;'),
);
const lastTitle = titleWrites[titleWrites.length - 1][0];
expect(lastTitle).toContain('✋ Action Required');
});
unmount(); unmount();
}); });
});
it('should pad title to exactly 80 characters', () => { it('should pad title to exactly 80 characters', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled // Arrange: Set up mock settings with showStatusInTitle enabled
@@ -1213,12 +1330,9 @@ describe('AppContainer State Management', () => {
expect(titleWrites).toHaveLength(1); expect(titleWrites).toHaveLength(1);
const calledWith = titleWrites[0][0]; const calledWith = titleWrites[0][0];
const expectedTitle = shortTitle.padEnd(80, ' '); const expectedTitle = `${shortTitle} (workspace)`.padEnd(80, ' ');
const expectedEscapeSequence = `\x1b]2;${expectedTitle}\x07`;
expect(calledWith).toContain(shortTitle); expect(calledWith).toBe(expectedEscapeSequence);
expect(calledWith).toContain('\x1b]2;');
expect(calledWith).toContain('\x07');
expect(calledWith).toBe('\x1b]2;' + expectedTitle + '\x07');
unmount(); unmount();
}); });
@@ -1258,20 +1372,20 @@ describe('AppContainer State Management', () => {
); );
expect(titleWrites).toHaveLength(1); expect(titleWrites).toHaveLength(1);
const expectedEscapeSequence = `\x1b]2;${title.padEnd(80, ' ')}\x07`; const expectedEscapeSequence = `\x1b]2;${`${title} (workspace)`.padEnd(80, ' ')}\x07`;
expect(titleWrites[0][0]).toBe(expectedEscapeSequence); expect(titleWrites[0][0]).toBe(expectedEscapeSequence);
unmount(); unmount();
}); });
it('should use CLI_TITLE environment variable when set', () => { it('should use CLI_TITLE environment variable when set', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled // Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix)
const mockSettingsWithTitleEnabled = { const mockSettingsWithTitleDisabled = {
...mockSettings, ...mockSettings,
merged: { merged: {
...mockSettings.merged, ...mockSettings.merged,
ui: { ui: {
...mockSettings.merged.ui, ...mockSettings.merged.ui,
showStatusInTitle: true, showStatusInTitle: false,
hideWindowTitle: false, hideWindowTitle: false,
}, },
}, },
@@ -1280,9 +1394,9 @@ describe('AppContainer State Management', () => {
// Mock CLI_TITLE environment variable // Mock CLI_TITLE environment variable
vi.stubEnv('CLI_TITLE', 'Custom Gemini Title'); vi.stubEnv('CLI_TITLE', 'Custom Gemini Title');
// Mock the streaming state as Idle with no thought // Mock the streaming state
mockedUseGeminiStream.mockReturnValue({ mockedUseGeminiStream.mockReturnValue({
streamingState: 'idle', streamingState: 'responding',
submitQuery: vi.fn(), submitQuery: vi.fn(),
initError: null, initError: null,
pendingHistoryItems: [], pendingHistoryItems: [],
@@ -1292,7 +1406,7 @@ describe('AppContainer State Management', () => {
// Act: Render the container // Act: Render the container
const { unmount } = renderAppContainer({ const { unmount } = renderAppContainer({
settings: mockSettingsWithTitleEnabled, settings: mockSettingsWithTitleDisabled,
}); });
// Assert: Check that title was updated with CLI_TITLE value // Assert: Check that title was updated with CLI_TITLE value
@@ -1302,7 +1416,7 @@ describe('AppContainer State Management', () => {
expect(titleWrites).toHaveLength(1); expect(titleWrites).toHaveLength(1);
expect(titleWrites[0][0]).toBe( expect(titleWrites[0][0]).toBe(
`\x1b]2;${'Custom Gemini Title'.padEnd(80, ' ')}\x07`, `\x1b]2;${'✦ Working… (Custom Gemini Title)'.padEnd(80, ' ')}\x07`,
); );
unmount(); unmount();
}); });
@@ -1315,6 +1429,7 @@ describe('AppContainer State Management', () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
vi.restoreAllMocks();
}); });
it('should set and clear the queue error message after a timeout', async () => { it('should set and clear the queue error message after a timeout', async () => {
@@ -1483,6 +1598,7 @@ describe('AppContainer State Management', () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
vi.restoreAllMocks();
}); });
describe('CTRL+C', () => { describe('CTRL+C', () => {
@@ -1620,6 +1736,7 @@ describe('AppContainer State Management', () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
vi.restoreAllMocks();
}); });
describe.each([ describe.each([
+28 -22
View File
@@ -81,7 +81,7 @@ import { calculateMainAreaWidth } from './utils/ui-sizing.js';
import ansiEscapes from 'ansi-escapes'; import ansiEscapes from 'ansi-escapes';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { basename } from 'node:path'; import { basename } from 'node:path';
import { computeWindowTitle } from '../utils/windowTitle.js'; import { computeTerminalTitle } from '../utils/windowTitle.js';
import { useTextBuffer } from './components/shared/text-buffer.js'; import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js'; import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useGeminiStream } from './hooks/useGeminiStream.js';
@@ -125,8 +125,10 @@ import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { import {
WARNING_PROMPT_DURATION_MS, WARNING_PROMPT_DURATION_MS,
QUEUE_ERROR_DISPLAY_DURATION_MS, QUEUE_ERROR_DISPLAY_DURATION_MS,
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
} from './constants.js'; } from './constants.js';
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js'; import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import { useInactivityTimer } from './hooks/useInactivityTimer.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => { return pendingHistoryItems.some((item) => {
@@ -278,9 +280,6 @@ export const AppContainer = (props: AppContainerProps) => {
const mainControlsRef = useRef<DOMElement>(null); const mainControlsRef = useRef<DOMElement>(null);
// For performance profiling only // For performance profiling only
const rootUiRef = useRef<DOMElement>(null); const rootUiRef = useRef<DOMElement>(null);
const originalTitleRef = useRef(
computeWindowTitle(basename(config.getTargetDir())),
);
const lastTitleRef = useRef<string | null>(null); const lastTitleRef = useRef<string | null>(null);
const staticExtraHeight = 3; const staticExtraHeight = 3;
@@ -828,6 +827,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
lastOutputTimeRef.current = lastOutputTime; lastOutputTimeRef.current = lastOutputTime;
}, [lastOutputTime]); }, [lastOutputTime]);
const isShellAwaitingFocus = !!activePtyId && !embeddedShellFocused;
const showShellActionRequired = useInactivityTimer(
isShellAwaitingFocus,
isShellAwaitingFocus,
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
);
// Auto-accept indicator // Auto-accept indicator
const showAutoAcceptIndicator = useAutoAcceptIndicator({ const showAutoAcceptIndicator = useAutoAcceptIndicator({
config, config,
@@ -1338,25 +1344,20 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Update terminal title with Gemini CLI status and thoughts // Update terminal title with Gemini CLI status and thoughts
useEffect(() => { useEffect(() => {
// Respect both showStatusInTitle and hideWindowTitle settings // Respect hideWindowTitle settings
if ( if (settings.merged.ui?.hideWindowTitle) return;
!settings.merged.ui?.showStatusInTitle ||
settings.merged.ui?.hideWindowTitle
)
return;
let title; const paddedTitle = computeTerminalTitle({
if (streamingState === StreamingState.Idle) { streamingState,
title = originalTitleRef.current; thoughtSubject: thought?.subject,
} else { isConfirming:
const statusText = thought?.subject !!shellConfirmationRequest ||
?.replace(/[\r\n]+/g, ' ') !!confirmationRequest ||
.substring(0, 80); showShellActionRequired,
title = statusText || originalTitleRef.current; folderName: basename(config.getTargetDir()),
} showThoughts: !!settings.merged.ui?.showStatusInTitle,
useDynamicTitle: settings.merged.ui?.dynamicWindowTitle ?? true,
// Pad the title to a fixed width to prevent taskbar icon resizing. });
const paddedTitle = title.padEnd(80, ' ');
// Only update the title if it's different from the last value we set // Only update the title if it's different from the last value we set
if (lastTitleRef.current !== paddedTitle) { if (lastTitleRef.current !== paddedTitle) {
@@ -1367,8 +1368,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
}, [ }, [
streamingState, streamingState,
thought, thought,
shellConfirmationRequest,
confirmationRequest,
showShellActionRequired,
settings.merged.ui?.showStatusInTitle, settings.merged.ui?.showStatusInTitle,
settings.merged.ui?.dynamicWindowTitle,
settings.merged.ui?.hideWindowTitle, settings.merged.ui?.hideWindowTitle,
config,
stdout, stdout,
]); ]);
+1
View File
@@ -31,3 +31,4 @@ export const MAX_MCP_RESOURCES_TO_SHOW = 10;
export const WARNING_PROMPT_DURATION_MS = 1000; export const WARNING_PROMPT_DURATION_MS = 1000;
export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000;
+189 -35
View File
@@ -4,56 +4,210 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { computeWindowTitle } from './windowTitle.js'; import { computeTerminalTitle } from './windowTitle.js';
import { StreamingState } from '../ui/types.js';
describe('computeWindowTitle', () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalEnv = process.env;
vi.stubEnv('CLI_TITLE', undefined);
});
describe('computeTerminalTitle', () => {
afterEach(() => { afterEach(() => {
process.env = originalEnv; vi.unstubAllEnvs();
}); });
it('should use default Gemini title when CLI_TITLE is not set', () => { it.each([
const result = computeWindowTitle('my-project'); {
expect(result).toBe('Gemini - my-project'); description: 'idle state title with folder name',
args: {
streamingState: StreamingState.Idle,
isConfirming: false,
folderName: 'my-project',
showThoughts: false,
useDynamicTitle: true,
},
expected: '◇ Ready (my-project)',
},
{
description: 'legacy title when useDynamicTitle is false',
args: {
streamingState: StreamingState.Responding,
isConfirming: false,
folderName: 'my-project',
showThoughts: true,
useDynamicTitle: false,
},
expected: 'Gemini CLI (my-project)'.padEnd(80, ' '),
exact: true,
},
{
description:
'active state title with "Working…" when thoughts are disabled',
args: {
streamingState: StreamingState.Responding,
thoughtSubject: 'Reading files',
isConfirming: false,
folderName: 'my-project',
showThoughts: false,
useDynamicTitle: true,
},
expected: '✦ Working… (my-project)',
},
{
description:
'active state title with thought subject and suffix when thoughts are short enough',
args: {
streamingState: StreamingState.Responding,
thoughtSubject: 'Short thought',
isConfirming: false,
folderName: 'my-project',
showThoughts: true,
useDynamicTitle: true,
},
expected: '✦ Short thought (my-project)',
},
{
description:
'fallback active title with suffix if no thought subject is provided even when thoughts are enabled',
args: {
streamingState: StreamingState.Responding,
thoughtSubject: undefined,
isConfirming: false,
folderName: 'my-project',
showThoughts: true,
useDynamicTitle: true,
},
expected: '✦ Working… (my-project)'.padEnd(80, ' '),
exact: true,
},
{
description: 'action required state when confirming',
args: {
streamingState: StreamingState.Idle,
isConfirming: true,
folderName: 'my-project',
showThoughts: false,
useDynamicTitle: true,
},
expected: '✋ Action Required (my-project)',
},
])('should return $description', ({ args, expected, exact }) => {
const title = computeTerminalTitle(args);
if (exact) {
expect(title).toBe(expected);
} else {
expect(title).toContain(expected);
}
expect(title.length).toBe(80);
}); });
it('should use CLI_TITLE environment variable when set', () => { it('should return active state title with thought subject and NO suffix when thoughts are very long', () => {
vi.stubEnv('CLI_TITLE', 'Custom Title'); const longThought = 'A'.repeat(70);
const result = computeWindowTitle('my-project'); const title = computeTerminalTitle({
expect(result).toBe('Custom Title'); streamingState: StreamingState.Responding,
thoughtSubject: longThought,
isConfirming: false,
folderName: 'my-project',
showThoughts: true,
useDynamicTitle: true,
}); });
it('should remove control characters from title', () => { expect(title).not.toContain('(my-project)');
vi.stubEnv('CLI_TITLE', 'Title\x1b[31m with \x07 control chars'); expect(title).toContain('✦ AAAAAAAAAAAAAAAA');
const result = computeWindowTitle('my-project'); expect(title.length).toBe(80);
// The \x1b[31m (ANSI escape sequence) and \x07 (bell character) should be removed
expect(result).toBe('Title[31m with control chars');
}); });
it('should handle folder names with control characters', () => { it('should truncate long thought subjects when thoughts are enabled', () => {
const result = computeWindowTitle('project\x07name'); const longThought = 'A'.repeat(100);
expect(result).toBe('Gemini - projectname'); const title = computeTerminalTitle({
streamingState: StreamingState.Responding,
thoughtSubject: longThought,
isConfirming: false,
folderName: 'my-project',
showThoughts: true,
useDynamicTitle: true,
}); });
it('should handle empty folder name', () => { expect(title.length).toBe(80);
const result = computeWindowTitle(''); expect(title).toContain('');
expect(result).toBe('Gemini - '); expect(title.trimEnd().length).toBe(80);
}); });
it('should handle folder names with spaces', () => { it('should strip control characters from the title', () => {
const result = computeWindowTitle('my project'); const title = computeTerminalTitle({
expect(result).toBe('Gemini - my project'); streamingState: StreamingState.Responding,
thoughtSubject: 'BadTitle\x00 With\x07Control\x1BChars',
isConfirming: false,
folderName: 'my-project',
showThoughts: true,
useDynamicTitle: true,
}); });
it('should handle folder names with special characters', () => { expect(title).toContain('BadTitle WithControlChars');
const result = computeWindowTitle('project-name_v1.0'); expect(title).not.toContain('\x00');
expect(result).toBe('Gemini - project-name_v1.0'); expect(title).not.toContain('\x07');
expect(title).not.toContain('\x1B');
expect(title.length).toBe(80);
});
it('should prioritize CLI_TITLE environment variable over folder name when thoughts are disabled', () => {
vi.stubEnv('CLI_TITLE', 'EnvOverride');
const title = computeTerminalTitle({
streamingState: StreamingState.Idle,
isConfirming: false,
folderName: 'my-project',
showThoughts: false,
useDynamicTitle: true,
});
expect(title).toContain('◇ Ready (EnvOverride)');
expect(title).not.toContain('my-project');
expect(title.length).toBe(80);
});
it.each([
{
name: 'folder name',
folderName: 'A'.repeat(100),
expected: '◇ Ready (AAAAA',
},
{
name: 'CLI_TITLE',
folderName: 'my-project',
envTitle: 'B'.repeat(100),
expected: '◇ Ready (BBBBB',
},
])(
'should truncate very long $name to fit within 80 characters',
({ folderName, envTitle, expected }) => {
if (envTitle) {
vi.stubEnv('CLI_TITLE', envTitle);
}
const title = computeTerminalTitle({
streamingState: StreamingState.Idle,
isConfirming: false,
folderName,
showThoughts: false,
useDynamicTitle: true,
});
expect(title.length).toBe(80);
expect(title).toContain(expected);
expect(title).toContain('…)');
},
);
it('should truncate long folder name when useDynamicTitle is false', () => {
const longFolderName = 'C'.repeat(100);
const title = computeTerminalTitle({
streamingState: StreamingState.Responding,
isConfirming: false,
folderName: longFolderName,
showThoughts: true,
useDynamicTitle: false,
});
expect(title.length).toBe(80);
expect(title).toContain('Gemini CLI (CCCCC');
expect(title).toContain('…)');
}); });
}); });
+94 -9
View File
@@ -4,19 +4,104 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { StreamingState } from '../ui/types.js';
export interface TerminalTitleOptions {
streamingState: StreamingState;
thoughtSubject?: string;
isConfirming: boolean;
folderName: string;
showThoughts: boolean;
useDynamicTitle: boolean;
}
function truncate(text: string, maxLen: number): string {
if (text.length <= maxLen) {
return text;
}
return text.substring(0, maxLen - 1) + '…';
}
/** /**
* Computes the window title for the Gemini CLI application. * Computes the dynamic terminal window title based on the current CLI state.
* *
* @param folderName - The name of the current folder/workspace to display in the title * @param options - The current state of the CLI and environment context
* @returns The computed window title, either from CLI_TITLE environment variable or the default Gemini title * @returns A formatted string padded to 80 characters for the terminal title
*/ */
export function computeWindowTitle(folderName: string): string { export function computeTerminalTitle({
const title = process.env['CLI_TITLE'] || `Gemini - ${folderName}`; streamingState,
thoughtSubject,
isConfirming,
folderName,
showThoughts,
useDynamicTitle,
}: TerminalTitleOptions): string {
const MAX_LEN = 80;
// Use CLI_TITLE env var if available, otherwise use the provided folder name
let displayContext = process.env['CLI_TITLE'] || folderName;
if (!useDynamicTitle) {
const base = 'Gemini CLI ';
// Max context length is 80 - base.length - 2 (for brackets)
const maxContextLen = MAX_LEN - base.length - 2;
displayContext = truncate(displayContext, maxContextLen);
return `${base}(${displayContext})`.padEnd(MAX_LEN, ' ');
}
// Pre-calculate suffix but keep it flexible
const getSuffix = (context: string) => ` (${context})`;
let title;
if (
isConfirming ||
streamingState === StreamingState.WaitingForConfirmation
) {
const base = '✋ Action Required';
// Max context length is 80 - base.length - 3 (for ' (' and ')')
const maxContextLen = MAX_LEN - base.length - 3;
const context = truncate(displayContext, maxContextLen);
title = `${base}${getSuffix(context)}`;
} else if (streamingState === StreamingState.Idle) {
const base = '◇ Ready';
// Max context length is 80 - base.length - 3 (for ' (' and ')')
const maxContextLen = MAX_LEN - base.length - 3;
const context = truncate(displayContext, maxContextLen);
title = `${base}${getSuffix(context)}`;
} else {
// Active/Working state
const cleanSubject =
showThoughts && thoughtSubject?.replace(/[\r\n]+/g, ' ').trim();
// If we have a thought subject and it's too long to fit with the suffix,
// we drop the suffix to maximize space for the thought.
// Otherwise, we keep the suffix.
const suffix = getSuffix(displayContext);
const suffixLen = suffix.length;
const canFitThoughtWithSuffix = cleanSubject
? cleanSubject.length + suffixLen + 3 <= MAX_LEN
: true;
let activeSuffix = '';
let maxStatusLen = MAX_LEN - 3; // Subtract icon prefix "✦ " (3 chars)
if (!cleanSubject || canFitThoughtWithSuffix) {
activeSuffix = suffix;
maxStatusLen -= activeSuffix.length;
}
const displayStatus = cleanSubject
? truncate(cleanSubject, maxStatusLen)
: 'Working…';
title = `${displayStatus}${activeSuffix}`;
}
// Remove control characters that could cause issues in terminal titles // Remove control characters that could cause issues in terminal titles
return title.replace(
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
/[\x00-\x1F\x7F]/g, const safeTitle = title.replace(/[\x00-\x1F\x7F]/g, '');
'',
); // Pad the title to a fixed width to prevent taskbar icon resizing/jitter.
// We also slice it to ensure it NEVER exceeds MAX_LEN.
return safeTitle.padEnd(MAX_LEN, ' ').substring(0, MAX_LEN);
} }
+10 -3
View File
@@ -188,12 +188,19 @@
"type": "boolean" "type": "boolean"
}, },
"showStatusInTitle": { "showStatusInTitle": {
"title": "Show Status in Title", "title": "Show Thoughts in Title",
"description": "Show Gemini CLI status and thoughts in the terminal window title", "description": "Show Gemini CLI model thoughts in the terminal window title during the working phase",
"markdownDescription": "Show Gemini CLI status and thoughts in the terminal window title\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", "markdownDescription": "Show Gemini CLI model thoughts in the terminal window title during the working phase\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
"default": false, "default": false,
"type": "boolean" "type": "boolean"
}, },
"dynamicWindowTitle": {
"title": "Dynamic Window Title",
"description": "Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦)",
"markdownDescription": "Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦)\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"showHomeDirectoryWarning": { "showHomeDirectoryWarning": {
"title": "Show Home Directory Warning", "title": "Show Home Directory Warning",
"description": "Show a warning when running Gemini CLI in the home directory.", "description": "Show a warning when running Gemini CLI in the home directory.",