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 = { 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 { 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 (process.env.USER_TYPE === '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 { 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 { // 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 { // 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 { // 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 { 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 { 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 { // 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 }> 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') } }