mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 08:31:14 -07:00
feat(cli): implement dynamic terminal tab titles for CLI status (#16378)
This commit is contained in:
@@ -4,56 +4,210 @@
|
||||
* 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);
|
||||
});
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { computeTerminalTitle } from './windowTitle.js';
|
||||
import { StreamingState } from '../ui/types.js';
|
||||
|
||||
describe('computeTerminalTitle', () => {
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should use default Gemini title when CLI_TITLE is not set', () => {
|
||||
const result = computeWindowTitle('my-project');
|
||||
expect(result).toBe('Gemini - my-project');
|
||||
it.each([
|
||||
{
|
||||
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', () => {
|
||||
vi.stubEnv('CLI_TITLE', 'Custom Title');
|
||||
const result = computeWindowTitle('my-project');
|
||||
expect(result).toBe('Custom Title');
|
||||
it('should return active state title with thought subject and NO suffix when thoughts are very long', () => {
|
||||
const longThought = 'A'.repeat(70);
|
||||
const title = computeTerminalTitle({
|
||||
streamingState: StreamingState.Responding,
|
||||
thoughtSubject: longThought,
|
||||
isConfirming: false,
|
||||
folderName: 'my-project',
|
||||
showThoughts: true,
|
||||
useDynamicTitle: true,
|
||||
});
|
||||
|
||||
expect(title).not.toContain('(my-project)');
|
||||
expect(title).toContain('✦ AAAAAAAAAAAAAAAA');
|
||||
expect(title.length).toBe(80);
|
||||
});
|
||||
|
||||
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 truncate long thought subjects when thoughts are enabled', () => {
|
||||
const longThought = 'A'.repeat(100);
|
||||
const title = computeTerminalTitle({
|
||||
streamingState: StreamingState.Responding,
|
||||
thoughtSubject: longThought,
|
||||
isConfirming: false,
|
||||
folderName: 'my-project',
|
||||
showThoughts: true,
|
||||
useDynamicTitle: true,
|
||||
});
|
||||
|
||||
expect(title.length).toBe(80);
|
||||
expect(title).toContain('…');
|
||||
expect(title.trimEnd().length).toBe(80);
|
||||
});
|
||||
|
||||
it('should handle folder names with control characters', () => {
|
||||
const result = computeWindowTitle('project\x07name');
|
||||
expect(result).toBe('Gemini - projectname');
|
||||
it('should strip control characters from the title', () => {
|
||||
const title = computeTerminalTitle({
|
||||
streamingState: StreamingState.Responding,
|
||||
thoughtSubject: 'BadTitle\x00 With\x07Control\x1BChars',
|
||||
isConfirming: false,
|
||||
folderName: 'my-project',
|
||||
showThoughts: true,
|
||||
useDynamicTitle: true,
|
||||
});
|
||||
|
||||
expect(title).toContain('BadTitle WithControlChars');
|
||||
expect(title).not.toContain('\x00');
|
||||
expect(title).not.toContain('\x07');
|
||||
expect(title).not.toContain('\x1B');
|
||||
expect(title.length).toBe(80);
|
||||
});
|
||||
|
||||
it('should handle empty folder name', () => {
|
||||
const result = computeWindowTitle('');
|
||||
expect(result).toBe('Gemini - ');
|
||||
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('should handle folder names with spaces', () => {
|
||||
const result = computeWindowTitle('my project');
|
||||
expect(result).toBe('Gemini - my project');
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
it('should handle folder names with special characters', () => {
|
||||
const result = computeWindowTitle('project-name_v1.0');
|
||||
expect(result).toBe('Gemini - project-name_v1.0');
|
||||
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
|
||||
*/
|
||||
|
||||
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
|
||||
* @returns The computed window title, either from CLI_TITLE environment variable or the default Gemini title
|
||||
* @param options - The current state of the CLI and environment context
|
||||
* @returns A formatted string padded to 80 characters for the terminal title
|
||||
*/
|
||||
export function computeWindowTitle(folderName: string): string {
|
||||
const title = process.env['CLI_TITLE'] || `Gemini - ${folderName}`;
|
||||
export function computeTerminalTitle({
|
||||
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
|
||||
return title.replace(
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[\x00-\x1F\x7F]/g,
|
||||
'',
|
||||
);
|
||||
// eslint-disable-next-line no-control-regex
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user