2025-12-18 10:36:48 -08:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import * as fs from 'node:fs';
|
|
|
|
|
import {
|
|
|
|
|
debugLogger,
|
|
|
|
|
enableKittyKeyboardProtocol,
|
|
|
|
|
disableKittyKeyboardProtocol,
|
2025-12-26 00:39:38 +05:30
|
|
|
enableModifyOtherKeys,
|
|
|
|
|
disableModifyOtherKeys,
|
2026-01-05 14:46:23 -08:00
|
|
|
enableBracketedPasteMode,
|
|
|
|
|
disableBracketedPasteMode,
|
2026-03-23 11:01:12 -07:00
|
|
|
disableMouseEvents,
|
2025-12-18 10:36:48 -08:00
|
|
|
} from '@google/gemini-cli-core';
|
2026-02-02 16:39:17 -08:00
|
|
|
import { parseColor } from '../themes/color-utils.js';
|
2025-12-18 10:36:48 -08:00
|
|
|
|
|
|
|
|
export type TerminalBackgroundColor = string | undefined;
|
|
|
|
|
|
2026-03-23 11:01:12 -07:00
|
|
|
const TERMINAL_CLEANUP_SEQUENCE =
|
|
|
|
|
'\x1b[<u\x1b[>4;0m\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l';
|
2026-02-12 09:55:56 -08:00
|
|
|
|
|
|
|
|
export function cleanupTerminalOnExit() {
|
|
|
|
|
try {
|
|
|
|
|
if (process.stdout?.fd !== undefined) {
|
|
|
|
|
fs.writeSync(process.stdout.fd, TERMINAL_CLEANUP_SEQUENCE);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
debugLogger.warn('Failed to synchronously cleanup terminal modes:', e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
disableKittyKeyboardProtocol();
|
|
|
|
|
disableModifyOtherKeys();
|
|
|
|
|
disableBracketedPasteMode();
|
2026-03-23 11:01:12 -07:00
|
|
|
disableMouseEvents();
|
2026-02-12 09:55:56 -08:00
|
|
|
}
|
|
|
|
|
|
2025-12-18 10:36:48 -08:00
|
|
|
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';
|
2025-12-26 00:39:38 +05:30
|
|
|
private static readonly MODIFY_OTHER_KEYS_QUERY = '\x1b[>4;?m';
|
2026-02-14 20:08:13 -08:00
|
|
|
private static readonly HIDDEN_MODE = '\x1b[8m';
|
|
|
|
|
private static readonly CLEAR_LINE_AND_RETURN = '\x1b[2K\r';
|
|
|
|
|
private static readonly RESET_ATTRIBUTES = '\x1b[0m';
|
2025-12-18 10:36:48 -08:00
|
|
|
|
2026-02-12 11:56:07 -08:00
|
|
|
/**
|
|
|
|
|
* Triggers a terminal background color query.
|
|
|
|
|
* @param stdout The stdout stream to write to.
|
|
|
|
|
*/
|
|
|
|
|
static queryBackgroundColor(stdout: {
|
|
|
|
|
write: (data: string) => void | boolean;
|
|
|
|
|
}): void {
|
|
|
|
|
stdout.write(TerminalCapabilityManager.OSC_11_QUERY);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 10:36:48 -08:00
|
|
|
// 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)
|
2026-02-02 16:39:17 -08:00
|
|
|
static readonly OSC_11_REGEX =
|
2025-12-18 10:36:48 -08:00
|
|
|
// eslint-disable-next-line no-control-regex
|
2026-02-12 11:56:07 -08:00
|
|
|
/\x1b\]11;rgb:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})(\x1b\\|\x07)/;
|
2025-12-26 00:39:38 +05:30
|
|
|
// modifyOtherKeys response: CSI > 4 ; level m
|
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
|
private static readonly MODIFY_OTHER_KEYS_REGEX = /\x1b\[>4;(\d+)m/;
|
2025-12-18 10:36:48 -08:00
|
|
|
|
2026-01-09 13:10:15 -08:00
|
|
|
private detectionComplete = false;
|
2025-12-18 10:36:48 -08:00
|
|
|
private terminalBackgroundColor: TerminalBackgroundColor;
|
|
|
|
|
private kittySupported = false;
|
|
|
|
|
private kittyEnabled = false;
|
2026-01-24 01:34:52 +08:00
|
|
|
private modifyOtherKeysSupported = false;
|
2025-12-18 10:36:48 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:55:56 -08:00
|
|
|
process.off('exit', cleanupTerminalOnExit);
|
|
|
|
|
process.off('SIGTERM', cleanupTerminalOnExit);
|
|
|
|
|
process.off('SIGINT', cleanupTerminalOnExit);
|
|
|
|
|
process.on('exit', cleanupTerminalOnExit);
|
|
|
|
|
process.on('SIGTERM', cleanupTerminalOnExit);
|
|
|
|
|
process.on('SIGINT', cleanupTerminalOnExit);
|
2026-01-05 14:46:23 -08:00
|
|
|
|
2025-12-18 10:36:48 -08:00
|
|
|
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;
|
2025-12-26 00:39:38 +05:30
|
|
|
let modifyOtherKeysReceived = false;
|
2025-12-18 10:36:48 -08:00
|
|
|
// 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;
|
|
|
|
|
resolve();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// A somewhat long timeout is acceptable as all terminals should respond
|
|
|
|
|
// to the device attributes query used as a sentinel.
|
2026-01-05 14:46:23 -08:00
|
|
|
timeoutId = setTimeout(cleanup, 1000);
|
2025-12-18 10:36:48 -08:00
|
|
|
|
|
|
|
|
const onData = (data: Buffer) => {
|
|
|
|
|
buffer += data.toString();
|
|
|
|
|
|
|
|
|
|
// Check OSC 11
|
|
|
|
|
if (!bgReceived) {
|
|
|
|
|
const match = buffer.match(TerminalCapabilityManager.OSC_11_REGEX);
|
|
|
|
|
if (match) {
|
|
|
|
|
bgReceived = true;
|
2026-02-02 16:39:17 -08:00
|
|
|
this.terminalBackgroundColor = parseColor(
|
2025-12-18 10:36:48 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 14:46:23 -08:00
|
|
|
// check for modifyOtherKeys support
|
|
|
|
|
if (!modifyOtherKeysReceived) {
|
|
|
|
|
const match = buffer.match(
|
|
|
|
|
TerminalCapabilityManager.MODIFY_OTHER_KEYS_REGEX,
|
|
|
|
|
);
|
|
|
|
|
if (match) {
|
|
|
|
|
modifyOtherKeysReceived = true;
|
|
|
|
|
const level = parseInt(match[1], 10);
|
|
|
|
|
this.modifyOtherKeysSupported = level >= 2;
|
|
|
|
|
debugLogger.log(
|
|
|
|
|
`Detected modifyOtherKeys support: ${this.modifyOtherKeysSupported} (level ${level})`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 10:36:48 -08:00
|
|
|
// 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,
|
2026-02-14 20:08:13 -08:00
|
|
|
// Use hidden mode to prevent potential "m" character from being printed
|
|
|
|
|
// to the terminal during startup when querying for modifyOtherKeys.
|
|
|
|
|
// This can happen on some terminals that might echo the query or
|
|
|
|
|
// malform the response. We hide the output, send queries, then
|
|
|
|
|
// immediately clear the line and reset attributes.
|
|
|
|
|
TerminalCapabilityManager.HIDDEN_MODE +
|
|
|
|
|
TerminalCapabilityManager.KITTY_QUERY +
|
2025-12-18 10:36:48 -08:00
|
|
|
TerminalCapabilityManager.OSC_11_QUERY +
|
|
|
|
|
TerminalCapabilityManager.TERMINAL_NAME_QUERY +
|
2025-12-26 00:39:38 +05:30
|
|
|
TerminalCapabilityManager.MODIFY_OTHER_KEYS_QUERY +
|
2026-02-14 20:08:13 -08:00
|
|
|
TerminalCapabilityManager.DEVICE_ATTRIBUTES_QUERY +
|
|
|
|
|
TerminalCapabilityManager.CLEAR_LINE_AND_RETURN +
|
|
|
|
|
TerminalCapabilityManager.RESET_ATTRIBUTES,
|
2025-12-18 10:36:48 -08:00
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
debugLogger.warn('Failed to write terminal capability queries:', e);
|
|
|
|
|
cleanup();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 14:46:23 -08:00
|
|
|
enableSupportedModes() {
|
2026-01-06 12:11:43 -08:00
|
|
|
try {
|
|
|
|
|
if (this.kittySupported) {
|
2026-03-05 15:29:12 +00:00
|
|
|
debugLogger.log('Enabling Kitty keyboard protocol');
|
2026-01-06 12:11:43 -08:00
|
|
|
enableKittyKeyboardProtocol();
|
|
|
|
|
this.kittyEnabled = true;
|
2026-01-24 01:34:52 +08:00
|
|
|
} else if (this.modifyOtherKeysSupported) {
|
2026-03-05 15:29:12 +00:00
|
|
|
debugLogger.log('Enabling modifyOtherKeys');
|
2026-01-06 12:11:43 -08:00
|
|
|
enableModifyOtherKeys();
|
|
|
|
|
}
|
2026-01-09 08:07:05 -08:00
|
|
|
// Always enable bracketed paste since it'll be ignored if unsupported.
|
|
|
|
|
enableBracketedPasteMode();
|
2026-01-06 12:11:43 -08:00
|
|
|
} catch (e) {
|
|
|
|
|
debugLogger.warn('Failed to enable keyboard protocols:', e);
|
2026-01-05 14:46:23 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 10:36:48 -08:00
|
|
|
getTerminalBackgroundColor(): TerminalBackgroundColor {
|
|
|
|
|
return this.terminalBackgroundColor;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getTerminalName(): string | undefined {
|
|
|
|
|
return this.terminalName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isKittyProtocolEnabled(): boolean {
|
|
|
|
|
return this.kittyEnabled;
|
|
|
|
|
}
|
2026-02-18 15:28:17 -05:00
|
|
|
|
2026-04-01 12:23:40 -04:00
|
|
|
isGhosttyTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
|
|
|
const termProgram = env['TERM_PROGRAM']?.toLowerCase();
|
|
|
|
|
const term = env['TERM']?.toLowerCase();
|
|
|
|
|
const name = this.getTerminalName()?.toLowerCase();
|
|
|
|
|
|
|
|
|
|
return !!(
|
|
|
|
|
name?.includes('ghostty') ||
|
|
|
|
|
termProgram?.includes('ghostty') ||
|
|
|
|
|
term?.includes('ghostty')
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 02:45:54 +08:00
|
|
|
isTmux(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
|
|
|
return !!env['TMUX'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isScreen(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
|
|
|
return !!env['STY'];
|
|
|
|
|
}
|
2026-02-18 15:28:17 -05:00
|
|
|
|
2026-04-17 02:45:54 +08:00
|
|
|
isITerm2(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
|
|
|
return !!(
|
|
|
|
|
this.getTerminalName()?.toLowerCase().includes('iterm') ||
|
|
|
|
|
env['TERM_PROGRAM']?.toLowerCase().includes('iterm')
|
2026-02-18 15:28:17 -05:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 02:45:54 +08:00
|
|
|
isAlacritty(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
|
|
|
return !!(
|
|
|
|
|
this.getTerminalName()?.toLowerCase().includes('alacritty') ||
|
|
|
|
|
env['ALACRITTY_WINDOW_ID'] ||
|
|
|
|
|
env['TERM']?.toLowerCase().includes('alacritty')
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-18 15:28:17 -05:00
|
|
|
|
2026-04-17 02:45:54 +08:00
|
|
|
isAppleTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
|
|
|
return !!(
|
|
|
|
|
this.getTerminalName()?.toLowerCase().includes('apple_terminal') ||
|
|
|
|
|
env['TERM_PROGRAM']?.toLowerCase().includes('apple_terminal')
|
2026-02-18 15:28:17 -05:00
|
|
|
);
|
|
|
|
|
}
|
2026-04-17 02:45:54 +08:00
|
|
|
|
|
|
|
|
isVSCodeTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
|
|
|
return !!env['TERM_PROGRAM']?.toLowerCase().includes('vscode');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isWindowsTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
|
|
|
return !!env['WT_SESSION'];
|
|
|
|
|
}
|
2025-12-18 10:36:48 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const terminalCapabilityManager =
|
|
|
|
|
TerminalCapabilityManager.getInstance();
|