mirror of
http://10.0.2.1:3031/sauer/claude-code.git
synced 2026-06-30 14:16:58 +10:00
531 lines
76 KiB
TypeScript
531 lines
76 KiB
TypeScript
|
|
import chalk from 'chalk';
|
||
|
|
import { randomBytes } from 'crypto';
|
||
|
|
import { copyFile, mkdir, readFile, writeFile } from 'fs/promises';
|
||
|
|
import { homedir, platform } from 'os';
|
||
|
|
import { dirname, join } from 'path';
|
||
|
|
import type { ThemeName } from 'src/utils/theme.js';
|
||
|
|
import { pathToFileURL } from 'url';
|
||
|
|
import { supportsHyperlinks } from '../../ink/supports-hyperlinks.js';
|
||
|
|
import { color } from '../../ink.js';
|
||
|
|
import { maybeMarkProjectOnboardingComplete } from '../../projectOnboardingState.js';
|
||
|
|
import type { ToolUseContext } from '../../Tool.js';
|
||
|
|
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
|
||
|
|
import { backupTerminalPreferences, checkAndRestoreTerminalBackup, getTerminalPlistPath, markTerminalSetupComplete } from '../../utils/appleTerminalBackup.js';
|
||
|
|
import { setupShellCompletion } from '../../utils/completionCache.js';
|
||
|
|
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||
|
|
import { env } from '../../utils/env.js';
|
||
|
|
import { isFsInaccessible } from '../../utils/errors.js';
|
||
|
|
import { execFileNoThrow } from '../../utils/execFileNoThrow.js';
|
||
|
|
import { addItemToJSONCArray, safeParseJSONC } from '../../utils/json.js';
|
||
|
|
import { logError } from '../../utils/log.js';
|
||
|
|
import { getPlatform } from '../../utils/platform.js';
|
||
|
|
import { jsonParse, jsonStringify } from '../../utils/slowOperations.js';
|
||
|
|
const EOL = '\n';
|
||
|
|
|
||
|
|
// Terminals that natively support CSI u / Kitty keyboard protocol
|
||
|
|
const NATIVE_CSIU_TERMINALS: Record<string, string> = {
|
||
|
|
ghostty: 'Ghostty',
|
||
|
|
kitty: 'Kitty',
|
||
|
|
'iTerm.app': 'iTerm2',
|
||
|
|
WezTerm: 'WezTerm',
|
||
|
|
WarpTerminal: 'Warp'
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Detect if we're running in a VSCode Remote SSH session.
|
||
|
|
* In this case, keybindings need to be installed on the LOCAL machine,
|
||
|
|
* not the remote server where Claude is running.
|
||
|
|
*/
|
||
|
|
function isVSCodeRemoteSSH(): boolean {
|
||
|
|
const askpassMain = process.env.VSCODE_GIT_ASKPASS_MAIN ?? '';
|
||
|
|
const path = process.env.PATH ?? '';
|
||
|
|
|
||
|
|
// Check both env vars - VSCODE_GIT_ASKPASS_MAIN is more reliable when git extension
|
||
|
|
// is active, and PATH is a fallback. Omit path separator for Windows compatibility.
|
||
|
|
return askpassMain.includes('.vscode-server') || askpassMain.includes('.cursor-server') || askpassMain.includes('.windsurf-server') || path.includes('.vscode-server') || path.includes('.cursor-server') || path.includes('.windsurf-server');
|
||
|
|
}
|
||
|
|
export function getNativeCSIuTerminalDisplayName(): string | null {
|
||
|
|
if (!env.terminal || !(env.terminal in NATIVE_CSIU_TERMINALS)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return NATIVE_CSIU_TERMINALS[env.terminal] ?? null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format a file path as a clickable hyperlink.
|
||
|
|
*
|
||
|
|
* Paths containing spaces (e.g., "Application Support") are not clickable
|
||
|
|
* in most terminals - they get split at the space. OSC 8 hyperlinks solve
|
||
|
|
* this by embedding a file:// URL that the terminal can open on click,
|
||
|
|
* while displaying the clean path to the user.
|
||
|
|
*
|
||
|
|
* Unlike createHyperlink(), this doesn't apply any color styling so the
|
||
|
|
* path inherits the parent's styling (e.g., chalk.dim).
|
||
|
|
*/
|
||
|
|
function formatPathLink(filePath: string): string {
|
||
|
|
if (!supportsHyperlinks()) {
|
||
|
|
return filePath;
|
||
|
|
}
|
||
|
|
const fileUrl = pathToFileURL(filePath).href;
|
||
|
|
// OSC 8 hyperlink: \e]8;;URL\a TEXT \e]8;;\a
|
||
|
|
return `\x1b]8;;${fileUrl}\x07${filePath}\x1b]8;;\x07`;
|
||
|
|
}
|
||
|
|
export function shouldOfferTerminalSetup(): boolean {
|
||
|
|
// iTerm2, WezTerm, Ghostty, Kitty, and Warp natively support CSI u / Kitty
|
||
|
|
// keyboard protocol, which Claude Code already parses. No setup needed for
|
||
|
|
// these terminals.
|
||
|
|
return platform() === 'darwin' && env.terminal === 'Apple_Terminal' || env.terminal === 'vscode' || env.terminal === 'cursor' || env.terminal === 'windsurf' || env.terminal === 'alacritty' || env.terminal === 'zed';
|
||
|
|
}
|
||
|
|
export async function setupTerminal(theme: ThemeName): Promise<string> {
|
||
|
|
let result = '';
|
||
|
|
switch (env.terminal) {
|
||
|
|
case 'Apple_Terminal':
|
||
|
|
result = await enableOptionAsMetaForTerminal(theme);
|
||
|
|
break;
|
||
|
|
case 'vscode':
|
||
|
|
result = await installBindingsForVSCodeTerminal('VSCode', theme);
|
||
|
|
break;
|
||
|
|
case 'cursor':
|
||
|
|
result = await installBindingsForVSCodeTerminal('Cursor', theme);
|
||
|
|
break;
|
||
|
|
case 'windsurf':
|
||
|
|
result = await installBindingsForVSCodeTerminal('Windsurf', theme);
|
||
|
|
break;
|
||
|
|
case 'alacritty':
|
||
|
|
result = await installBindingsForAlacritty(theme);
|
||
|
|
break;
|
||
|
|
case 'zed':
|
||
|
|
result = await installBindingsForZed(theme);
|
||
|
|
break;
|
||
|
|
case null:
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
saveGlobalConfig(current => {
|
||
|
|
if (['vscode', 'cursor', 'windsurf', 'alacritty', 'zed'].includes(env.terminal ?? '')) {
|
||
|
|
if (current.shiftEnterKeyBindingInstalled === true) return current;
|
||
|
|
return {
|
||
|
|
...current,
|
||
|
|
shiftEnterKeyBindingInstalled: true
|
||
|
|
};
|
||
|
|
} else if (env.terminal === 'Apple_Terminal') {
|
||
|
|
if (current.optionAsMetaKeyInstalled === true) return current;
|
||
|
|
return {
|
||
|
|
...current,
|
||
|
|
optionAsMetaKeyInstalled: true
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return current;
|
||
|
|
});
|
||
|
|
maybeMarkProjectOnboardingComplete();
|
||
|
|
|
||
|
|
// Install shell completions (ant-only, since the completion command is ant-only)
|
||
|
|
if ("external" === 'ant') {
|
||
|
|
result += await setupShellCompletion(theme);
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
export function isShiftEnterKeyBindingInstalled(): boolean {
|
||
|
|
return getGlobalConfig().shiftEnterKeyBindingInstalled === true;
|
||
|
|
}
|
||
|
|
export function hasUsedBackslashReturn(): boolean {
|
||
|
|
return getGlobalConfig().hasUsedBackslashReturn === true;
|
||
|
|
}
|
||
|
|
export function markBackslashReturnUsed(): void {
|
||
|
|
const config = getGlobalConfig();
|
||
|
|
if (!config.hasUsedBackslashReturn) {
|
||
|
|
saveGlobalConfig(current => ({
|
||
|
|
...current,
|
||
|
|
hasUsedBackslashReturn: true
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext & LocalJSXCommandContext, _args: string): Promise<null> {
|
||
|
|
if (env.terminal && env.terminal in NATIVE_CSIU_TERMINALS) {
|
||
|
|
const message = `Shift+Enter is natively supported in ${NATIVE_CSIU_TERMINALS[env.terminal]}.
|
||
|
|
|
||
|
|
No configuration needed. Just use Shift+Enter to add newlines.`;
|
||
|
|
onDone(message);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if terminal is supported
|
||
|
|
if (!shouldOfferTerminalSetup()) {
|
||
|
|
const terminalName = env.terminal || 'your current terminal';
|
||
|
|
const currentPlatform = getPlatform();
|
||
|
|
|
||
|
|
// Build platform-specific terminal suggestions
|
||
|
|
let platformTerminals = '';
|
||
|
|
if (currentPlatform === 'macos') {
|
||
|
|
platformTerminals = ' • macOS: Apple Terminal\n';
|
||
|
|
} else if (currentPlatform === 'windows') {
|
||
|
|
platformTerminals = ' • Windows: Windows Terminal\n';
|
||
|
|
}
|
||
|
|
// For Linux and other platforms, we don't show native terminal options
|
||
|
|
// since they're not currently supported
|
||
|
|
|
||
|
|
const message = `Terminal setup cannot be run from ${terminalName}.
|
||
|
|
|
||
|
|
This command configures a convenient Shift+Enter shortcut for multi-line prompts.
|
||
|
|
${chalk.dim('Note: You can already use backslash (\\\\) + return to add newlines.')}
|
||
|
|
|
||
|
|
To set up the shortcut (optional):
|
||
|
|
1. Exit tmux/screen temporarily
|
||
|
|
2. Run /terminal-setup directly in one of these terminals:
|
||
|
|
${platformTerminals} • IDE: VSCode, Cursor, Windsurf, Zed
|
||
|
|
• Other: Alacritty
|
||
|
|
3. Return to tmux/screen - settings will persist
|
||
|
|
|
||
|
|
${chalk.dim('Note: iTerm2, WezTerm, Ghostty, Kitty, and Warp support Shift+Enter natively.')}`;
|
||
|
|
onDone(message);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const result = await setupTerminal(context.options.theme);
|
||
|
|
onDone(result);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
type VSCodeKeybinding = {
|
||
|
|
key: string;
|
||
|
|
command: string;
|
||
|
|
args: {
|
||
|
|
text: string;
|
||
|
|
};
|
||
|
|
when: string;
|
||
|
|
};
|
||
|
|
async function installBindingsForVSCodeTerminal(editor: 'VSCode' | 'Cursor' | 'Windsurf' = 'VSCode', theme: ThemeName): Promise<string> {
|
||
|
|
// Check if we're running in a VSCode Remote SSH session
|
||
|
|
// In this case, keybindings need to be installed on the LOCAL machine
|
||
|
|
if (isVSCodeRemoteSSH()) {
|
||
|
|
return `${color('warning', theme)(`Cannot install keybindings from a remote ${editor} session.`)}${EOL}${EOL}${editor} keybindings must be installed on your local machine, not the remote server.${EOL}${EOL}To install the Shift+Enter keybinding:${EOL}1. Open ${editor} on your local machine (not connected to remote)${EOL}2. Open the Command Palette (Cmd/Ctrl+Shift+P) → "Preferences: Open Keyboard Shortcuts (JSON)"${EOL}3. Add this keybinding (the file must be a JSON array):${EOL}${EOL}${chalk.dim(`[
|
||
|
|
{
|
||
|
|
"key": "shift+enter",
|
||
|
|
"command": "workbench.action.terminal.sendSequence",
|
||
|
|
"args": { "text": "\\u001b\\r" },
|
||
|
|
"when": "terminalFocus"
|
||
|
|
}
|
||
|
|
]`)}${EOL}`;
|
||
|
|
}
|
||
|
|
const editorDir = editor === 'VSCode' ? 'Code' : editor;
|
||
|
|
const userDirPath = join(homedir(), platform() === 'win32' ? join('AppData', 'Roaming', editorDir, 'User') : platform() === 'darwin' ? join('Library', 'Application Support', editorDir, 'User') : join('.config', editorDir, 'User'));
|
||
|
|
const keybindingsPath = join(userDirPath, 'keybindings.json');
|
||
|
|
try {
|
||
|
|
// Ensure user directory exists (idempotent with recursive)
|
||
|
|
await mkdir(userDirPath, {
|
||
|
|
recursive: true
|
||
|
|
});
|
||
|
|
|
||
|
|
// Read existing keybindings file, or default to empty array if it doesn't exist
|
||
|
|
let content = '[]';
|
||
|
|
let keybindings: VSCodeKeybinding[] = [];
|
||
|
|
let fileExists = false;
|
||
|
|
try {
|
||
|
|
content = await readFile(keybindingsPath, {
|
||
|
|
encoding: 'utf-8'
|
||
|
|
});
|
||
|
|
fileExists = true;
|
||
|
|
keybindings = safeParseJSONC(content) as VSCodeKeybinding[] ?? [];
|
||
|
|
} catch (e: unknown) {
|
||
|
|
if (!isFsInaccessible(e)) throw e;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Backup the existing file before modifying it
|
||
|
|
if (fileExists) {
|
||
|
|
const randomSha = randomBytes(4).toString('hex');
|
||
|
|
const backupPath = `${keybindingsPath}.${randomSha}.bak`;
|
||
|
|
try {
|
||
|
|
await copyFile(keybindingsPath, backupPath);
|
||
|
|
} catch {
|
||
|
|
return `${color('warning', theme)(`Error backing up existing ${editor} terminal keybindings. Bailing out.`)}${EOL}${chalk.dim(`See ${formatPathLink(keybindingsPath)}`)}${EOL}${chalk.dim(`Backup path: ${formatPathLink(backupPath)}`)}${EOL}`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if keybinding already exists
|
||
|
|
const existingBinding = keybindings.find(binding => binding.key === 'shift+enter' && binding.command === 'workbench.action.terminal.sendSequence' && binding.when === 'terminalFocus');
|
||
|
|
if (existingBinding) {
|
||
|
|
return `${color('warning', theme)(`Found existing ${editor} terminal Shift+Enter key binding. Remove it to continue.`)}${EOL}${chalk.dim(`See ${formatPathLink(keybindingsPath)}`)}${EOL}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create the new keybinding
|
||
|
|
const newKeybinding: VSCodeKeybinding = {
|
||
|
|
key: 'shift+enter',
|
||
|
|
command: 'workbench.action.terminal.sendSequence',
|
||
|
|
args: {
|
||
|
|
text: '\u001b\r'
|
||
|
|
},
|
||
|
|
when: 'terminalFocus'
|
||
|
|
};
|
||
|
|
|
||
|
|
// Modify the content by adding the new keybinding while preserving comments and formatting
|
||
|
|
const updatedContent = addItemToJSONCArray(content, newKeybinding);
|
||
|
|
|
||
|
|
// Write the updated content back to the file
|
||
|
|
await writeFile(keybindingsPath, updatedContent, {
|
||
|
|
encoding: 'utf-8'
|
||
|
|
});
|
||
|
|
return `${color('success', theme)(`Installed ${editor} terminal Shift+Enter key binding`)}${EOL}${chalk.dim(`See ${formatPathLink(keybindingsPath)}`)}${EOL}`;
|
||
|
|
} catch (error) {
|
||
|
|
logError(error);
|
||
|
|
throw new Error(`Failed to install ${editor} terminal Shift+Enter key binding`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
async function enableOptionAsMetaForProfile(profileName: string): Promise<boolean> {
|
||
|
|
// First try to add the property (in case it doesn't exist)
|
||
|
|
// Quote the profile name to handle names with spaces (e.g., "Man Page", "Red Sands")
|
||
|
|
const {
|
||
|
|
code: addCode
|
||
|
|
} = await execFileNoThrow('/usr/libexec/PlistBuddy', ['-c', `Add :'Window Settings':'${profileName}':useOptionAsMetaKey bool true`, getTerminalPlistPath()]);
|
||
|
|
|
||
|
|
// If adding fails (likely because it already exists), try setting it instead
|
||
|
|
if (addCode !== 0) {
|
||
|
|
const {
|
||
|
|
code: setCode
|
||
|
|
} = await execFileNoThrow('/usr/libexec/PlistBuddy', ['-c', `Set :'Window Settings':'${profileName}':useOptionAsMetaKey true`, getTerminalPlistPath()]);
|
||
|
|
if (setCode !== 0) {
|
||
|
|
logError(new Error(`Failed to enable Option as Meta key for Terminal.app profile: ${profileName}`));
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
async function disableAudioBellForProfile(profileName: string): Promise<boolean> {
|
||
|
|
// First try to add the property (in case it doesn't exist)
|
||
|
|
// Quote the profile name to handle names with spaces (e.g., "Man Page", "Red Sands")
|
||
|
|
const {
|
||
|
|
code: addCode
|
||
|
|
} = await execFileNoThrow('/usr/libexec/PlistBuddy', ['-c', `Add :'Window Settings':'${profileName}':Bell bool false`, getTerminalPlistPath()]);
|
||
|
|
|
||
|
|
// If adding fails (likely because it already exists), try setting it instead
|
||
|
|
if (addCode !== 0) {
|
||
|
|
const {
|
||
|
|
code: setCode
|
||
|
|
} = await execFileNoThrow('/usr/libexec/PlistBuddy', ['-c', `Set :'Window Settings':'${profileName}':Bell false`, getTerminalPlistPath()]);
|
||
|
|
if (setCode !== 0) {
|
||
|
|
logError(new Error(`Failed to disable audio bell for Terminal.app profile: ${profileName}`));
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Enable Option as Meta key for Terminal.app
|
||
|
|
async function enableOptionAsMetaForTerminal(theme: ThemeName): Promise<string> {
|
||
|
|
try {
|
||
|
|
// Create a backup of the current plist file
|
||
|
|
const backupPath = await backupTerminalPreferences();
|
||
|
|
if (!backupPath) {
|
||
|
|
throw new Error('Failed to create backup of Terminal.app preferences, bailing out');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Read the current default profile from the plist
|
||
|
|
const {
|
||
|
|
stdout: defaultProfile,
|
||
|
|
code: readCode
|
||
|
|
} = await execFileNoThrow('defaults', ['read', 'com.apple.Terminal', 'Default Window Settings']);
|
||
|
|
if (readCode !== 0 || !defaultProfile.trim()) {
|
||
|
|
throw new Error('Failed to read default Terminal.app profile');
|
||
|
|
}
|
||
|
|
const {
|
||
|
|
stdout: startupProfile,
|
||
|
|
code: startupCode
|
||
|
|
} = await execFileNoThrow('defaults', ['read', 'com.apple.Terminal', 'Startup Window Settings']);
|
||
|
|
if (startupCode !== 0 || !startupProfile.trim()) {
|
||
|
|
throw new Error('Failed to read startup Terminal.app profile');
|
||
|
|
}
|
||
|
|
let wasAnyProfileUpdated = false;
|
||
|
|
const defaultProfileName = defaultProfile.trim();
|
||
|
|
const optionAsMetaEnabled = await enableOptionAsMetaForProfile(defaultProfileName);
|
||
|
|
const audioBellDisabled = await disableAudioBellForProfile(defaultProfileName);
|
||
|
|
if (optionAsMetaEnabled || audioBellDisabled) {
|
||
|
|
wasAnyProfileUpdated = true;
|
||
|
|
}
|
||
|
|
const startupProfileName = startupProfile.trim();
|
||
|
|
|
||
|
|
// Only proceed if the startup profile is different from the default profile
|
||
|
|
if (startupProfileName !== defaultProfileName) {
|
||
|
|
const startupOptionAsMetaEnabled = await enableOptionAsMetaForProfile(startupProfileName);
|
||
|
|
const startupAudioBellDisabled = await disableAudioBellForProfile(startupProfileName);
|
||
|
|
if (startupOptionAsMetaEnabled || startupAudioBellDisabled) {
|
||
|
|
wasAnyProfileUpdated = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!wasAnyProfileUpdated) {
|
||
|
|
throw new Error('Failed to enable Option as Meta key or disable audio bell for any Terminal.app profile');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Flush the preferences cache
|
||
|
|
await execFileNoThrow('killall', ['cfprefsd']);
|
||
|
|
markTerminalSetupComplete();
|
||
|
|
return `${color('success', theme)(`Configured Terminal.app settings:`)}${EOL}${color('success', theme)('- Enabled "Use Option as Meta key"')}${EOL}${color('success', theme)('- Switched to visual bell')}${EOL}${chalk.dim('Option+Enter will now enter a newline.')}${EOL}${chalk.dim('You must restart Terminal.app for changes to take effect.', theme)}${EOL}`;
|
||
|
|
} catch (error) {
|
||
|
|
logError(error);
|
||
|
|
|
||
|
|
// Attempt to restore from backup
|
||
|
|
const restoreResult = await checkAndRestoreTerminalBackup();
|
||
|
|
const errorMessage = 'Failed to enable Option as Meta key for Terminal.app.';
|
||
|
|
if (restoreResult.status === 'restored') {
|
||
|
|
throw new Error(`${errorMessage} Your settings have been restored from backup.`);
|
||
|
|
} else if (restoreResult.status === 'failed') {
|
||
|
|
throw new Error(`${errorMessage} Restoring from backup failed, try manually with: defaults import com.apple.Terminal ${restoreResult.backupPath}`);
|
||
|
|
} else {
|
||
|
|
throw new Error(`${errorMessage} No backup was available to restore from.`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
async function installBindingsForAlacritty(theme: ThemeName): Promise<string> {
|
||
|
|
const ALACRITTY_KEYBINDING = `[[keyboard.bindings]]
|
||
|
|
key = "Return"
|
||
|
|
mods = "Shift"
|
||
|
|
chars = "\\u001B\\r"`;
|
||
|
|
|
||
|
|
// Get Alacritty config file paths in order of preference
|
||
|
|
const configPaths: string[] = [];
|
||
|
|
|
||
|
|
// XDG config path (Linux and macOS)
|
||
|
|
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
|
||
|
|
if (xdgConfigHome) {
|
||
|
|
configPaths.push(join(xdgConfigHome, 'alacritty', 'alacritty.toml'));
|
||
|
|
} else {
|
||
|
|
configPaths.push(join(homedir(), '.config', 'alacritty', 'alacritty.toml'));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Windows-specific path
|
||
|
|
if (platform() === 'win32') {
|
||
|
|
const appData = process.env.APPDATA;
|
||
|
|
if (appData) {
|
||
|
|
configPaths.push(join(appData, 'alacritty', 'alacritty.toml'));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Find existing config file by attempting to read it, or use first preferred path
|
||
|
|
let configPath: string | null = null;
|
||
|
|
let configContent = '';
|
||
|
|
let configExists = false;
|
||
|
|
for (const path of configPaths) {
|
||
|
|
try {
|
||
|
|
configContent = await readFile(path, {
|
||
|
|
encoding: 'utf-8'
|
||
|
|
});
|
||
|
|
configPath = path;
|
||
|
|
configExists = true;
|
||
|
|
break;
|
||
|
|
} catch (e: unknown) {
|
||
|
|
if (!isFsInaccessible(e)) throw e;
|
||
|
|
// File missing or inaccessible — try next config path
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// If no config exists, use the first path (XDG/default location)
|
||
|
|
if (!configPath) {
|
||
|
|
configPath = configPaths[0] ?? null;
|
||
|
|
}
|
||
|
|
if (!configPath) {
|
||
|
|
throw new Error('No valid config path found for Alacritty');
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
if (configExists) {
|
||
|
|
// Check if keybinding already exists (look for Shift+Return binding)
|
||
|
|
if (configContent.includes('mods = "Shift"') && configContent.includes('key = "Return"')) {
|
||
|
|
return `${color('warning', theme)('Found existing Alacritty Shift+Enter key binding. Remove it to continue.')}${EOL}${chalk.dim(`See ${formatPathLink(configPath)}`)}${EOL}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create backup
|
||
|
|
const randomSha = randomBytes(4).toString('hex');
|
||
|
|
const backupPath = `${configPath}.${randomSha}.bak`;
|
||
|
|
try {
|
||
|
|
await copyFile(configPath, backupPath);
|
||
|
|
} catch {
|
||
|
|
return `${color('warning', theme)('Error backing up existing Alacritty config. Bailing out.')}${EOL}${chalk.dim(`See ${formatPathLink(configPath)}`)}${EOL}${chalk.dim(`Backup path: ${formatPathLink(backupPath)}`)}${EOL}`;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Ensure config directory exists (idempotent with recursive)
|
||
|
|
await mkdir(dirname(configPath), {
|
||
|
|
recursive: true
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add the keybinding to the config
|
||
|
|
let updatedContent = configContent;
|
||
|
|
if (configContent && !configContent.endsWith('\n')) {
|
||
|
|
updatedContent += '\n';
|
||
|
|
}
|
||
|
|
updatedContent += '\n' + ALACRITTY_KEYBINDING + '\n';
|
||
|
|
|
||
|
|
// Write the updated config
|
||
|
|
await writeFile(configPath, updatedContent, {
|
||
|
|
encoding: 'utf-8'
|
||
|
|
});
|
||
|
|
return `${color('success', theme)('Installed Alacritty Shift+Enter key binding')}${EOL}${color('success', theme)('You may need to restart Alacritty for changes to take effect')}${EOL}${chalk.dim(`See ${formatPathLink(configPath)}`)}${EOL}`;
|
||
|
|
} catch (error) {
|
||
|
|
logError(error);
|
||
|
|
throw new Error('Failed to install Alacritty Shift+Enter key binding');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
async function installBindingsForZed(theme: ThemeName): Promise<string> {
|
||
|
|
// Zed uses JSON keybindings similar to VSCode
|
||
|
|
const zedDir = join(homedir(), '.config', 'zed');
|
||
|
|
const keymapPath = join(zedDir, 'keymap.json');
|
||
|
|
try {
|
||
|
|
// Ensure zed directory exists (idempotent with recursive)
|
||
|
|
await mkdir(zedDir, {
|
||
|
|
recursive: true
|
||
|
|
});
|
||
|
|
|
||
|
|
// Read existing keymap file, or default to empty array if it doesn't exist
|
||
|
|
let keymapContent = '[]';
|
||
|
|
let fileExists = false;
|
||
|
|
try {
|
||
|
|
keymapContent = await readFile(keymapPath, {
|
||
|
|
encoding: 'utf-8'
|
||
|
|
});
|
||
|
|
fileExists = true;
|
||
|
|
} catch (e: unknown) {
|
||
|
|
if (!isFsInaccessible(e)) throw e;
|
||
|
|
}
|
||
|
|
if (fileExists) {
|
||
|
|
// Check if keybinding already exists
|
||
|
|
if (keymapContent.includes('shift-enter')) {
|
||
|
|
return `${color('warning', theme)('Found existing Zed Shift+Enter key binding. Remove it to continue.')}${EOL}${chalk.dim(`See ${formatPathLink(keymapPath)}`)}${EOL}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create backup
|
||
|
|
const randomSha = randomBytes(4).toString('hex');
|
||
|
|
const backupPath = `${keymapPath}.${randomSha}.bak`;
|
||
|
|
try {
|
||
|
|
await copyFile(keymapPath, backupPath);
|
||
|
|
} catch {
|
||
|
|
return `${color('warning', theme)('Error backing up existing Zed keymap. Bailing out.')}${EOL}${chalk.dim(`See ${formatPathLink(keymapPath)}`)}${EOL}${chalk.dim(`Backup path: ${formatPathLink(backupPath)}`)}${EOL}`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse and modify the keymap
|
||
|
|
let keymap: Array<{
|
||
|
|
context?: string;
|
||
|
|
bindings: Record<string, string | string[]>;
|
||
|
|
}>;
|
||
|
|
try {
|
||
|
|
keymap = jsonParse(keymapContent);
|
||
|
|
if (!Array.isArray(keymap)) {
|
||
|
|
keymap = [];
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
keymap = [];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add the new keybinding for terminal context
|
||
|
|
keymap.push({
|
||
|
|
context: 'Terminal',
|
||
|
|
bindings: {
|
||
|
|
'shift-enter': ['terminal::SendText', '\u001b\r']
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Write the updated keymap
|
||
|
|
await writeFile(keymapPath, jsonStringify(keymap, null, 2) + '\n', {
|
||
|
|
encoding: 'utf-8'
|
||
|
|
});
|
||
|
|
return `${color('success', theme)('Installed Zed Shift+Enter key binding')}${EOL}${chalk.dim(`See ${formatPathLink(keymapPath)}`)}${EOL}`;
|
||
|
|
} catch (error) {
|
||
|
|
logError(error);
|
||
|
|
throw new Error('Failed to install Zed Shift+Enter key binding');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJjaGFsayIsInJhbmRvbUJ5dGVzIiwiY29weUZpbGUiLCJta2RpciIsInJlYWRGaWxlIiwid3JpdGVGaWxlIiwiaG9tZWRpciIsInBsYXRmb3JtIiwiZGlybmFtZSIsImpvaW4iLCJUaGVtZU5hbWUiLCJwYXRoVG9GaWxlVVJMIiwic3VwcG9ydHNIeXBlcmxpbmtzIiwiY29sb3IiLCJtYXliZU1hcmtQcm9qZWN0T25ib2FyZGluZ0NvbXBsZXRlIiwiVG9vbFVzZUNvbnRleHQiLCJMb2NhbEpTWENvbW1hbmRDb250ZXh0IiwiTG9jYWxKU1hDb21tYW5kT25Eb25lIiwiYmFja3VwVGVybWluYWxQcmVmZXJlbmNlcyIsImNoZWNrQW5kUmVzdG9yZVRlcm1pbmFsQmFja3VwIiwiZ2V0VGVybWluYWxQbGlzdFBhdGgiLCJtYXJrVGVybWluYWxTZXR1cENvbXBsZXRlIiwic2V0dXBTaGVsbENvbXBsZXRpb24iLCJnZXRHbG9iYWxDb25maWciLCJzYXZlR2xvYmFsQ29uZmlnIiwiZW52IiwiaXNGc0luYWNjZXNzaWJsZSIsImV4ZWNGaWxlTm9UaHJvdyIsImFkZEl0ZW1Ub0pTT05DQXJyYXkiLCJzYWZlUGFyc2VKU09OQyIsImxvZ0Vycm9yIiwiZ2V0UGxhdGZvcm0iLCJqc29uUGFyc2UiLCJqc29uU3RyaW5naWZ5IiwiRU9MIiwiTkFUSVZFX0NTSVVfVEVSTUlOQUxTIiwiUmVjb3JkIiwiZ2hvc3R0eSIsImtpdHR5IiwiV2V6VGVybSIsIldhcnBUZXJtaW5hbCIsImlzVlNDb2RlUmVtb3RlU1NIIiwiYXNrcGFzc01haW4iLCJwcm9jZXNzIiwiVlNDT0RFX0dJVF9BU0tQQVNTX01BSU4iLCJwYXRoIiwiUEFUSCIsImluY2x1ZGVzIiwiZ2V0TmF0aXZlQ1NJdVRlcm1pbmFsRGlzcGxheU5hbWUiLCJ0ZXJtaW5hbCIsImZvcm1hdFBhdGhMaW5rIiwiZmlsZVBhdGgiLCJmaWxlVXJsIiwiaHJlZiIsInNob3VsZE9mZmVyVGVybWluYWxTZXR1cCIsInNldHVwVGVybWluYWwiLCJ0aGVtZSIsIlByb21pc2UiLCJyZXN1bHQiLCJlbmFibGVPcHRpb25Bc01ldGFGb3JUZXJtaW5hbCIsImluc3RhbGxCaW5kaW5nc0ZvclZTQ29kZVRlcm1pbmFsIiwiaW5zdGFsbEJpbmRpbmdzRm9yQWxhY3JpdHR5IiwiaW5zdGFsbEJpbmRpbmdzRm9yWmVkIiwiY3VycmVudCIsInNoaWZ0RW50ZXJLZXlCaW5kaW5nSW5zdGFsbGVkIiwib3B0aW9uQXNNZXRhS2V5SW5zdGFsbGVkIiwiaXNTaGlmdEVudGVyS2V5QmluZGluZ0luc3RhbGxlZCIsImhhc1VzZWRCYWNrc2xhc2hSZXR1cm4iLCJtYXJrQmFja3NsYXNoUmV0dXJuVXNlZCIsImNvbmZpZyIsImNhbGwiLCJvbkRvbmUiLCJjb250ZXh0IiwiX2FyZ3MiLCJtZXNzYWdlIiwidGVybWluYWxOYW1lIiwiY3VycmVudFBsYXRmb3JtIiwicGxhdGZvcm1UZXJtaW5hbHMiLCJkaW0iLCJvcHRpb25zIiwiVlNDb2RlS2V5YmluZGluZyIsImtleSIsImNvbW1hbmQiLCJhcmdzIiwidGV4dCIsIndoZW4iLCJlZGl0b3IiLCJlZGl0b3JEaXIiLCJ1c2VyRGlyUGF0aCIsImtleWJpbmRpbmdzUGF0aCIsInJlY3Vyc2l2ZSIsImNvbnRlbnQiLCJrZXliaW5kaW5ncyIsImZpbGVFeGlzdHMiLCJlbmNvZGluZyIsImUiLCJyYW5kb21TaGEiLCJ0b1N0cmluZyIsImJhY2t1cFBhdGgiLCJleGlzdGluZ0JpbmRpbmciLCJmaW5kIiwiYmluZGluZyIsIm5ld0tleWJpbmRpbmciLCJ1cGRhdGVkQ29udGVudCIsImVycm9yIiwiRXJyb3IiLCJlbmFibGVPcHRpb25Bc01ldGFGb3JQcm9maWxlIiwicHJvZmlsZU5hbWUiLCJjb2RlIiwiYWRkQ29kZSIsInNldENvZGUiLCJkaXNhYmxlQXVkaW9CZWxsRm9yUHJvZmlsZSIsInN0ZG91dCIsImRlZmF1bHRQcm9maWxlIiwicmVhZENvZGUiLCJ0cmltIiwic3RhcnR1cFByb2ZpbGUiLCJzdGFydHVwQ29kZSIsIndhc0FueVByb2ZpbGVVcGRhdGVkIiwiZGVmYXVsdFByb2ZpbGVOYW1lIiwib3B0aW9uQXNNZXRhRW5hYmxlZCIsImF1ZGlvQmVsbERpc2FibGVkIiwic3RhcnR1cFByb2ZpbGVOYW1lIiwic3RhcnR1cE9wdGlvbkFzTWV0YUVuYWJsZWQiLCJzdGFydHVwQXVkaW9CZWxsRGlzYWJsZWQiLCJyZXN0b3JlUmVzdWx0IiwiZXJyb3JNZXNzYWdlIiwic3RhdHVzIiwiQUxBQ1JJVFRZX0tFWUJJTkRJTkciLCJjb25maWdQYXRocyIsInhkZ0NvbmZpZ0hvbWUiLCJYREdfQ09ORklHX0hPTUUiLCJwdXNoIiwiYXBwRGF0YSIsIkFQUERBVEEiLCJjb25maWdQYXRoIiwiY29uZmlnQ29udGVudCIsImNvbmZpZ0V4aXN0cyIsImVuZHNXaXRoIiwiemVkRGlyIiwia2V5bWFwUGF0aCIsImtleW1hcENvbnRlbnQiLCJrZXltYXAiLCJBcnJheSIsImJpbmRpbmdzIiwiaXNBcnJheSJdLCJzb3VyY2VzIjpbInRlcm1pbmFsU2V0dXAudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBjaGFsayBmcm9tICdjaGFsaydcbmltcG9ydCB7IHJhbmRvbUJ5dGVzIH0gZnJvbSAnY3J5cHRvJ1xuaW1wb3J0IHsgY29weUZpbGUsIG1rZGlyLCByZWFkRmlsZSwgd3JpdGVGaWxlIH0gZnJvbSAnZnMvcHJvbWlzZXMnXG5pbXBvcnQgeyBob21lZGlyLCBwbGF0Zm9ybSB9IGZyb20gJ29zJ1xuaW1wb3J0IHsgZGlybmFtZSwgam9pbiB9IGZyb20gJ3BhdGgnXG5pbXBvcnQgdHlwZSB7IFRoZW1lTmFtZSB9IGZyb20gJ3NyYy91dGlscy90aGVtZS5qcydcbmltcG9ydCB7IHBhdGhUb0ZpbGVVUkwgfSBmcm9tICd1cmwnXG5pbXBvcnQgeyBzdXBwb3J0c0h5cGVybGlua3MgfSBmcm9tICcuLi8uLi9pbmsvc3VwcG9ydHMtaHlwZXJsaW5rcy5qcydcbmltcG9ydCB7IGNvbG9yIH0gZnJvbSAnLi4vLi4vaW5rLmpzJ1xuaW1wb3J0IHsgbWF5YmVNYXJrUHJvamVjdE9uYm9hcmRpbmdDb21wbGV0ZSB9IGZyb20gJy4uLy4uL3Byb2plY3RPbmJvYXJkaW5nU3RhdGUuanMnXG5pbXBvcnQgdHlwZSB7IFRvb2xVc2VDb250ZXh0IH0gZnJvbSAnLi4vLi4vVG9vbC5qcydcbmltcG9ydCB0eXBlIHtcbiAgTG9jYWxKU1hDb21tYW5kQ29udGV4dCxcbiAgTG9jYWxKU1hDb21tYW5kT25Eb25lLFxufSBmcm9tICcuLi8uLi90eXBlcy9jb21tYW5kLmpzJ1xuaW1wb3J0IHtcbiAgYmFja3VwVGVybWluYWxQcmVmZXJlbmNlcyxcbiAgY2hlY2tBbmRSZXN0b3JlVGVybWluYWxCYWNrdXAsXG4gIGdldFRlcm1pbmFsUGxpc3RQYXR
|