feat: Detect background color (#15132)

This commit is contained in:
Jacob Richman
2025-12-18 10:36:48 -08:00
committed by GitHub
parent 54466a3ea8
commit 322232e514
28 changed files with 1031 additions and 359 deletions

View File

@@ -1,145 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock dependencies
const mocks = vi.hoisted(() => ({
writeSync: vi.fn(),
enableKittyKeyboardProtocol: vi.fn(),
disableKittyKeyboardProtocol: vi.fn(),
}));
vi.mock('node:fs', () => ({
writeSync: mocks.writeSync,
}));
vi.mock('@google/gemini-cli-core', () => ({
enableKittyKeyboardProtocol: mocks.enableKittyKeyboardProtocol,
disableKittyKeyboardProtocol: mocks.disableKittyKeyboardProtocol,
}));
describe('kittyProtocolDetector', () => {
let originalStdin: NodeJS.ReadStream & { fd?: number };
let originalStdout: NodeJS.WriteStream & { fd?: number };
let stdinListeners: Record<string, (data: Buffer) => void> = {};
// Module functions
let detectAndEnableKittyProtocol: typeof import('./kittyProtocolDetector.js').detectAndEnableKittyProtocol;
let isKittyProtocolEnabled: typeof import('./kittyProtocolDetector.js').isKittyProtocolEnabled;
let enableSupportedProtocol: typeof import('./kittyProtocolDetector.js').enableSupportedProtocol;
beforeEach(async () => {
vi.resetModules();
vi.resetAllMocks();
vi.useFakeTimers();
const mod = await import('./kittyProtocolDetector.js');
detectAndEnableKittyProtocol = mod.detectAndEnableKittyProtocol;
isKittyProtocolEnabled = mod.isKittyProtocolEnabled;
enableSupportedProtocol = mod.enableSupportedProtocol;
// Mock process.stdin and stdout
originalStdin = process.stdin;
originalStdout = process.stdout;
stdinListeners = {};
Object.defineProperty(process, 'stdin', {
value: {
isTTY: true,
isRaw: false,
setRawMode: vi.fn(),
on: vi.fn((event, handler) => {
stdinListeners[event] = handler;
}),
removeListener: vi.fn(),
},
configurable: true,
});
Object.defineProperty(process, 'stdout', {
value: {
isTTY: true,
fd: 1,
},
configurable: true,
});
});
afterEach(() => {
Object.defineProperty(process, 'stdin', { value: originalStdin });
Object.defineProperty(process, 'stdout', { value: originalStdout });
vi.useRealTimers();
});
it('should resolve immediately if not TTY', async () => {
Object.defineProperty(process.stdin, 'isTTY', { value: false });
await detectAndEnableKittyProtocol();
expect(mocks.writeSync).not.toHaveBeenCalled();
});
it('should enable protocol if response indicates support', async () => {
const promise = detectAndEnableKittyProtocol();
// Simulate response
expect(stdinListeners['data']).toBeDefined();
// Send progressive enhancement response
stdinListeners['data'](Buffer.from('\x1b[?u'));
// Send device attributes response
stdinListeners['data'](Buffer.from('\x1b[?c'));
await promise;
expect(mocks.enableKittyKeyboardProtocol).toHaveBeenCalled();
expect(isKittyProtocolEnabled()).toBe(true);
});
it('should not enable protocol if timeout occurs', async () => {
const promise = detectAndEnableKittyProtocol();
// Fast forward time past timeout
vi.advanceTimersByTime(300);
await promise;
expect(mocks.enableKittyKeyboardProtocol).not.toHaveBeenCalled();
});
it('should wait longer if progressive enhancement received but not attributes', async () => {
const promise = detectAndEnableKittyProtocol();
// Send progressive enhancement response
stdinListeners['data'](Buffer.from('\x1b[?u'));
// Should not resolve yet
vi.advanceTimersByTime(300); // Original timeout passed
// Send device attributes response late
stdinListeners['data'](Buffer.from('\x1b[?c'));
await promise;
expect(mocks.enableKittyKeyboardProtocol).toHaveBeenCalled();
});
it('should handle re-enabling protocol', async () => {
// First, simulate successful detection to set kittySupported = true
const promise = detectAndEnableKittyProtocol();
stdinListeners['data'](Buffer.from('\x1b[?u'));
stdinListeners['data'](Buffer.from('\x1b[?c'));
await promise;
// Reset mocks to clear previous calls
mocks.enableKittyKeyboardProtocol.mockClear();
// Now test re-enabling
enableSupportedProtocol();
expect(mocks.enableKittyKeyboardProtocol).toHaveBeenCalled();
});
});

View File

@@ -1,133 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
let detectionComplete = false;
let kittySupported = false;
let kittyEnabled = false;
/**
* Detects Kitty keyboard protocol support.
* Definitive document about this protocol lives at https://sw.kovidgoyal.net/kitty/keyboard-protocol/
* This function should be called once at app startup.
*/
export async function detectAndEnableKittyProtocol(): Promise<void> {
if (detectionComplete) {
return;
}
return new Promise((resolve) => {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
detectionComplete = true;
resolve();
return;
}
const originalRawMode = process.stdin.isRaw;
if (!originalRawMode) {
process.stdin.setRawMode(true);
}
let responseBuffer = '';
let progressiveEnhancementReceived = false;
let timeoutId: NodeJS.Timeout | undefined;
const finish = () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
process.stdin.removeListener('data', handleData);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
if (kittySupported) {
enableSupportedProtocol();
process.on('exit', disableAllProtocols);
process.on('SIGTERM', disableAllProtocols);
}
detectionComplete = true;
resolve();
};
const handleData = (data: Buffer) => {
if (timeoutId === undefined) {
// Race condition. We have already timed out.
return;
}
responseBuffer += data.toString();
// Check for progressive enhancement response (CSI ? <flags> u)
if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('u')) {
progressiveEnhancementReceived = true;
// Give more time to get the full set of kitty responses if we have an
// indication the terminal probably supports kitty and we just need to
// wait a bit longer for a response.
clearTimeout(timeoutId);
timeoutId = setTimeout(finish, 1000);
}
// Check for device attributes response (CSI ? <attrs> c)
if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('c')) {
if (progressiveEnhancementReceived) {
kittySupported = true;
}
finish();
}
};
process.stdin.on('data', handleData);
// Query progressive enhancement and device attributes
fs.writeSync(process.stdout.fd, '\x1b[?u\x1b[c');
// Timeout after 200ms
// When a iterm2 terminal does not have focus this can take over 90s on a
// fast macbook so we need a somewhat longer threshold than would be ideal.
timeoutId = setTimeout(finish, 200);
});
}
import {
enableKittyKeyboardProtocol,
disableKittyKeyboardProtocol,
} from '@google/gemini-cli-core';
export function isKittyProtocolEnabled(): boolean {
return kittyEnabled;
}
function disableAllProtocols() {
try {
if (kittyEnabled) {
disableKittyKeyboardProtocol();
kittyEnabled = false;
}
} catch {
// Ignore
}
}
/**
* This is exported so we can reenable this after exiting an editor which might
* change the mode.
*/
export function enableSupportedProtocol(): void {
try {
if (kittySupported) {
enableKittyKeyboardProtocol();
kittyEnabled = true;
}
} catch {
// Ignore
}
}

View File

@@ -0,0 +1,177 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { TerminalCapabilityManager } from './terminalCapabilityManager.js';
import { EventEmitter } from 'node:events';
// Mock fs
vi.mock('node:fs', () => ({
writeSync: vi.fn(),
}));
// Mock core
vi.mock('@google/gemini-cli-core', () => ({
debugLogger: {
log: vi.fn(),
warn: vi.fn(),
},
enableKittyKeyboardProtocol: vi.fn(),
disableKittyKeyboardProtocol: vi.fn(),
}));
describe('TerminalCapabilityManager', () => {
let stdin: EventEmitter & {
isTTY?: boolean;
isRaw?: boolean;
setRawMode?: (mode: boolean) => void;
removeListener?: (
event: string,
listener: (...args: unknown[]) => void,
) => void;
};
let stdout: { isTTY?: boolean; fd?: number };
// Save original process properties
const originalStdin = process.stdin;
const originalStdout = process.stdout;
beforeEach(() => {
vi.resetAllMocks();
// Reset singleton
TerminalCapabilityManager.resetInstanceForTesting();
// Setup process mocks
stdin = new EventEmitter();
stdin.isTTY = true;
stdin.isRaw = false;
stdin.setRawMode = vi.fn();
stdin.removeListener = vi.fn();
stdout = { isTTY: true, fd: 1 };
// Use defineProperty to mock process.stdin/stdout
Object.defineProperty(process, 'stdin', {
value: stdin,
configurable: true,
});
Object.defineProperty(process, 'stdout', {
value: stdout,
configurable: true,
});
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
// Restore original process properties
Object.defineProperty(process, 'stdin', {
value: originalStdin,
configurable: true,
});
Object.defineProperty(process, 'stdout', {
value: originalStdout,
configurable: true,
});
});
it('should detect Kitty support when u response is received', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate Kitty response: \x1b[?1u
stdin.emit('data', Buffer.from('\x1b[?1u'));
// Complete detection with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.isKittyProtocolEnabled()).toBe(true);
});
it('should detect Background Color', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate OSC 11 response
// \x1b]11;rgb:0000/ff00/0000\x1b\
// RGB: 0, 255, 0 -> #00ff00
stdin.emit('data', Buffer.from('\x1b]11;rgb:0000/ffff/0000\x1b\\'));
// Complete detection with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.getTerminalBackgroundColor()).toBe('#00ff00');
});
it('should detect Terminal Name', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate Terminal Name response
stdin.emit('data', Buffer.from('\x1bP>|WezTerm 20240203\x1b\\'));
// Complete detection with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.getTerminalName()).toBe('WezTerm 20240203');
});
it('should complete early if sentinel (DA1) is found', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
stdin.emit('data', Buffer.from('\x1b[?1u'));
stdin.emit('data', Buffer.from('\x1b]11;rgb:0000/0000/0000\x1b\\'));
// Sentinel
stdin.emit('data', Buffer.from('\x1b[?62c'));
// Should resolve without waiting for timeout
await promise;
expect(manager.isKittyProtocolEnabled()).toBe(true);
expect(manager.getTerminalBackgroundColor()).toBe('#000000');
});
it('should timeout if no DA1 (c) is received', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate only Kitty response
stdin.emit('data', Buffer.from('\x1b[?1u'));
// Advance to timeout
vi.advanceTimersByTime(1000);
await promise;
expect(manager.isKittyProtocolEnabled()).toBe(true);
});
it('should not detect Kitty if only DA1 (c) is received', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate DA1 response only: \x1b[?62;c
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.isKittyProtocolEnabled()).toBe(false);
});
it('should handle split chunks', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Split response: \x1b[? 1u
stdin.emit('data', Buffer.from('\x1b[?'));
stdin.emit('data', Buffer.from('1u'));
// Complete with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.isKittyProtocolEnabled()).toBe(true);
});
});

View File

@@ -0,0 +1,237 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import {
debugLogger,
enableKittyKeyboardProtocol,
disableKittyKeyboardProtocol,
} from '@google/gemini-cli-core';
export type TerminalBackgroundColor = string | undefined;
export class TerminalCapabilityManager {
private static instance: TerminalCapabilityManager | undefined;
private static readonly KITTY_QUERY = '\x1b[?u';
private static readonly OSC_11_QUERY = '\x1b]11;?\x1b\\';
private static readonly TERMINAL_NAME_QUERY = '\x1b[>q';
private static readonly DEVICE_ATTRIBUTES_QUERY = '\x1b[c';
// Kitty keyboard flags: CSI ? flags u
// eslint-disable-next-line no-control-regex
private static readonly KITTY_REGEX = /\x1b\[\?(\d+)u/;
// Terminal Name/Version response: DCS > | text ST (or BEL)
// eslint-disable-next-line no-control-regex
private static readonly TERMINAL_NAME_REGEX = /\x1bP>\|(.+?)(\x1b\\|\x07)/;
// Primary Device Attributes: CSI ? ID ; ... c
// eslint-disable-next-line no-control-regex
private static readonly DEVICE_ATTRIBUTES_REGEX = /\x1b\[\?(\d+)(;\d+)*c/;
// OSC 11 response: OSC 11 ; rgb:rrrr/gggg/bbbb ST (or BEL)
private static readonly OSC_11_REGEX =
// eslint-disable-next-line no-control-regex
/\x1b\]11;rgb:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})(\x1b\\|\x07)?/;
private terminalBackgroundColor: TerminalBackgroundColor;
private kittySupported = false;
private kittyEnabled = false;
private detectionComplete = false;
private terminalName: string | undefined;
private constructor() {}
static getInstance(): TerminalCapabilityManager {
if (!this.instance) {
this.instance = new TerminalCapabilityManager();
}
return this.instance;
}
static resetInstanceForTesting(): void {
this.instance = undefined;
}
/**
* Detects terminal capabilities (Kitty protocol support, terminal name,
* background color).
* This should be called once at app startup.
*/
async detectCapabilities(): Promise<void> {
if (this.detectionComplete) return;
if (!process.stdin.isTTY || !process.stdout.isTTY) {
this.detectionComplete = true;
return;
}
return new Promise((resolve) => {
const originalRawMode = process.stdin.isRaw;
if (!originalRawMode) {
process.stdin.setRawMode(true);
}
let buffer = '';
let kittyKeyboardReceived = false;
let terminalNameReceived = false;
let deviceAttributesReceived = false;
let bgReceived = false;
// eslint-disable-next-line prefer-const
let timeoutId: NodeJS.Timeout;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
process.stdin.removeListener('data', onData);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
this.detectionComplete = true;
// Auto-enable kitty if supported
if (this.kittySupported) {
this.enableKittyProtocol();
process.on('exit', () => this.disableKittyProtocol());
process.on('SIGTERM', () => this.disableKittyProtocol());
}
resolve();
};
const onTimeout = () => {
cleanup();
};
// A somewhat long timeout is acceptable as all terminals should respond
// to the device attributes query used as a sentinel.
timeoutId = setTimeout(onTimeout, 1000);
const onData = (data: Buffer) => {
buffer += data.toString();
// Check OSC 11
if (!bgReceived) {
const match = buffer.match(TerminalCapabilityManager.OSC_11_REGEX);
if (match) {
bgReceived = true;
this.terminalBackgroundColor = this.parseColor(
match[1],
match[2],
match[3],
);
debugLogger.log(
`Detected terminal background color: ${this.terminalBackgroundColor}`,
);
}
}
if (
!kittyKeyboardReceived &&
TerminalCapabilityManager.KITTY_REGEX.test(buffer)
) {
kittyKeyboardReceived = true;
this.kittySupported = true;
}
// Check for Terminal Name/Version response.
if (!terminalNameReceived) {
const match = buffer.match(
TerminalCapabilityManager.TERMINAL_NAME_REGEX,
);
if (match) {
terminalNameReceived = true;
this.terminalName = match[1];
debugLogger.log(`Detected terminal name: ${this.terminalName}`);
}
}
// We use the Primary Device Attributes response as a sentinel to know
// that the terminal has processed all our queries. Since we send it
// last, receiving it means we can stop waiting.
if (!deviceAttributesReceived) {
const match = buffer.match(
TerminalCapabilityManager.DEVICE_ATTRIBUTES_REGEX,
);
if (match) {
deviceAttributesReceived = true;
cleanup();
}
}
};
process.stdin.on('data', onData);
try {
fs.writeSync(
process.stdout.fd,
TerminalCapabilityManager.KITTY_QUERY +
TerminalCapabilityManager.OSC_11_QUERY +
TerminalCapabilityManager.TERMINAL_NAME_QUERY +
TerminalCapabilityManager.DEVICE_ATTRIBUTES_QUERY,
);
} catch (e) {
debugLogger.warn('Failed to write terminal capability queries:', e);
cleanup();
}
});
}
getTerminalBackgroundColor(): TerminalBackgroundColor {
return this.terminalBackgroundColor;
}
getTerminalName(): string | undefined {
return this.terminalName;
}
isKittyProtocolEnabled(): boolean {
return this.kittyEnabled;
}
enableKittyProtocol(): void {
try {
if (this.kittySupported) {
enableKittyKeyboardProtocol();
this.kittyEnabled = true;
}
} catch (e) {
debugLogger.warn('Failed to enable Kitty protocol:', e);
}
}
disableKittyProtocol(): void {
try {
if (this.kittyEnabled) {
disableKittyKeyboardProtocol();
this.kittyEnabled = false;
}
} catch (e) {
debugLogger.warn('Failed to disable Kitty protocol:', e);
}
}
private parseColor(rHex: string, gHex: string, bHex: string): string {
const parseComponent = (hex: string) => {
const val = parseInt(hex, 16);
if (hex.length === 1) return (val / 15) * 255;
if (hex.length === 2) return val;
if (hex.length === 3) return (val / 4095) * 255;
if (hex.length === 4) return (val / 65535) * 255;
return val;
};
const r = parseComponent(rHex);
const g = parseComponent(gHex);
const b = parseComponent(bHex);
const toHex = (c: number) => Math.round(c).toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
}
export const terminalCapabilityManager =
TerminalCapabilityManager.getInstance();

View File

@@ -42,8 +42,10 @@ vi.mock('node:os', () => ({
platform: mocks.platform,
}));
vi.mock('./kittyProtocolDetector.js', () => ({
isKittyProtocolEnabled: vi.fn().mockReturnValue(false),
vi.mock('./terminalCapabilityManager.js', () => ({
terminalCapabilityManager: {
isKittyProtocolEnabled: vi.fn().mockReturnValue(false),
},
}));
describe('terminalSetup', () => {

View File

@@ -28,7 +28,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { isKittyProtocolEnabled } from './kittyProtocolDetector.js';
import { terminalCapabilityManager } from './terminalCapabilityManager.js';
import { debugLogger } from '@google/gemini-cli-core';
@@ -323,7 +323,7 @@ async function configureWindsurf(): Promise<TerminalSetupResult> {
*/
export async function terminalSetup(): Promise<TerminalSetupResult> {
// Check if terminal already has optimal keyboard support
if (isKittyProtocolEnabled()) {
if (terminalCapabilityManager.isKittyProtocolEnabled()) {
return {
success: true,
message: