diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index cc28c3e3c5..197ef0fb5a 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -87,6 +87,7 @@ const MIGRATION_MAP: Record = { folderTrust: 'security.folderTrust.enabled', hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge', hideWindowTitle: 'ui.hideWindowTitle', + showStatusInTitle: 'ui.showStatusInTitle', hideTips: 'ui.hideTips', hideBanner: 'ui.hideBanner', hideFooter: 'ui.hideFooter', diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 64ce27d368..b294ba77cd 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -248,6 +248,16 @@ const SETTINGS_SCHEMA = { description: 'Hide the window title bar', showInDialog: true, }, + showStatusInTitle: { + type: 'boolean', + label: 'Show Status in Title', + category: 'UI', + requiresRestart: false, + default: false, + description: + 'Show Gemini CLI status and thoughts in the terminal window title', + showInDialog: true, + }, hideTips: { type: 'boolean', label: 'Hide Tips', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index a521924776..2e18c8f08d 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -51,6 +51,7 @@ import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.j import { checkForUpdates } from './ui/utils/updateCheck.js'; import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from './utils/events.js'; +import { computeWindowTitle } from './utils/windowTitle.js'; import { SettingsContext } from './ui/contexts/SettingsContext.js'; import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; @@ -454,13 +455,7 @@ export async function main() { function setWindowTitle(title: string, settings: LoadedSettings) { if (!settings.merged.ui?.hideWindowTitle) { - const windowTitle = ( - process.env['CLI_TITLE'] || `Gemini - ${title}` - ).replace( - // eslint-disable-next-line no-control-regex - /[\x00-\x1F\x7F]/g, - '', - ); + const windowTitle = computeWindowTitle(title); process.stdout.write(`\x1b]2;${windowTitle}\x07`); process.on('exit', () => { diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index dc7b5efe7e..497f367592 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -26,6 +26,17 @@ import { } from './contexts/UIActionsContext.js'; import { useContext } from 'react'; +// Mock useStdout to capture terminal title writes +let mockStdout: { write: ReturnType }; +vi.mock('ink', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useStdout: () => ({ stdout: mockStdout }), + measureElement: vi.fn(), + }; +}); + // Helper component will read the context values provided by AppContainer // so we can assert against them in our tests. let capturedUIState: UIState; @@ -40,14 +51,6 @@ vi.mock('./App.js', () => ({ App: TestContextConsumer, })); -vi.mock('ink', async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - measureElement: vi.fn(), - }; -}); - vi.mock('./hooks/useQuotaAndFallback.js'); vi.mock('./hooks/useHistoryManager.js'); vi.mock('./hooks/useThemeCommand.js'); @@ -137,6 +140,18 @@ describe('AppContainer State Management', () => { beforeEach(() => { vi.clearAllMocks(); + // Initialize mock stdout for terminal title tests + mockStdout = { write: vi.fn() }; + + // 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!; capturedUIActions = null!; @@ -242,6 +257,9 @@ describe('AppContainer State Management', () => { // Mock Config mockConfig = makeFakeConfig(); + // Mock config's getTargetDir to return consistent workspace directory + vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace'); + // Mock LoadedSettings mockSettings = { merged: { @@ -250,6 +268,10 @@ describe('AppContainer State Management', () => { hideTips: false, showMemoryUsage: false, theme: 'default', + ui: { + showStatusInTitle: false, + hideWindowTitle: false, + }, }, } as unknown as LoadedSettings; @@ -576,6 +598,357 @@ describe('AppContainer State Management', () => { }); }); + describe('Terminal Title Update Feature', () => { + beforeEach(() => { + // Reset mock stdout for each test + mockStdout = { write: vi.fn() }; + }); + + it('should not update terminal title when showStatusInTitle is false', () => { + // Arrange: Set up mock settings with showStatusInTitle disabled + const mockSettingsWithShowStatusFalse = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + showStatusInTitle: false, + hideWindowTitle: false, + }, + }, + } as unknown as LoadedSettings; + + // Act: Render the container + const { unmount } = render( + , + ); + + // Assert: Check that no title-related writes occurred + const titleWrites = mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]2;'), + ); + expect(titleWrites).toHaveLength(0); + unmount(); + }); + + it('should not update terminal title when hideWindowTitle is true', () => { + // Arrange: Set up mock settings with hideWindowTitle enabled + const mockSettingsWithHideTitleTrue = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + showStatusInTitle: true, + hideWindowTitle: true, + }, + }, + } as unknown as LoadedSettings; + + // Act: Render the container + const { unmount } = render( + , + ); + + // Assert: Check that no title-related writes occurred + const titleWrites = mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]2;'), + ); + expect(titleWrites).toHaveLength(0); + unmount(); + }); + + it('should update terminal title with thought subject when in active state', () => { + // 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 the streaming state and thought + const thoughtSubject = 'Processing request'; + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'responding', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: { subject: thoughtSubject }, + cancelOngoingRequest: vi.fn(), + }); + + // Act: Render the container + const { unmount } = render( + , + ); + + // Assert: Check that title was updated with thought subject + const titleWrites = mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]2;'), + ); + expect(titleWrites).toHaveLength(1); + expect(titleWrites[0][0]).toBe( + `\x1b]2;${thoughtSubject.padEnd(80, ' ')}\x07`, + ); + unmount(); + }); + + it('should update terminal title with default text when in Idle state and no thought subject', () => { + // 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 the streaming state as Idle with no thought + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'idle', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: null, + cancelOngoingRequest: vi.fn(), + }); + + // Act: Render the container + const { unmount } = render( + , + ); + + // Assert: Check that title was updated with default Idle text + const titleWrites = mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]2;'), + ); + expect(titleWrites).toHaveLength(1); + expect(titleWrites[0][0]).toBe( + `\x1b]2;${'Gemini - workspace'.padEnd(80, ' ')}\x07`, + ); + unmount(); + }); + + it('should update terminal title when in WaitingForConfirmation state with thought subject', () => { + // 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 the streaming state and thought + const thoughtSubject = 'Confirm tool execution'; + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'waitingForConfirmation', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: { subject: thoughtSubject }, + cancelOngoingRequest: vi.fn(), + }); + + // Act: Render the container + const { unmount } = render( + , + ); + + // Assert: Check that title was updated with confirmation text + const titleWrites = mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]2;'), + ); + expect(titleWrites).toHaveLength(1); + expect(titleWrites[0][0]).toBe( + `\x1b]2;${thoughtSubject.padEnd(80, ' ')}\x07`, + ); + unmount(); + }); + + it('should pad title to exactly 80 characters', () => { + // 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 the streaming state and thought with a short subject + const shortTitle = 'Short'; + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'responding', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: { subject: shortTitle }, + cancelOngoingRequest: vi.fn(), + }); + + // Act: Render the container + const { unmount } = render( + , + ); + + // Assert: Check that title is padded to exactly 80 characters + const titleWrites = mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]2;'), + ); + expect(titleWrites).toHaveLength(1); + const calledWith = titleWrites[0][0]; + const expectedTitle = shortTitle.padEnd(80, ' '); + + expect(calledWith).toContain(shortTitle); + expect(calledWith).toContain('\x1b]2;'); + expect(calledWith).toContain('\x07'); + expect(calledWith).toBe('\x1b]2;' + expectedTitle + '\x07'); + unmount(); + }); + + it('should use correct ANSI escape code format', () => { + // 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 the streaming state and thought + const title = 'Test Title'; + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'responding', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: { subject: title }, + cancelOngoingRequest: vi.fn(), + }); + + // Act: Render the container + const { unmount } = render( + , + ); + + // Assert: Check that the correct ANSI escape sequence is used + const titleWrites = mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]2;'), + ); + expect(titleWrites).toHaveLength(1); + const expectedEscapeSequence = `\x1b]2;${title.padEnd(80, ' ')}\x07`; + expect(titleWrites[0][0]).toBe(expectedEscapeSequence); + unmount(); + }); + + it('should use CLI_TITLE environment variable when set', () => { + // 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 CLI_TITLE environment variable + vi.stubEnv('CLI_TITLE', 'Custom Gemini Title'); + + // Mock the streaming state as Idle with no thought + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'idle', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: null, + cancelOngoingRequest: vi.fn(), + }); + + // Act: Render the container + const { unmount } = render( + , + ); + + // Assert: Check that title was updated with CLI_TITLE value + const titleWrites = mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]2;'), + ); + expect(titleWrites).toHaveLength(1); + expect(titleWrites[0][0]).toBe( + `\x1b]2;${'Custom Gemini Title'.padEnd(80, ' ')}\x07`, + ); + unmount(); + }); + }); + describe('Terminal Height Calculation', () => { const mockedMeasureElement = measureElement as Mock; const mockedUseTerminalSize = useTerminalSize as Mock; @@ -585,6 +958,7 @@ describe('AppContainer State Management', () => { // Arrange: Simulate a small terminal and a large footer mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 5 }); mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen + mockedUseGeminiStream.mockReturnValue({ streamingState: 'idle', submitQuery: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 570f8cbf24..3c40fbad28 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -62,6 +62,8 @@ import { calculatePromptWidths } from './components/InputPrompt.js'; import { useStdin, useStdout } from 'ink'; import ansiEscapes from 'ansi-escapes'; import * as fs from 'node:fs'; +import { basename } from 'node:path'; +import { computeWindowTitle } from '../utils/windowTitle.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; @@ -196,6 +198,10 @@ export const AppContainer = (props: AppContainerProps) => { // Layout measurements const mainControlsRef = useRef(null); + const originalTitleRef = useRef( + computeWindowTitle(basename(config.getTargetDir())), + ); + const lastTitleRef = useRef(null); const staticExtraHeight = 3; useEffect(() => { @@ -976,6 +982,40 @@ Logging in with Google... Please restart Gemini CLI to continue. { isActive: showIdeRestartPrompt }, ); + // Update terminal title with Gemini CLI status and thoughts + useEffect(() => { + // Respect both showStatusInTitle and hideWindowTitle settings + if ( + !settings.merged.ui?.showStatusInTitle || + settings.merged.ui?.hideWindowTitle + ) + return; + + let title; + if (streamingState === StreamingState.Idle) { + title = originalTitleRef.current; + } else { + const statusText = thought?.subject?.replace(/[\r\n]+/g, ' ').substring(0, 80); + title = statusText || originalTitleRef.current; + } + + // 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 + if (lastTitleRef.current !== paddedTitle) { + lastTitleRef.current = paddedTitle; + stdout.write(`\x1b]2;${paddedTitle}\x07`); + } + // Note: We don't need to reset the window title on exit because Gemini CLI is already doing that elsewhere + }, [ + streamingState, + thought, + settings.merged.ui?.showStatusInTitle, + settings.merged.ui?.hideWindowTitle, + stdout, + ]); + const filteredConsoleMessages = useMemo(() => { if (config.getDebugMode()) { return consoleMessages; diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index bf1515b9c8..5e528375fd 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -18,9 +18,9 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ │ │ Hide Window Title false │ │ │ -│ Hide Tips false │ +│ Show Status in Title false │ │ │ -│ Hide Banner false │ +│ Hide Tips false │ │ │ │ ▼ │ │ │ @@ -53,9 +53,9 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ │ │ Hide Window Title false │ │ │ -│ Hide Tips false │ +│ Show Status in Title false │ │ │ -│ Hide Banner false │ +│ Hide Tips false │ │ │ │ ▼ │ │ │ @@ -88,9 +88,9 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ │ │ Hide Window Title false │ │ │ -│ Hide Tips false │ +│ Show Status in Title false │ │ │ -│ Hide Banner false │ +│ Hide Tips false │ │ │ │ ▼ │ │ │ @@ -123,9 +123,9 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ │ │ Hide Window Title false* │ │ │ -│ Hide Tips false* │ +│ Show Status in Title false │ │ │ -│ Hide Banner false │ +│ Hide Tips false* │ │ │ │ ▼ │ │ │ @@ -158,9 +158,9 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Hide Window Title false │ │ │ -│ Hide Tips false │ +│ Show Status in Title false │ │ │ -│ Hide Banner false │ +│ Hide Tips false │ │ │ │ ▼ │ │ │ @@ -193,9 +193,9 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Hide Window Title false │ │ │ -│ Hide Tips false │ +│ Show Status in Title false │ │ │ -│ Hide Banner false │ +│ Hide Tips false │ │ │ │ ▼ │ │ │ @@ -228,9 +228,9 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ │ │ Hide Window Title false │ │ │ -│ Hide Tips false │ +│ Show Status in Title false │ │ │ -│ Hide Banner false │ +│ Hide Tips false │ │ │ │ ▼ │ │ │ @@ -263,9 +263,9 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ │ │ Hide Window Title false* │ │ │ -│ Hide Tips false │ +│ Show Status in Title false │ │ │ -│ Hide Banner false │ +│ Hide Tips false │ │ │ │ ▼ │ │ │ @@ -298,9 +298,9 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ │ │ Hide Window Title false │ │ │ -│ Hide Tips false │ +│ Show Status in Title false │ │ │ -│ Hide Banner false │ +│ Hide Tips false │ │ │ │ ▼ │ │ │ @@ -333,9 +333,9 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ │ │ Hide Window Title true* │ │ │ -│ Hide Tips true* │ +│ Show Status in Title false │ │ │ -│ Hide Banner false │ +│ Hide Tips true* │ │ │ │ ▼ │ │ │ diff --git a/packages/cli/src/utils/windowTitle.test.ts b/packages/cli/src/utils/windowTitle.test.ts new file mode 100644 index 0000000000..eed30f6768 --- /dev/null +++ b/packages/cli/src/utils/windowTitle.test.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { computeWindowTitle } from './windowTitle.js'; + +describe('computeWindowTitle', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = process.env; + vi.stubEnv('CLI_TITLE', undefined); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should use default Gemini title when CLI_TITLE is not set', () => { + const result = computeWindowTitle('my-project'); + expect(result).toBe('Gemini - my-project'); + }); + + it('should use CLI_TITLE environment variable when set', () => { + vi.stubEnv('CLI_TITLE', 'Custom Title'); + const result = computeWindowTitle('my-project'); + expect(result).toBe('Custom Title'); + }); + + it('should remove control characters from title', () => { + vi.stubEnv('CLI_TITLE', 'Title\x1b[31m with \x07 control chars'); + const result = computeWindowTitle('my-project'); + // 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', () => { + const result = computeWindowTitle('project\x07name'); + expect(result).toBe('Gemini - projectname'); + }); + + it('should handle empty folder name', () => { + const result = computeWindowTitle(''); + expect(result).toBe('Gemini - '); + }); + + it('should handle folder names with spaces', () => { + const result = computeWindowTitle('my project'); + expect(result).toBe('Gemini - my project'); + }); + + it('should handle folder names with special characters', () => { + const result = computeWindowTitle('project-name_v1.0'); + expect(result).toBe('Gemini - project-name_v1.0'); + }); +}); diff --git a/packages/cli/src/utils/windowTitle.ts b/packages/cli/src/utils/windowTitle.ts new file mode 100644 index 0000000000..7ff462494c --- /dev/null +++ b/packages/cli/src/utils/windowTitle.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Computes the window title for the Gemini CLI application. + * + * @param folderName - The name of the current folder/workspace to display in the title + * @returns The computed window title, either from CLI_TITLE environment variable or the default Gemini title + */ +export function computeWindowTitle(folderName: string): string { + const title = process.env['CLI_TITLE'] || `Gemini - ${folderName}`; + + // Remove control characters that could cause issues in terminal titles + return title.replace( + // eslint-disable-next-line no-control-regex + /[\x00-\x1F\x7F]/g, + '', + ); +}