mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(cli): implement dynamic terminal tab titles for CLI status (#16378)
This commit is contained in:
@@ -42,7 +42,8 @@ they appear in the UI.
|
|||||||
| UI Label | Setting | Description | Default |
|
| UI Label | Setting | Description | Default |
|
||||||
| ------------------------------ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
| ------------------------------ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
||||||
| Show Status in Title | `ui.showStatusInTitle` | Show Gemini CLI status and thoughts in the terminal window title | `false` |
|
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
||||||
|
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||||
|
|||||||
@@ -180,10 +180,15 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
- **Requires restart:** Yes
|
- **Requires restart:** Yes
|
||||||
|
|
||||||
- **`ui.showStatusInTitle`** (boolean):
|
- **`ui.showStatusInTitle`** (boolean):
|
||||||
- **Description:** Show Gemini CLI status and thoughts in the terminal window
|
- **Description:** Show Gemini CLI model thoughts in the terminal window title
|
||||||
title
|
during the working phase
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
|
||||||
|
- **`ui.dynamicWindowTitle`** (boolean):
|
||||||
|
- **Description:** Update the terminal window title with current status icons
|
||||||
|
(Ready: ◇, Action Required: ✋, Working: ✦)
|
||||||
|
- **Default:** `true`
|
||||||
|
|
||||||
- **`ui.showHomeDirectoryWarning`** (boolean):
|
- **`ui.showHomeDirectoryWarning`** (boolean):
|
||||||
- **Description:** Show a warning when running Gemini CLI in the home
|
- **Description:** Show a warning when running Gemini CLI in the home
|
||||||
directory.
|
directory.
|
||||||
|
|||||||
@@ -376,12 +376,22 @@ const SETTINGS_SCHEMA = {
|
|||||||
},
|
},
|
||||||
showStatusInTitle: {
|
showStatusInTitle: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
label: 'Show Status in Title',
|
label: 'Show Thoughts in Title',
|
||||||
category: 'UI',
|
category: 'UI',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
default: false,
|
default: false,
|
||||||
description:
|
description:
|
||||||
'Show Gemini CLI status and thoughts in the terminal window title',
|
'Show Gemini CLI model thoughts in the terminal window title during the working phase',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
dynamicWindowTitle: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Dynamic Window Title',
|
||||||
|
category: 'UI',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: true,
|
||||||
|
description:
|
||||||
|
'Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦)',
|
||||||
showInDialog: true,
|
showInDialog: true,
|
||||||
},
|
},
|
||||||
showHomeDirectoryWarning: {
|
showHomeDirectoryWarning: {
|
||||||
|
|||||||
@@ -75,9 +75,10 @@ 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 { SessionSelector } from './utils/sessionUtils.js';
|
import { SessionSelector } from './utils/sessionUtils.js';
|
||||||
import { computeWindowTitle } from './utils/windowTitle.js';
|
|
||||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||||
import { MouseProvider } from './ui/contexts/MouseContext.js';
|
import { MouseProvider } from './ui/contexts/MouseContext.js';
|
||||||
|
import { StreamingState } from './ui/types.js';
|
||||||
|
import { computeTerminalTitle } from './utils/windowTitle.js';
|
||||||
|
|
||||||
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||||
@@ -711,7 +712,14 @@ 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 = computeWindowTitle(title);
|
// Initial state before React loop starts
|
||||||
|
const windowTitle = computeTerminalTitle({
|
||||||
|
streamingState: StreamingState.Idle,
|
||||||
|
isConfirming: false,
|
||||||
|
folderName: title,
|
||||||
|
showThoughts: !!settings.merged.ui?.showStatusInTitle,
|
||||||
|
useDynamicTitle: settings.merged.ui?.dynamicWindowTitle ?? true,
|
||||||
|
});
|
||||||
writeToStdout(`\x1b]2;${windowTitle}\x07`);
|
writeToStdout(`\x1b]2;${windowTitle}\x07`);
|
||||||
|
|
||||||
process.on('exit', () => {
|
process.on('exit', () => {
|
||||||
|
|||||||
@@ -250,20 +250,13 @@ describe('AppContainer State Management', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockIdeClient.getInstance.mockReturnValue(new Promise(() => {}));
|
||||||
|
|
||||||
// Initialize mock stdout for terminal title tests
|
// Initialize mock stdout for terminal title tests
|
||||||
|
|
||||||
mocks.mockStdout.write.mockClear();
|
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!;
|
capturedUIState = null!;
|
||||||
capturedUIActions = null!;
|
|
||||||
|
|
||||||
// **Provide a default return value for EVERY mocked hook.**
|
// **Provide a default return value for EVERY mocked hook.**
|
||||||
mockedUseQuotaAndFallback.mockReturnValue({
|
mockedUseQuotaAndFallback.mockReturnValue({
|
||||||
@@ -413,6 +406,7 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Basic Rendering', () => {
|
describe('Basic Rendering', () => {
|
||||||
@@ -995,7 +989,7 @@ describe('AppContainer State Management', () => {
|
|||||||
expect(stdout).toBe(mocks.mockStdout);
|
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
|
// Arrange: Set up mock settings with showStatusInTitle disabled
|
||||||
const mockSettingsWithShowStatusFalse = {
|
const mockSettingsWithShowStatusFalse = {
|
||||||
...mockSettings,
|
...mockSettings,
|
||||||
@@ -1009,17 +1003,71 @@ describe('AppContainer State Management', () => {
|
|||||||
},
|
},
|
||||||
} as unknown as LoadedSettings;
|
} 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
|
// Act: Render the container
|
||||||
const { unmount } = renderAppContainer({
|
const { unmount } = renderAppContainer({
|
||||||
settings: mockSettingsWithShowStatusFalse,
|
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) =>
|
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
|
||||||
call[0].includes('\x1b]2;'),
|
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();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1081,14 +1129,14 @@ describe('AppContainer State Management', () => {
|
|||||||
settings: mockSettingsWithTitleEnabled,
|
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) =>
|
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
|
||||||
call[0].includes('\x1b]2;'),
|
call[0].includes('\x1b]2;'),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(titleWrites).toHaveLength(1);
|
expect(titleWrites).toHaveLength(1);
|
||||||
expect(titleWrites[0][0]).toBe(
|
expect(titleWrites[0][0]).toBe(
|
||||||
`\x1b]2;${thoughtSubject.padEnd(80, ' ')}\x07`,
|
`\x1b]2;${`✦ ${thoughtSubject} (workspace)`.padEnd(80, ' ')}\x07`,
|
||||||
);
|
);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -1129,12 +1177,12 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
expect(titleWrites).toHaveLength(1);
|
expect(titleWrites).toHaveLength(1);
|
||||||
expect(titleWrites[0][0]).toBe(
|
expect(titleWrites[0][0]).toBe(
|
||||||
`\x1b]2;${'Gemini - workspace'.padEnd(80, ' ')}\x07`,
|
`\x1b]2;${'◇ Ready (workspace)'.padEnd(80, ' ')}\x07`,
|
||||||
);
|
);
|
||||||
unmount();
|
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
|
// Arrange: Set up mock settings with showStatusInTitle enabled
|
||||||
const mockSettingsWithTitleEnabled = {
|
const mockSettingsWithTitleEnabled = {
|
||||||
...mockSettings,
|
...mockSettings,
|
||||||
@@ -1151,7 +1199,7 @@ describe('AppContainer State Management', () => {
|
|||||||
// Mock the streaming state and thought
|
// Mock the streaming state and thought
|
||||||
const thoughtSubject = 'Confirm tool execution';
|
const thoughtSubject = 'Confirm tool execution';
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
streamingState: 'waitingForConfirmation',
|
streamingState: 'waiting_for_confirmation',
|
||||||
submitQuery: vi.fn(),
|
submitQuery: vi.fn(),
|
||||||
initError: null,
|
initError: null,
|
||||||
pendingHistoryItems: [],
|
pendingHistoryItems: [],
|
||||||
@@ -1160,9 +1208,13 @@ describe('AppContainer State Management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Act: Render the container
|
// Act: Render the container
|
||||||
const { unmount } = renderAppContainer({
|
let unmount: () => void;
|
||||||
|
await act(async () => {
|
||||||
|
const result = renderAppContainer({
|
||||||
settings: mockSettingsWithTitleEnabled,
|
settings: mockSettingsWithTitleEnabled,
|
||||||
});
|
});
|
||||||
|
unmount = result.unmount;
|
||||||
|
});
|
||||||
|
|
||||||
// Assert: Check that title was updated with confirmation text
|
// Assert: Check that title was updated with confirmation text
|
||||||
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
|
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
|
||||||
@@ -1171,10 +1223,75 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
expect(titleWrites).toHaveLength(1);
|
expect(titleWrites).toHaveLength(1);
|
||||||
expect(titleWrites[0][0]).toBe(
|
expect(titleWrites[0][0]).toBe(
|
||||||
`\x1b]2;${thoughtSubject.padEnd(80, ' ')}\x07`,
|
`\x1b]2;${'✋ Action Required (workspace)'.padEnd(80, ' ')}\x07`,
|
||||||
);
|
);
|
||||||
|
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();
|
unmount();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should pad title to exactly 80 characters', () => {
|
it('should pad title to exactly 80 characters', () => {
|
||||||
// Arrange: Set up mock settings with showStatusInTitle enabled
|
// Arrange: Set up mock settings with showStatusInTitle enabled
|
||||||
@@ -1213,12 +1330,9 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
expect(titleWrites).toHaveLength(1);
|
expect(titleWrites).toHaveLength(1);
|
||||||
const calledWith = titleWrites[0][0];
|
const calledWith = titleWrites[0][0];
|
||||||
const expectedTitle = shortTitle.padEnd(80, ' ');
|
const expectedTitle = `✦ ${shortTitle} (workspace)`.padEnd(80, ' ');
|
||||||
|
const expectedEscapeSequence = `\x1b]2;${expectedTitle}\x07`;
|
||||||
expect(calledWith).toContain(shortTitle);
|
expect(calledWith).toBe(expectedEscapeSequence);
|
||||||
expect(calledWith).toContain('\x1b]2;');
|
|
||||||
expect(calledWith).toContain('\x07');
|
|
||||||
expect(calledWith).toBe('\x1b]2;' + expectedTitle + '\x07');
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1258,20 +1372,20 @@ describe('AppContainer State Management', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(titleWrites).toHaveLength(1);
|
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);
|
expect(titleWrites[0][0]).toBe(expectedEscapeSequence);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use CLI_TITLE environment variable when set', () => {
|
it('should use CLI_TITLE environment variable when set', () => {
|
||||||
// Arrange: Set up mock settings with showStatusInTitle enabled
|
// Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix)
|
||||||
const mockSettingsWithTitleEnabled = {
|
const mockSettingsWithTitleDisabled = {
|
||||||
...mockSettings,
|
...mockSettings,
|
||||||
merged: {
|
merged: {
|
||||||
...mockSettings.merged,
|
...mockSettings.merged,
|
||||||
ui: {
|
ui: {
|
||||||
...mockSettings.merged.ui,
|
...mockSettings.merged.ui,
|
||||||
showStatusInTitle: true,
|
showStatusInTitle: false,
|
||||||
hideWindowTitle: false,
|
hideWindowTitle: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1280,9 +1394,9 @@ describe('AppContainer State Management', () => {
|
|||||||
// Mock CLI_TITLE environment variable
|
// Mock CLI_TITLE environment variable
|
||||||
vi.stubEnv('CLI_TITLE', 'Custom Gemini Title');
|
vi.stubEnv('CLI_TITLE', 'Custom Gemini Title');
|
||||||
|
|
||||||
// Mock the streaming state as Idle with no thought
|
// Mock the streaming state
|
||||||
mockedUseGeminiStream.mockReturnValue({
|
mockedUseGeminiStream.mockReturnValue({
|
||||||
streamingState: 'idle',
|
streamingState: 'responding',
|
||||||
submitQuery: vi.fn(),
|
submitQuery: vi.fn(),
|
||||||
initError: null,
|
initError: null,
|
||||||
pendingHistoryItems: [],
|
pendingHistoryItems: [],
|
||||||
@@ -1292,7 +1406,7 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
// Act: Render the container
|
// Act: Render the container
|
||||||
const { unmount } = renderAppContainer({
|
const { unmount } = renderAppContainer({
|
||||||
settings: mockSettingsWithTitleEnabled,
|
settings: mockSettingsWithTitleDisabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assert: Check that title was updated with CLI_TITLE value
|
// Assert: Check that title was updated with CLI_TITLE value
|
||||||
@@ -1302,7 +1416,7 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
expect(titleWrites).toHaveLength(1);
|
expect(titleWrites).toHaveLength(1);
|
||||||
expect(titleWrites[0][0]).toBe(
|
expect(titleWrites[0][0]).toBe(
|
||||||
`\x1b]2;${'Custom Gemini Title'.padEnd(80, ' ')}\x07`,
|
`\x1b]2;${'✦ Working… (Custom Gemini Title)'.padEnd(80, ' ')}\x07`,
|
||||||
);
|
);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -1315,6 +1429,7 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set and clear the queue error message after a timeout', async () => {
|
it('should set and clear the queue error message after a timeout', async () => {
|
||||||
@@ -1483,6 +1598,7 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('CTRL+C', () => {
|
describe('CTRL+C', () => {
|
||||||
@@ -1620,6 +1736,7 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.each([
|
describe.each([
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ import { calculateMainAreaWidth } from './utils/ui-sizing.js';
|
|||||||
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 { 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 { 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';
|
||||||
@@ -125,8 +125,10 @@ import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
|||||||
import {
|
import {
|
||||||
WARNING_PROMPT_DURATION_MS,
|
WARNING_PROMPT_DURATION_MS,
|
||||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||||
|
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||||
|
import { useInactivityTimer } from './hooks/useInactivityTimer.js';
|
||||||
|
|
||||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||||
return pendingHistoryItems.some((item) => {
|
return pendingHistoryItems.some((item) => {
|
||||||
@@ -278,9 +280,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const mainControlsRef = useRef<DOMElement>(null);
|
const mainControlsRef = useRef<DOMElement>(null);
|
||||||
// For performance profiling only
|
// For performance profiling only
|
||||||
const rootUiRef = useRef<DOMElement>(null);
|
const rootUiRef = useRef<DOMElement>(null);
|
||||||
const originalTitleRef = useRef(
|
|
||||||
computeWindowTitle(basename(config.getTargetDir())),
|
|
||||||
);
|
|
||||||
const lastTitleRef = useRef<string | null>(null);
|
const lastTitleRef = useRef<string | null>(null);
|
||||||
const staticExtraHeight = 3;
|
const staticExtraHeight = 3;
|
||||||
|
|
||||||
@@ -828,6 +827,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
lastOutputTimeRef.current = lastOutputTime;
|
lastOutputTimeRef.current = lastOutputTime;
|
||||||
}, [lastOutputTime]);
|
}, [lastOutputTime]);
|
||||||
|
|
||||||
|
const isShellAwaitingFocus = !!activePtyId && !embeddedShellFocused;
|
||||||
|
const showShellActionRequired = useInactivityTimer(
|
||||||
|
isShellAwaitingFocus,
|
||||||
|
isShellAwaitingFocus,
|
||||||
|
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
|
||||||
|
);
|
||||||
|
|
||||||
// Auto-accept indicator
|
// Auto-accept indicator
|
||||||
const showAutoAcceptIndicator = useAutoAcceptIndicator({
|
const showAutoAcceptIndicator = useAutoAcceptIndicator({
|
||||||
config,
|
config,
|
||||||
@@ -1338,25 +1344,20 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
|
|
||||||
// Update terminal title with Gemini CLI status and thoughts
|
// Update terminal title with Gemini CLI status and thoughts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Respect both showStatusInTitle and hideWindowTitle settings
|
// Respect hideWindowTitle settings
|
||||||
if (
|
if (settings.merged.ui?.hideWindowTitle) return;
|
||||||
!settings.merged.ui?.showStatusInTitle ||
|
|
||||||
settings.merged.ui?.hideWindowTitle
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
let title;
|
const paddedTitle = computeTerminalTitle({
|
||||||
if (streamingState === StreamingState.Idle) {
|
streamingState,
|
||||||
title = originalTitleRef.current;
|
thoughtSubject: thought?.subject,
|
||||||
} else {
|
isConfirming:
|
||||||
const statusText = thought?.subject
|
!!shellConfirmationRequest ||
|
||||||
?.replace(/[\r\n]+/g, ' ')
|
!!confirmationRequest ||
|
||||||
.substring(0, 80);
|
showShellActionRequired,
|
||||||
title = statusText || originalTitleRef.current;
|
folderName: basename(config.getTargetDir()),
|
||||||
}
|
showThoughts: !!settings.merged.ui?.showStatusInTitle,
|
||||||
|
useDynamicTitle: settings.merged.ui?.dynamicWindowTitle ?? true,
|
||||||
// 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
|
// Only update the title if it's different from the last value we set
|
||||||
if (lastTitleRef.current !== paddedTitle) {
|
if (lastTitleRef.current !== paddedTitle) {
|
||||||
@@ -1367,8 +1368,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
}, [
|
}, [
|
||||||
streamingState,
|
streamingState,
|
||||||
thought,
|
thought,
|
||||||
|
shellConfirmationRequest,
|
||||||
|
confirmationRequest,
|
||||||
|
showShellActionRequired,
|
||||||
settings.merged.ui?.showStatusInTitle,
|
settings.merged.ui?.showStatusInTitle,
|
||||||
|
settings.merged.ui?.dynamicWindowTitle,
|
||||||
settings.merged.ui?.hideWindowTitle,
|
settings.merged.ui?.hideWindowTitle,
|
||||||
|
config,
|
||||||
stdout,
|
stdout,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,4 @@ export const MAX_MCP_RESOURCES_TO_SHOW = 10;
|
|||||||
|
|
||||||
export const WARNING_PROMPT_DURATION_MS = 1000;
|
export const WARNING_PROMPT_DURATION_MS = 1000;
|
||||||
export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
|
export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
|
||||||
|
export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000;
|
||||||
|
|||||||
@@ -4,56 +4,210 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { computeWindowTitle } from './windowTitle.js';
|
import { computeTerminalTitle } from './windowTitle.js';
|
||||||
|
import { StreamingState } from '../ui/types.js';
|
||||||
describe('computeWindowTitle', () => {
|
|
||||||
let originalEnv: NodeJS.ProcessEnv;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
originalEnv = process.env;
|
|
||||||
vi.stubEnv('CLI_TITLE', undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
describe('computeTerminalTitle', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env = originalEnv;
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use default Gemini title when CLI_TITLE is not set', () => {
|
it.each([
|
||||||
const result = computeWindowTitle('my-project');
|
{
|
||||||
expect(result).toBe('Gemini - my-project');
|
description: 'idle state title with folder name',
|
||||||
|
args: {
|
||||||
|
streamingState: StreamingState.Idle,
|
||||||
|
isConfirming: false,
|
||||||
|
folderName: 'my-project',
|
||||||
|
showThoughts: false,
|
||||||
|
useDynamicTitle: true,
|
||||||
|
},
|
||||||
|
expected: '◇ Ready (my-project)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'legacy title when useDynamicTitle is false',
|
||||||
|
args: {
|
||||||
|
streamingState: StreamingState.Responding,
|
||||||
|
isConfirming: false,
|
||||||
|
folderName: 'my-project',
|
||||||
|
showThoughts: true,
|
||||||
|
useDynamicTitle: false,
|
||||||
|
},
|
||||||
|
expected: 'Gemini CLI (my-project)'.padEnd(80, ' '),
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'active state title with "Working…" when thoughts are disabled',
|
||||||
|
args: {
|
||||||
|
streamingState: StreamingState.Responding,
|
||||||
|
thoughtSubject: 'Reading files',
|
||||||
|
isConfirming: false,
|
||||||
|
folderName: 'my-project',
|
||||||
|
showThoughts: false,
|
||||||
|
useDynamicTitle: true,
|
||||||
|
},
|
||||||
|
expected: '✦ Working… (my-project)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'active state title with thought subject and suffix when thoughts are short enough',
|
||||||
|
args: {
|
||||||
|
streamingState: StreamingState.Responding,
|
||||||
|
thoughtSubject: 'Short thought',
|
||||||
|
isConfirming: false,
|
||||||
|
folderName: 'my-project',
|
||||||
|
showThoughts: true,
|
||||||
|
useDynamicTitle: true,
|
||||||
|
},
|
||||||
|
expected: '✦ Short thought (my-project)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
'fallback active title with suffix if no thought subject is provided even when thoughts are enabled',
|
||||||
|
args: {
|
||||||
|
streamingState: StreamingState.Responding,
|
||||||
|
thoughtSubject: undefined,
|
||||||
|
isConfirming: false,
|
||||||
|
folderName: 'my-project',
|
||||||
|
showThoughts: true,
|
||||||
|
useDynamicTitle: true,
|
||||||
|
},
|
||||||
|
expected: '✦ Working… (my-project)'.padEnd(80, ' '),
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'action required state when confirming',
|
||||||
|
args: {
|
||||||
|
streamingState: StreamingState.Idle,
|
||||||
|
isConfirming: true,
|
||||||
|
folderName: 'my-project',
|
||||||
|
showThoughts: false,
|
||||||
|
useDynamicTitle: true,
|
||||||
|
},
|
||||||
|
expected: '✋ Action Required (my-project)',
|
||||||
|
},
|
||||||
|
])('should return $description', ({ args, expected, exact }) => {
|
||||||
|
const title = computeTerminalTitle(args);
|
||||||
|
if (exact) {
|
||||||
|
expect(title).toBe(expected);
|
||||||
|
} else {
|
||||||
|
expect(title).toContain(expected);
|
||||||
|
}
|
||||||
|
expect(title.length).toBe(80);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use CLI_TITLE environment variable when set', () => {
|
it('should return active state title with thought subject and NO suffix when thoughts are very long', () => {
|
||||||
vi.stubEnv('CLI_TITLE', 'Custom Title');
|
const longThought = 'A'.repeat(70);
|
||||||
const result = computeWindowTitle('my-project');
|
const title = computeTerminalTitle({
|
||||||
expect(result).toBe('Custom Title');
|
streamingState: StreamingState.Responding,
|
||||||
|
thoughtSubject: longThought,
|
||||||
|
isConfirming: false,
|
||||||
|
folderName: 'my-project',
|
||||||
|
showThoughts: true,
|
||||||
|
useDynamicTitle: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove control characters from title', () => {
|
expect(title).not.toContain('(my-project)');
|
||||||
vi.stubEnv('CLI_TITLE', 'Title\x1b[31m with \x07 control chars');
|
expect(title).toContain('✦ AAAAAAAAAAAAAAAA');
|
||||||
const result = computeWindowTitle('my-project');
|
expect(title.length).toBe(80);
|
||||||
// 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', () => {
|
it('should truncate long thought subjects when thoughts are enabled', () => {
|
||||||
const result = computeWindowTitle('project\x07name');
|
const longThought = 'A'.repeat(100);
|
||||||
expect(result).toBe('Gemini - projectname');
|
const title = computeTerminalTitle({
|
||||||
|
streamingState: StreamingState.Responding,
|
||||||
|
thoughtSubject: longThought,
|
||||||
|
isConfirming: false,
|
||||||
|
folderName: 'my-project',
|
||||||
|
showThoughts: true,
|
||||||
|
useDynamicTitle: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty folder name', () => {
|
expect(title.length).toBe(80);
|
||||||
const result = computeWindowTitle('');
|
expect(title).toContain('…');
|
||||||
expect(result).toBe('Gemini - ');
|
expect(title.trimEnd().length).toBe(80);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle folder names with spaces', () => {
|
it('should strip control characters from the title', () => {
|
||||||
const result = computeWindowTitle('my project');
|
const title = computeTerminalTitle({
|
||||||
expect(result).toBe('Gemini - my project');
|
streamingState: StreamingState.Responding,
|
||||||
|
thoughtSubject: 'BadTitle\x00 With\x07Control\x1BChars',
|
||||||
|
isConfirming: false,
|
||||||
|
folderName: 'my-project',
|
||||||
|
showThoughts: true,
|
||||||
|
useDynamicTitle: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle folder names with special characters', () => {
|
expect(title).toContain('BadTitle WithControlChars');
|
||||||
const result = computeWindowTitle('project-name_v1.0');
|
expect(title).not.toContain('\x00');
|
||||||
expect(result).toBe('Gemini - project-name_v1.0');
|
expect(title).not.toContain('\x07');
|
||||||
|
expect(title).not.toContain('\x1B');
|
||||||
|
expect(title.length).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize CLI_TITLE environment variable over folder name when thoughts are disabled', () => {
|
||||||
|
vi.stubEnv('CLI_TITLE', 'EnvOverride');
|
||||||
|
|
||||||
|
const title = computeTerminalTitle({
|
||||||
|
streamingState: StreamingState.Idle,
|
||||||
|
isConfirming: false,
|
||||||
|
folderName: 'my-project',
|
||||||
|
showThoughts: false,
|
||||||
|
useDynamicTitle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(title).toContain('◇ Ready (EnvOverride)');
|
||||||
|
expect(title).not.toContain('my-project');
|
||||||
|
expect(title.length).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'folder name',
|
||||||
|
folderName: 'A'.repeat(100),
|
||||||
|
expected: '◇ Ready (AAAAA',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CLI_TITLE',
|
||||||
|
folderName: 'my-project',
|
||||||
|
envTitle: 'B'.repeat(100),
|
||||||
|
expected: '◇ Ready (BBBBB',
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should truncate very long $name to fit within 80 characters',
|
||||||
|
({ folderName, envTitle, expected }) => {
|
||||||
|
if (envTitle) {
|
||||||
|
vi.stubEnv('CLI_TITLE', envTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = computeTerminalTitle({
|
||||||
|
streamingState: StreamingState.Idle,
|
||||||
|
isConfirming: false,
|
||||||
|
folderName,
|
||||||
|
showThoughts: false,
|
||||||
|
useDynamicTitle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(title.length).toBe(80);
|
||||||
|
expect(title).toContain(expected);
|
||||||
|
expect(title).toContain('…)');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should truncate long folder name when useDynamicTitle is false', () => {
|
||||||
|
const longFolderName = 'C'.repeat(100);
|
||||||
|
const title = computeTerminalTitle({
|
||||||
|
streamingState: StreamingState.Responding,
|
||||||
|
isConfirming: false,
|
||||||
|
folderName: longFolderName,
|
||||||
|
showThoughts: true,
|
||||||
|
useDynamicTitle: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(title.length).toBe(80);
|
||||||
|
expect(title).toContain('Gemini CLI (CCCCC');
|
||||||
|
expect(title).toContain('…)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,19 +4,104 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { StreamingState } from '../ui/types.js';
|
||||||
|
|
||||||
|
export interface TerminalTitleOptions {
|
||||||
|
streamingState: StreamingState;
|
||||||
|
thoughtSubject?: string;
|
||||||
|
isConfirming: boolean;
|
||||||
|
folderName: string;
|
||||||
|
showThoughts: boolean;
|
||||||
|
useDynamicTitle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, maxLen: number): string {
|
||||||
|
if (text.length <= maxLen) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return text.substring(0, maxLen - 1) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the window title for the Gemini CLI application.
|
* Computes the dynamic terminal window title based on the current CLI state.
|
||||||
*
|
*
|
||||||
* @param folderName - The name of the current folder/workspace to display in the title
|
* @param options - The current state of the CLI and environment context
|
||||||
* @returns The computed window title, either from CLI_TITLE environment variable or the default Gemini title
|
* @returns A formatted string padded to 80 characters for the terminal title
|
||||||
*/
|
*/
|
||||||
export function computeWindowTitle(folderName: string): string {
|
export function computeTerminalTitle({
|
||||||
const title = process.env['CLI_TITLE'] || `Gemini - ${folderName}`;
|
streamingState,
|
||||||
|
thoughtSubject,
|
||||||
|
isConfirming,
|
||||||
|
folderName,
|
||||||
|
showThoughts,
|
||||||
|
useDynamicTitle,
|
||||||
|
}: TerminalTitleOptions): string {
|
||||||
|
const MAX_LEN = 80;
|
||||||
|
|
||||||
|
// Use CLI_TITLE env var if available, otherwise use the provided folder name
|
||||||
|
let displayContext = process.env['CLI_TITLE'] || folderName;
|
||||||
|
|
||||||
|
if (!useDynamicTitle) {
|
||||||
|
const base = 'Gemini CLI ';
|
||||||
|
// Max context length is 80 - base.length - 2 (for brackets)
|
||||||
|
const maxContextLen = MAX_LEN - base.length - 2;
|
||||||
|
displayContext = truncate(displayContext, maxContextLen);
|
||||||
|
return `${base}(${displayContext})`.padEnd(MAX_LEN, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-calculate suffix but keep it flexible
|
||||||
|
const getSuffix = (context: string) => ` (${context})`;
|
||||||
|
|
||||||
|
let title;
|
||||||
|
if (
|
||||||
|
isConfirming ||
|
||||||
|
streamingState === StreamingState.WaitingForConfirmation
|
||||||
|
) {
|
||||||
|
const base = '✋ Action Required';
|
||||||
|
// Max context length is 80 - base.length - 3 (for ' (' and ')')
|
||||||
|
const maxContextLen = MAX_LEN - base.length - 3;
|
||||||
|
const context = truncate(displayContext, maxContextLen);
|
||||||
|
title = `${base}${getSuffix(context)}`;
|
||||||
|
} else if (streamingState === StreamingState.Idle) {
|
||||||
|
const base = '◇ Ready';
|
||||||
|
// Max context length is 80 - base.length - 3 (for ' (' and ')')
|
||||||
|
const maxContextLen = MAX_LEN - base.length - 3;
|
||||||
|
const context = truncate(displayContext, maxContextLen);
|
||||||
|
title = `${base}${getSuffix(context)}`;
|
||||||
|
} else {
|
||||||
|
// Active/Working state
|
||||||
|
const cleanSubject =
|
||||||
|
showThoughts && thoughtSubject?.replace(/[\r\n]+/g, ' ').trim();
|
||||||
|
|
||||||
|
// If we have a thought subject and it's too long to fit with the suffix,
|
||||||
|
// we drop the suffix to maximize space for the thought.
|
||||||
|
// Otherwise, we keep the suffix.
|
||||||
|
const suffix = getSuffix(displayContext);
|
||||||
|
const suffixLen = suffix.length;
|
||||||
|
const canFitThoughtWithSuffix = cleanSubject
|
||||||
|
? cleanSubject.length + suffixLen + 3 <= MAX_LEN
|
||||||
|
: true;
|
||||||
|
|
||||||
|
let activeSuffix = '';
|
||||||
|
let maxStatusLen = MAX_LEN - 3; // Subtract icon prefix "✦ " (3 chars)
|
||||||
|
|
||||||
|
if (!cleanSubject || canFitThoughtWithSuffix) {
|
||||||
|
activeSuffix = suffix;
|
||||||
|
maxStatusLen -= activeSuffix.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayStatus = cleanSubject
|
||||||
|
? truncate(cleanSubject, maxStatusLen)
|
||||||
|
: 'Working…';
|
||||||
|
|
||||||
|
title = `✦ ${displayStatus}${activeSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove control characters that could cause issues in terminal titles
|
// Remove control characters that could cause issues in terminal titles
|
||||||
return title.replace(
|
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
/[\x00-\x1F\x7F]/g,
|
const safeTitle = title.replace(/[\x00-\x1F\x7F]/g, '');
|
||||||
'',
|
|
||||||
);
|
// Pad the title to a fixed width to prevent taskbar icon resizing/jitter.
|
||||||
|
// We also slice it to ensure it NEVER exceeds MAX_LEN.
|
||||||
|
return safeTitle.padEnd(MAX_LEN, ' ').substring(0, MAX_LEN);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,12 +188,19 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"showStatusInTitle": {
|
"showStatusInTitle": {
|
||||||
"title": "Show Status in Title",
|
"title": "Show Thoughts in Title",
|
||||||
"description": "Show Gemini CLI status and thoughts in the terminal window title",
|
"description": "Show Gemini CLI model thoughts in the terminal window title during the working phase",
|
||||||
"markdownDescription": "Show Gemini CLI status and thoughts in the terminal window title\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
|
"markdownDescription": "Show Gemini CLI model thoughts in the terminal window title during the working phase\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
|
||||||
"default": false,
|
"default": false,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"dynamicWindowTitle": {
|
||||||
|
"title": "Dynamic Window Title",
|
||||||
|
"description": "Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦)",
|
||||||
|
"markdownDescription": "Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦)\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`",
|
||||||
|
"default": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"showHomeDirectoryWarning": {
|
"showHomeDirectoryWarning": {
|
||||||
"title": "Show Home Directory Warning",
|
"title": "Show Home Directory Warning",
|
||||||
"description": "Show a warning when running Gemini CLI in the home directory.",
|
"description": "Show a warning when running Gemini CLI in the home directory.",
|
||||||
|
|||||||
Reference in New Issue
Block a user