gemini-cli/packages/cli/src/ui/utils/terminalSetup.ts

341 lines
10 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Terminal setup utility for configuring Shift+Enter and Ctrl+Enter support.
*
* This module provides automatic detection and configuration of various terminal
* emulators to support multiline input through modified Enter keys.
*
* Supported terminals:
* - VS Code: Configures keybindings.json to send \\\r\n
* - Cursor: Configures keybindings.json to send \\\r\n (VS Code fork)
* - Windsurf: Configures keybindings.json to send \\\r\n (VS Code fork)
*
* For VS Code and its forks:
* - Shift+Enter: Sends \\\r\n (backslash followed by CRLF)
* - Ctrl+Enter: Sends \\\r\n (backslash followed by CRLF)
*
* The module will not modify existing shift+enter or ctrl+enter keybindings
* to avoid conflicts with user customizations.
*/
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { isKittyProtocolEnabled } from './kittyProtocolDetector.js';
import { VSCODE_SHIFT_ENTER_SEQUENCE } from './platformConstants.js';
const execAsync = promisify(exec);
/**
* Removes single-line JSON comments (// ...) from a string to allow parsing
* VS Code style JSON files that may contain comments.
*/
function stripJsonComments(content: string): string {
// Remove single-line comments (// ...)
return content.replace(/^\s*\/\/.*$/gm, '');
}
export interface TerminalSetupResult {
success: boolean;
message: string;
requiresRestart?: boolean;
}
type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf';
// Terminal detection
async function detectTerminal(): Promise<SupportedTerminal | null> {
const termProgram = process.env.TERM_PROGRAM;
// Check VS Code and its forks - check forks first to avoid false positives
// Check for Cursor-specific indicators
if (
process.env.CURSOR_TRACE_ID ||
process.env.VSCODE_GIT_ASKPASS_MAIN?.toLowerCase().includes('cursor')
) {
return 'cursor';
}
// Check for Windsurf-specific indicators
if (process.env.VSCODE_GIT_ASKPASS_MAIN?.toLowerCase().includes('windsurf')) {
return 'windsurf';
}
// Check VS Code last since forks may also set VSCODE env vars
if (termProgram === 'vscode' || process.env.VSCODE_GIT_IPC_HANDLE) {
return 'vscode';
}
// Check parent process name
if (os.platform() !== 'win32') {
try {
const { stdout } = await execAsync('ps -o comm= -p $PPID');
const parentName = stdout.trim();
// Check forks before VS Code to avoid false positives
if (parentName.includes('windsurf') || parentName.includes('Windsurf'))
return 'windsurf';
if (parentName.includes('cursor') || parentName.includes('Cursor'))
return 'cursor';
if (parentName.includes('code') || parentName.includes('Code'))
return 'vscode';
} catch (error) {
// Continue detection even if process check fails
console.debug('Parent process detection failed:', error);
}
}
return null;
}
// Backup file helper
async function backupFile(filePath: string): Promise<void> {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${filePath}.backup.${timestamp}`;
await fs.copyFile(filePath, backupPath);
} catch (error) {
// Log backup errors but continue with operation
console.warn(`Failed to create backup of ${filePath}:`, error);
}
}
// Helper function to get VS Code-style config directory
function getVSCodeStyleConfigDir(appName: string): string | null {
const platform = os.platform();
if (platform === 'darwin') {
return path.join(
os.homedir(),
'Library',
'Application Support',
appName,
'User',
);
} else if (platform === 'win32') {
if (!process.env.APPDATA) {
return null;
}
return path.join(process.env.APPDATA, appName, 'User');
} else {
return path.join(os.homedir(), '.config', appName, 'User');
}
}
// Generic VS Code-style terminal configuration
async function configureVSCodeStyle(
terminalName: string,
appName: string,
): Promise<TerminalSetupResult> {
const configDir = getVSCodeStyleConfigDir(appName);
if (!configDir) {
return {
success: false,
message: `Could not determine ${terminalName} config path on Windows: APPDATA environment variable is not set.`,
};
}
const keybindingsFile = path.join(configDir, 'keybindings.json');
try {
await fs.mkdir(configDir, { recursive: true });
let keybindings: unknown[] = [];
try {
const content = await fs.readFile(keybindingsFile, 'utf8');
await backupFile(keybindingsFile);
try {
const cleanContent = stripJsonComments(content);
const parsedContent = JSON.parse(cleanContent);
if (!Array.isArray(parsedContent)) {
return {
success: false,
message:
`${terminalName} keybindings.json exists but is not a valid JSON array. ` +
`Please fix the file manually or delete it to allow automatic configuration.\n` +
`File: ${keybindingsFile}`,
};
}
keybindings = parsedContent;
} catch (parseError) {
return {
success: false,
message:
`Failed to parse ${terminalName} keybindings.json. The file contains invalid JSON.\n` +
`Please fix the file manually or delete it to allow automatic configuration.\n` +
`File: ${keybindingsFile}\n` +
`Error: ${parseError}`,
};
}
} catch {
// File doesn't exist, will create new one
}
const shiftEnterBinding = {
key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
};
const ctrlEnterBinding = {
key: 'ctrl+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
};
// Check if ANY shift+enter or ctrl+enter bindings already exist
const existingShiftEnter = keybindings.find((kb) => {
const binding = kb as { key?: string };
return binding.key === 'shift+enter';
});
const existingCtrlEnter = keybindings.find((kb) => {
const binding = kb as { key?: string };
return binding.key === 'ctrl+enter';
});
if (existingShiftEnter || existingCtrlEnter) {
const messages: string[] = [];
if (existingShiftEnter) {
messages.push(`- Shift+Enter binding already exists`);
}
if (existingCtrlEnter) {
messages.push(`- Ctrl+Enter binding already exists`);
}
return {
success: false,
message:
`Existing keybindings detected. Will not modify to avoid conflicts.\n` +
messages.join('\n') +
'\n' +
`Please check and modify manually if needed: ${keybindingsFile}`,
};
}
// Check if our specific bindings already exist
const hasOurShiftEnter = keybindings.some((kb) => {
const binding = kb as {
command?: string;
args?: { text?: string };
key?: string;
};
return (
binding.key === 'shift+enter' &&
binding.command === 'workbench.action.terminal.sendSequence' &&
binding.args?.text === '\\\r\n'
);
});
const hasOurCtrlEnter = keybindings.some((kb) => {
const binding = kb as {
command?: string;
args?: { text?: string };
key?: string;
};
return (
binding.key === 'ctrl+enter' &&
binding.command === 'workbench.action.terminal.sendSequence' &&
binding.args?.text === '\\\r\n'
);
});
if (!hasOurShiftEnter || !hasOurCtrlEnter) {
if (!hasOurShiftEnter) keybindings.unshift(shiftEnterBinding);
if (!hasOurCtrlEnter) keybindings.unshift(ctrlEnterBinding);
await fs.writeFile(keybindingsFile, JSON.stringify(keybindings, null, 4));
return {
success: true,
message: `Added Shift+Enter and Ctrl+Enter keybindings to ${terminalName}.\nModified: ${keybindingsFile}`,
requiresRestart: true,
};
} else {
return {
success: true,
message: `${terminalName} keybindings already configured.`,
};
}
} catch (error) {
return {
success: false,
message: `Failed to configure ${terminalName}.\nFile: ${keybindingsFile}\nError: ${error}`,
};
}
}
// Terminal-specific configuration functions
async function configureVSCode(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('VS Code', 'Code');
}
async function configureCursor(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Cursor', 'Cursor');
}
async function configureWindsurf(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Windsurf', 'Windsurf');
}
/**
* Main terminal setup function that detects and configures the current terminal.
*
* This function:
* 1. Detects the current terminal emulator
* 2. Applies appropriate configuration for Shift+Enter and Ctrl+Enter support
* 3. Creates backups of configuration files before modifying them
*
* @returns Promise<TerminalSetupResult> Result object with success status and message
*
* @example
* const result = await terminalSetup();
* if (result.success) {
* console.log(result.message);
* if (result.requiresRestart) {
* console.log('Please restart your terminal');
* }
* }
*/
export async function terminalSetup(): Promise<TerminalSetupResult> {
// Check if terminal already has optimal keyboard support
if (isKittyProtocolEnabled()) {
return {
success: true,
message:
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).',
};
}
const terminal = await detectTerminal();
if (!terminal) {
return {
success: false,
message:
'Could not detect terminal type. Supported terminals: VS Code, Cursor, and Windsurf.',
};
}
switch (terminal) {
case 'vscode':
return configureVSCode();
case 'cursor':
return configureCursor();
case 'windsurf':
return configureWindsurf();
default:
return {
success: false,
message: `Terminal "${terminal}" is not supported yet.`,
};
}
}