feat(cli): Add setting to show status(or Gemini 's thoughts) in terminal title and taskbar icon (#4386)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Fridayxiao
2025-09-28 03:48:24 +08:00
committed by GitHub
parent 0b2d79a2ea
commit 331e2ce45d
8 changed files with 536 additions and 35 deletions

View File

@@ -87,6 +87,7 @@ const MIGRATION_MAP: Record<string, string> = {
folderTrust: 'security.folderTrust.enabled',
hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge',
hideWindowTitle: 'ui.hideWindowTitle',
showStatusInTitle: 'ui.showStatusInTitle',
hideTips: 'ui.hideTips',
hideBanner: 'ui.hideBanner',
hideFooter: 'ui.hideFooter',

View File

@@ -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',

View File

@@ -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', () => {

View File

@@ -26,6 +26,17 @@ import {
} from './contexts/UIActionsContext.js';
import { useContext } from 'react';
// Mock useStdout to capture terminal title writes
let mockStdout: { write: ReturnType<typeof vi.fn> };
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
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<typeof import('ink')>();
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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithShowStatusFalse}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
// 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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithHideTitleTrue}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
// 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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithTitleEnabled}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
// 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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithTitleEnabled}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
// 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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithTitleEnabled}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
// 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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithTitleEnabled}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
// 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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithTitleEnabled}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
// 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(
<AppContainer
config={mockConfig}
settings={mockSettingsWithTitleEnabled}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
// 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(),

View File

@@ -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<DOMElement>(null);
const originalTitleRef = useRef(
computeWindowTitle(basename(config.getTargetDir())),
);
const lastTitleRef = useRef<string | null>(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;

View File

@@ -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*
│ │
│ ▼ │
│ │

View File

@@ -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');
});
});

View File

@@ -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,
'',
);
}