feat(cli): implement dynamic terminal tab titles for CLI status (#16378)

This commit is contained in:
N. Taylor Mullen
2026-01-12 17:18:14 -08:00
committed by GitHub
parent c572b9e9ac
commit 2fc61685a3
10 changed files with 508 additions and 114 deletions
+153 -36
View File
@@ -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([
+28 -22
View File
@@ -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<DOMElement>(null);
// For performance profiling only
const rootUiRef = useRef<DOMElement>(null);
const originalTitleRef = useRef(
computeWindowTitle(basename(config.getTargetDir())),
);
const lastTitleRef = useRef<string | null>(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,
]);
+1
View File
@@ -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;