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

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([