mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 14:40:52 -07:00
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:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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* │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
|
||||
59
packages/cli/src/utils/windowTitle.test.ts
Normal file
59
packages/cli/src/utils/windowTitle.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
22
packages/cli/src/utils/windowTitle.ts
Normal file
22
packages/cli/src/utils/windowTitle.ts
Normal 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,
|
||||
'',
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user