diff --git a/docs/cli/settings.md b/docs/cli/settings.md index f0df2d48c0..ebd7c05fe0 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -42,7 +42,8 @@ they appear in the UI. | UI Label | Setting | Description | Default | | ------------------------------ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | 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` | | Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` | | Hide Banner | `ui.hideBanner` | Hide the application banner | `false` | diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 2ec3edc09b..4dc17aa548 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -180,10 +180,15 @@ their corresponding top-level category object in your `settings.json` file. - **Requires restart:** Yes - **`ui.showStatusInTitle`** (boolean): - - **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 - **Default:** `false` +- **`ui.dynamicWindowTitle`** (boolean): + - **Description:** Update the terminal window title with current status icons + (Ready: ◇, Action Required: ✋, Working: ✦) + - **Default:** `true` + - **`ui.showHomeDirectoryWarning`** (boolean): - **Description:** Show a warning when running Gemini CLI in the home directory. diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 7b29ff3d62..390c0ad403 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -376,12 +376,22 @@ const SETTINGS_SCHEMA = { }, showStatusInTitle: { type: 'boolean', - label: 'Show Status in Title', + label: 'Show Thoughts in Title', category: 'UI', requiresRestart: false, default: false, 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, }, showHomeDirectoryWarning: { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 53153c2944..7a100678df 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -75,9 +75,10 @@ import { checkForUpdates } from './ui/utils/updateCheck.js'; import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from './utils/events.js'; import { SessionSelector } from './utils/sessionUtils.js'; -import { computeWindowTitle } from './utils/windowTitle.js'; import { SettingsContext } from './ui/contexts/SettingsContext.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 { VimModeProvider } from './ui/contexts/VimModeContext.js'; @@ -711,7 +712,14 @@ export async function main() { function setWindowTitle(title: string, settings: LoadedSettings) { 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`); process.on('exit', () => { diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 939045d44a..be0ba688b6 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -250,20 +250,13 @@ describe('AppContainer State Management', () => { beforeEach(() => { vi.clearAllMocks(); + mockIdeClient.getInstance.mockReturnValue(new Promise(() => {})); + // Initialize mock stdout for terminal title tests + 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!; - capturedUIActions = null!; // **Provide a default return value for EVERY mocked hook.** mockedUseQuotaAndFallback.mockReturnValue({ @@ -413,6 +406,7 @@ describe('AppContainer State Management', () => { afterEach(() => { cleanup(); + vi.restoreAllMocks(); }); describe('Basic Rendering', () => { @@ -995,7 +989,7 @@ describe('AppContainer State Management', () => { 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 const mockSettingsWithShowStatusFalse = { ...mockSettings, @@ -1009,17 +1003,71 @@ describe('AppContainer State Management', () => { }, } 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 const { unmount } = renderAppContainer({ 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) => 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(); }); @@ -1081,14 +1129,14 @@ describe('AppContainer State Management', () => { 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) => call[0].includes('\x1b]2;'), ); expect(titleWrites).toHaveLength(1); expect(titleWrites[0][0]).toBe( - `\x1b]2;${thoughtSubject.padEnd(80, ' ')}\x07`, + `\x1b]2;${`✦ ${thoughtSubject} (workspace)`.padEnd(80, ' ')}\x07`, ); unmount(); }); @@ -1129,12 +1177,12 @@ describe('AppContainer State Management', () => { expect(titleWrites).toHaveLength(1); expect(titleWrites[0][0]).toBe( - `\x1b]2;${'Gemini - workspace'.padEnd(80, ' ')}\x07`, + `\x1b]2;${'◇ Ready (workspace)'.padEnd(80, ' ')}\x07`, ); 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 const mockSettingsWithTitleEnabled = { ...mockSettings, @@ -1151,7 +1199,7 @@ describe('AppContainer State Management', () => { // Mock the streaming state and thought const thoughtSubject = 'Confirm tool execution'; mockedUseGeminiStream.mockReturnValue({ - streamingState: 'waitingForConfirmation', + streamingState: 'waiting_for_confirmation', submitQuery: vi.fn(), initError: null, pendingHistoryItems: [], @@ -1160,8 +1208,12 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); + unmount = result.unmount; }); // Assert: Check that title was updated with confirmation text @@ -1171,9 +1223,74 @@ describe('AppContainer State Management', () => { expect(titleWrites).toHaveLength(1); expect(titleWrites[0][0]).toBe( - `\x1b]2;${thoughtSubject.padEnd(80, ' ')}\x07`, + `\x1b]2;${'✋ Action Required (workspace)'.padEnd(80, ' ')}\x07`, ); - unmount(); + 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(); + }); }); it('should pad title to exactly 80 characters', () => { @@ -1213,12 +1330,9 @@ describe('AppContainer State Management', () => { 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'); + const expectedTitle = `✦ ${shortTitle} (workspace)`.padEnd(80, ' '); + const expectedEscapeSequence = `\x1b]2;${expectedTitle}\x07`; + expect(calledWith).toBe(expectedEscapeSequence); unmount(); }); @@ -1258,20 +1372,20 @@ describe('AppContainer State Management', () => { ); 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); unmount(); }); it('should use CLI_TITLE environment variable when set', () => { - // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { + // Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix) + const mockSettingsWithTitleDisabled = { ...mockSettings, merged: { ...mockSettings.merged, ui: { ...mockSettings.merged.ui, - showStatusInTitle: true, + showStatusInTitle: false, hideWindowTitle: false, }, }, @@ -1280,9 +1394,9 @@ describe('AppContainer State Management', () => { // Mock CLI_TITLE environment variable vi.stubEnv('CLI_TITLE', 'Custom Gemini Title'); - // Mock the streaming state as Idle with no thought + // Mock the streaming state mockedUseGeminiStream.mockReturnValue({ - streamingState: 'idle', + streamingState: 'responding', submitQuery: vi.fn(), initError: null, pendingHistoryItems: [], @@ -1292,7 +1406,7 @@ describe('AppContainer State Management', () => { // Act: Render the container const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, + settings: mockSettingsWithTitleDisabled, }); // Assert: Check that title was updated with CLI_TITLE value @@ -1302,7 +1416,7 @@ describe('AppContainer State Management', () => { expect(titleWrites).toHaveLength(1); expect(titleWrites[0][0]).toBe( - `\x1b]2;${'Custom Gemini Title'.padEnd(80, ' ')}\x07`, + `\x1b]2;${'✦ Working… (Custom Gemini Title)'.padEnd(80, ' ')}\x07`, ); unmount(); }); @@ -1315,6 +1429,7 @@ describe('AppContainer State Management', () => { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); it('should set and clear the queue error message after a timeout', async () => { @@ -1483,6 +1598,7 @@ describe('AppContainer State Management', () => { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); describe('CTRL+C', () => { @@ -1620,6 +1736,7 @@ describe('AppContainer State Management', () => { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); describe.each([ diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 93fef00dbb..dbfdf58681 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -81,7 +81,7 @@ import { calculateMainAreaWidth } from './utils/ui-sizing.js'; import ansiEscapes from 'ansi-escapes'; import * as fs from 'node:fs'; 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 { useLogger } from './hooks/useLogger.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; @@ -125,8 +125,10 @@ import { useHookDisplayState } from './hooks/useHookDisplayState.js'; import { WARNING_PROMPT_DURATION_MS, QUEUE_ERROR_DISPLAY_DURATION_MS, + SHELL_ACTION_REQUIRED_TITLE_DELAY_MS, } from './constants.js'; import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js'; +import { useInactivityTimer } from './hooks/useInactivityTimer.js'; function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { return pendingHistoryItems.some((item) => { @@ -278,9 +280,6 @@ export const AppContainer = (props: AppContainerProps) => { const mainControlsRef = useRef(null); // For performance profiling only const rootUiRef = useRef(null); - const originalTitleRef = useRef( - computeWindowTitle(basename(config.getTargetDir())), - ); const lastTitleRef = useRef(null); const staticExtraHeight = 3; @@ -828,6 +827,13 @@ Logging in with Google... Restarting Gemini CLI to continue. lastOutputTimeRef.current = lastOutputTime; }, [lastOutputTime]); + const isShellAwaitingFocus = !!activePtyId && !embeddedShellFocused; + const showShellActionRequired = useInactivityTimer( + isShellAwaitingFocus, + isShellAwaitingFocus, + SHELL_ACTION_REQUIRED_TITLE_DELAY_MS, + ); + // Auto-accept indicator const showAutoAcceptIndicator = useAutoAcceptIndicator({ config, @@ -1338,25 +1344,20 @@ Logging in with Google... Restarting Gemini CLI to continue. // 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; + // Respect hideWindowTitle settings + if (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, ' '); + const paddedTitle = computeTerminalTitle({ + streamingState, + thoughtSubject: thought?.subject, + isConfirming: + !!shellConfirmationRequest || + !!confirmationRequest || + showShellActionRequired, + folderName: basename(config.getTargetDir()), + showThoughts: !!settings.merged.ui?.showStatusInTitle, + useDynamicTitle: settings.merged.ui?.dynamicWindowTitle ?? true, + }); // Only update the title if it's different from the last value we set if (lastTitleRef.current !== paddedTitle) { @@ -1367,8 +1368,13 @@ Logging in with Google... Restarting Gemini CLI to continue. }, [ streamingState, thought, + shellConfirmationRequest, + confirmationRequest, + showShellActionRequired, settings.merged.ui?.showStatusInTitle, + settings.merged.ui?.dynamicWindowTitle, settings.merged.ui?.hideWindowTitle, + config, stdout, ]); diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index e27f3c3bbe..b211c9ce8d 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -31,3 +31,4 @@ export const MAX_MCP_RESOURCES_TO_SHOW = 10; export const WARNING_PROMPT_DURATION_MS = 1000; export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; +export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000; diff --git a/packages/cli/src/utils/windowTitle.test.ts b/packages/cli/src/utils/windowTitle.test.ts index eed30f6768..c25f151f83 100644 --- a/packages/cli/src/utils/windowTitle.test.ts +++ b/packages/cli/src/utils/windowTitle.test.ts @@ -4,56 +4,210 @@ * 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); - }); +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { computeTerminalTitle } from './windowTitle.js'; +import { StreamingState } from '../ui/types.js'; +describe('computeTerminalTitle', () => { afterEach(() => { - process.env = originalEnv; + vi.unstubAllEnvs(); }); - it('should use default Gemini title when CLI_TITLE is not set', () => { - const result = computeWindowTitle('my-project'); - expect(result).toBe('Gemini - my-project'); + it.each([ + { + 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', () => { - vi.stubEnv('CLI_TITLE', 'Custom Title'); - const result = computeWindowTitle('my-project'); - expect(result).toBe('Custom Title'); + it('should return active state title with thought subject and NO suffix when thoughts are very long', () => { + const longThought = 'A'.repeat(70); + const title = computeTerminalTitle({ + streamingState: StreamingState.Responding, + thoughtSubject: longThought, + isConfirming: false, + folderName: 'my-project', + showThoughts: true, + useDynamicTitle: true, + }); + + expect(title).not.toContain('(my-project)'); + expect(title).toContain('✦ AAAAAAAAAAAAAAAA'); + expect(title.length).toBe(80); }); - 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 truncate long thought subjects when thoughts are enabled', () => { + const longThought = 'A'.repeat(100); + const title = computeTerminalTitle({ + streamingState: StreamingState.Responding, + thoughtSubject: longThought, + isConfirming: false, + folderName: 'my-project', + showThoughts: true, + useDynamicTitle: true, + }); + + expect(title.length).toBe(80); + expect(title).toContain('…'); + expect(title.trimEnd().length).toBe(80); }); - it('should handle folder names with control characters', () => { - const result = computeWindowTitle('project\x07name'); - expect(result).toBe('Gemini - projectname'); + it('should strip control characters from the title', () => { + const title = computeTerminalTitle({ + streamingState: StreamingState.Responding, + thoughtSubject: 'BadTitle\x00 With\x07Control\x1BChars', + isConfirming: false, + folderName: 'my-project', + showThoughts: true, + useDynamicTitle: true, + }); + + expect(title).toContain('BadTitle WithControlChars'); + expect(title).not.toContain('\x00'); + expect(title).not.toContain('\x07'); + expect(title).not.toContain('\x1B'); + expect(title.length).toBe(80); }); - it('should handle empty folder name', () => { - const result = computeWindowTitle(''); - expect(result).toBe('Gemini - '); + 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('should handle folder names with spaces', () => { - const result = computeWindowTitle('my project'); - expect(result).toBe('Gemini - my project'); - }); + 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); + } - it('should handle folder names with special characters', () => { - const result = computeWindowTitle('project-name_v1.0'); - expect(result).toBe('Gemini - project-name_v1.0'); + 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('…)'); }); }); diff --git a/packages/cli/src/utils/windowTitle.ts b/packages/cli/src/utils/windowTitle.ts index 7ff462494c..3378119915 100644 --- a/packages/cli/src/utils/windowTitle.ts +++ b/packages/cli/src/utils/windowTitle.ts @@ -4,19 +4,104 @@ * 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 - * @returns The computed window title, either from CLI_TITLE environment variable or the default Gemini title + * @param options - The current state of the CLI and environment context + * @returns A formatted string padded to 80 characters for the terminal title */ -export function computeWindowTitle(folderName: string): string { - const title = process.env['CLI_TITLE'] || `Gemini - ${folderName}`; +export function computeTerminalTitle({ + 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 - return title.replace( - // eslint-disable-next-line no-control-regex - /[\x00-\x1F\x7F]/g, - '', - ); + // eslint-disable-next-line no-control-regex + 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); } diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 33a659a822..2f06d0c954 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -188,12 +188,19 @@ "type": "boolean" }, "showStatusInTitle": { - "title": "Show Status in Title", - "description": "Show Gemini CLI status and thoughts in the terminal window title", - "markdownDescription": "Show Gemini CLI status and thoughts in the terminal window title\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "title": "Show Thoughts in Title", + "description": "Show Gemini CLI model thoughts in the terminal window title during the working phase", + "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, "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": { "title": "Show Home Directory Warning", "description": "Show a warning when running Gemini CLI in the home directory.",