mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-25 20:44:46 -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',
|
folderTrust: 'security.folderTrust.enabled',
|
||||||
hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge',
|
hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge',
|
||||||
hideWindowTitle: 'ui.hideWindowTitle',
|
hideWindowTitle: 'ui.hideWindowTitle',
|
||||||
|
showStatusInTitle: 'ui.showStatusInTitle',
|
||||||
hideTips: 'ui.hideTips',
|
hideTips: 'ui.hideTips',
|
||||||
hideBanner: 'ui.hideBanner',
|
hideBanner: 'ui.hideBanner',
|
||||||
hideFooter: 'ui.hideFooter',
|
hideFooter: 'ui.hideFooter',
|
||||||
|
|||||||
@@ -248,6 +248,16 @@ const SETTINGS_SCHEMA = {
|
|||||||
description: 'Hide the window title bar',
|
description: 'Hide the window title bar',
|
||||||
showInDialog: true,
|
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: {
|
hideTips: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
label: 'Hide Tips',
|
label: 'Hide Tips',
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.j
|
|||||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||||
import { appEvents, AppEvent } from './utils/events.js';
|
import { appEvents, AppEvent } from './utils/events.js';
|
||||||
|
import { computeWindowTitle } from './utils/windowTitle.js';
|
||||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||||
|
|
||||||
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||||
@@ -454,13 +455,7 @@ export async function main() {
|
|||||||
|
|
||||||
function setWindowTitle(title: string, settings: LoadedSettings) {
|
function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||||
if (!settings.merged.ui?.hideWindowTitle) {
|
if (!settings.merged.ui?.hideWindowTitle) {
|
||||||
const windowTitle = (
|
const windowTitle = computeWindowTitle(title);
|
||||||
process.env['CLI_TITLE'] || `Gemini - ${title}`
|
|
||||||
).replace(
|
|
||||||
// eslint-disable-next-line no-control-regex
|
|
||||||
/[\x00-\x1F\x7F]/g,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
process.stdout.write(`\x1b]2;${windowTitle}\x07`);
|
process.stdout.write(`\x1b]2;${windowTitle}\x07`);
|
||||||
|
|
||||||
process.on('exit', () => {
|
process.on('exit', () => {
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ import {
|
|||||||
} from './contexts/UIActionsContext.js';
|
} from './contexts/UIActionsContext.js';
|
||||||
import { useContext } from 'react';
|
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
|
// Helper component will read the context values provided by AppContainer
|
||||||
// so we can assert against them in our tests.
|
// so we can assert against them in our tests.
|
||||||
let capturedUIState: UIState;
|
let capturedUIState: UIState;
|
||||||
@@ -40,14 +51,6 @@ vi.mock('./App.js', () => ({
|
|||||||
App: TestContextConsumer,
|
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/useQuotaAndFallback.js');
|
||||||
vi.mock('./hooks/useHistoryManager.js');
|
vi.mock('./hooks/useHistoryManager.js');
|
||||||
vi.mock('./hooks/useThemeCommand.js');
|
vi.mock('./hooks/useThemeCommand.js');
|
||||||
@@ -137,6 +140,18 @@ describe('AppContainer State Management', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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!;
|
capturedUIState = null!;
|
||||||
capturedUIActions = null!;
|
capturedUIActions = null!;
|
||||||
|
|
||||||
@@ -242,6 +257,9 @@ describe('AppContainer State Management', () => {
|
|||||||
// Mock Config
|
// Mock Config
|
||||||
mockConfig = makeFakeConfig();
|
mockConfig = makeFakeConfig();
|
||||||
|
|
||||||
|
// Mock config's getTargetDir to return consistent workspace directory
|
||||||
|
vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace');
|
||||||
|
|
||||||
// Mock LoadedSettings
|
// Mock LoadedSettings
|
||||||
mockSettings = {
|
mockSettings = {
|
||||||
merged: {
|
merged: {
|
||||||
@@ -250,6 +268,10 @@ describe('AppContainer State Management', () => {
|
|||||||
hideTips: false,
|
hideTips: false,
|
||||||
showMemoryUsage: false,
|
showMemoryUsage: false,
|
||||||
theme: 'default',
|
theme: 'default',
|
||||||
|
ui: {
|
||||||
|
showStatusInTitle: false,
|
||||||
|
hideWindowTitle: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as unknown as LoadedSettings;
|
} 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', () => {
|
describe('Terminal Height Calculation', () => {
|
||||||
const mockedMeasureElement = measureElement as Mock;
|
const mockedMeasureElement = measureElement as Mock;
|
||||||
const mockedUseTerminalSize = useTerminalSize as Mock;
|
const mockedUseTerminalSize = useTerminalSize as Mock;
|
||||||
@@ -585,6 +958,7 @@ describe('AppContainer State Management', () => {
|
|||||||
// Arrange: Simulate a small terminal and a large footer
|
// Arrange: Simulate a small terminal and a large footer
|
||||||
mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 5 });
|
mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 5 });
|
||||||
mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen
|
mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen
|
||||||
|
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
streamingState: 'idle',
|
streamingState: 'idle',
|
||||||
submitQuery: vi.fn(),
|
submitQuery: vi.fn(),
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ import { calculatePromptWidths } from './components/InputPrompt.js';
|
|||||||
import { useStdin, useStdout } from 'ink';
|
import { useStdin, useStdout } from 'ink';
|
||||||
import ansiEscapes from 'ansi-escapes';
|
import ansiEscapes from 'ansi-escapes';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
|
import { basename } from 'node:path';
|
||||||
|
import { computeWindowTitle } from '../utils/windowTitle.js';
|
||||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||||
import { useLogger } from './hooks/useLogger.js';
|
import { useLogger } from './hooks/useLogger.js';
|
||||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||||
@@ -196,6 +198,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
|
|
||||||
// Layout measurements
|
// Layout measurements
|
||||||
const mainControlsRef = useRef<DOMElement>(null);
|
const mainControlsRef = useRef<DOMElement>(null);
|
||||||
|
const originalTitleRef = useRef(
|
||||||
|
computeWindowTitle(basename(config.getTargetDir())),
|
||||||
|
);
|
||||||
|
const lastTitleRef = useRef<string | null>(null);
|
||||||
const staticExtraHeight = 3;
|
const staticExtraHeight = 3;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -976,6 +982,40 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
|||||||
{ isActive: showIdeRestartPrompt },
|
{ 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(() => {
|
const filteredConsoleMessages = useMemo(() => {
|
||||||
if (config.getDebugMode()) {
|
if (config.getDebugMode()) {
|
||||||
return consoleMessages;
|
return consoleMessages;
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
|
|||||||
│ │
|
│ │
|
||||||
│ Hide Window Title false │
|
│ 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 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 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 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 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 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 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 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 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 Window Title true* │
|
||||||
│ │
|
│ │
|
||||||
│ Hide Tips true* │
|
│ Show Status in Title false │
|
||||||
│ │
|
│ │
|
||||||
│ Hide Banner false │
|
│ Hide Tips true* │
|
||||||
│ │
|
│ │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ │
|
│ │
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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