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

@@ -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('…)');
});
});