import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' export type SupportedTerminal = 'cursor' | 'vscode' | 'windsurf' export type FileOps = { copyFile: typeof copyFile mkdir: typeof mkdir readFile: typeof readFile writeFile: typeof writeFile } type Keybinding = { args?: { text?: string } command?: string key?: string when?: string } export type TerminalSetupResult = { message: string requiresRestart?: boolean success: boolean } const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile } const COPY_SEQUENCE = '\u001b[99;13u' const MULTILINE_SEQUENCE = '\\\r\n' const TERMINAL_META: Record = { vscode: { appName: 'Code', label: 'VS Code' }, cursor: { appName: 'Cursor', label: 'Cursor' }, windsurf: { appName: 'Windsurf', label: 'Windsurf' } } const MAC_COPY_BINDING: Keybinding = { key: 'cmd+c', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus && terminalTextSelected', args: { text: COPY_SEQUENCE } } const BASE_BINDINGS: Keybinding[] = [ { key: 'shift+enter', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: MULTILINE_SEQUENCE } }, { key: 'ctrl+enter', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: MULTILINE_SEQUENCE } }, { key: 'cmd+enter', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: MULTILINE_SEQUENCE } }, { key: 'cmd+z', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\u001b[122;9u' } }, { key: 'shift+cmd+z', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\u001b[122;10u' } } ] const targetBindings = (platform: NodeJS.Platform): Keybinding[] => platform === 'darwin' ? [MAC_COPY_BINDING, ...BASE_BINDINGS] : BASE_BINDINGS export function detectVSCodeLikeTerminal(env: NodeJS.ProcessEnv = process.env): null | SupportedTerminal { const askpass = env['VSCODE_GIT_ASKPASS_MAIN']?.toLowerCase() ?? '' if (env['CURSOR_TRACE_ID'] || askpass.includes('cursor')) { return 'cursor' } if (askpass.includes('windsurf')) { return 'windsurf' } if (env['TERM_PROGRAM'] === 'vscode' || env['VSCODE_GIT_IPC_HANDLE']) { return 'vscode' } return null } /** * Strip JSONC features (// line comments, /* block comments *\/, trailing commas) * so the result is valid JSON parseable by JSON.parse(). * Handles comments inside strings correctly (preserves them). */ export function stripJsonComments(content: string): string { let result = '' let i = 0 const len = content.length while (i < len) { const ch = content[i]! // String literal — copy as-is, including any comment-like chars inside if (ch === '"') { let j = i + 1 while (j < len) { if (content[j] === '\\') { j += 2 // skip escaped char } else if (content[j] === '"') { j++ break } else { j++ } } result += content.slice(i, j) i = j continue } // Line comment if (ch === '/' && content[i + 1] === '/') { const eol = content.indexOf('\n', i) i = eol === -1 ? len : eol continue } // Block comment if (ch === '/' && content[i + 1] === '*') { const end = content.indexOf('*/', i + 2) i = end === -1 ? len : end + 2 continue } result += ch i++ } // Remove trailing commas before ] or } return result.replace(/,(\s*[}\]])/g, '$1') } export function isRemoteShellSession(env: NodeJS.ProcessEnv): boolean { return Boolean(env['SSH_CONNECTION'] || env['SSH_TTY'] || env['SSH_CLIENT']) } export function getVSCodeStyleConfigDir( appName: string, platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, homeDir: string = homedir() ): null | string { if (platform === 'darwin') { return join(homeDir, 'Library', 'Application Support', appName, 'User') } if (platform === 'win32') { return env['APPDATA'] ? join(env['APPDATA'], appName, 'User') : null } return join(homeDir, '.config', appName, 'User') } function isKeybinding(value: unknown): value is Keybinding { return typeof value === 'object' && value !== null } function sameBinding(a: Keybinding, b: Keybinding): boolean { return a.key === b.key && a.command === b.command && a.when === b.when && a.args?.text === b.args?.text } type WhenRequirements = { forbidden: Set required: Set } const WHEN_TOKEN_RE = /!?[A-Za-z_][\w.]*/g function parseWhenRequirements(when: string): WhenRequirements { const required = new Set() const forbidden = new Set() for (const [token] of when.matchAll(WHEN_TOKEN_RE)) { if (token.startsWith('!')) { forbidden.add(token.slice(1)) } else { required.add(token) } } return { forbidden, required } } function requirementsContradict(a: WhenRequirements, b: WhenRequirements): boolean { for (const token of a.required) { if (b.forbidden.has(token)) { return true } } for (const token of b.required) { if (a.forbidden.has(token)) { return true } } return false } function whensOverlap(a: string, b: string): boolean { if (a === b) { return true } // Empty when = global, overlaps every context. if (!a || !b) { return true } const left = parseWhenRequirements(a) const right = parseWhenRequirements(b) if (requirementsContradict(left, right)) { return false } // This intentionally avoids a full VS Code when-clause parser. If two // same-key bindings share a positive context token and don't explicitly // contradict each other, they can fire together in that context. for (const token of left.required) { if (right.required.has(token)) { return true } } return false } // VS Code allows multiple bindings on the same key as long as their `when` // clauses don't overlap. We flag a conflict when the contexts overlap but // the bindings differ — e.g. existing `terminalFocus` cmd+c overlaps with // our `terminalFocus && terminalTextSelected`, so the existing binding // would shadow ours when text isn't selected. function bindingsConflict(existing: Keybinding, target: Keybinding): boolean { if (existing.key !== target.key) { return false } if (!whensOverlap(existing.when ?? '', target.when ?? '')) { return false } return !sameBinding(existing, target) } async function backupFile(filePath: string, ops: FileOps): Promise { const stamp = new Date().toISOString().replace(/[:.]/g, '-') await ops.copyFile(filePath, `${filePath}.backup.${stamp}`) } export async function configureTerminalKeybindings( terminal: SupportedTerminal, options?: { env?: NodeJS.ProcessEnv fileOps?: Partial homeDir?: string platform?: NodeJS.Platform } ): Promise { const env = options?.env ?? process.env const platform = options?.platform ?? process.platform const homeDir = options?.homeDir ?? homedir() const ops: FileOps = { ...DEFAULT_FILE_OPS, ...(options?.fileOps ?? {}) } const meta = TERMINAL_META[terminal] if (isRemoteShellSession(env)) { return { success: false, message: `${meta.label} terminal setup must be run on the local machine, not inside an SSH session.` } } const configDir = getVSCodeStyleConfigDir(meta.appName, platform, env, homeDir) if (!configDir) { return { success: false, message: `Could not determine ${meta.label} settings path on this platform.` } } const keybindingsFile = join(configDir, 'keybindings.json') try { await ops.mkdir(configDir, { recursive: true }) let keybindings: unknown[] = [] let hasExistingFile = false try { const content = await ops.readFile(keybindingsFile, 'utf8') hasExistingFile = true const parsed: unknown = JSON.parse(stripJsonComments(content)) if (!Array.isArray(parsed)) { return { success: false, message: `${meta.label} keybindings.json is not a JSON array: ${keybindingsFile}` } } keybindings = parsed } catch (error) { const code = (error as NodeJS.ErrnoException | undefined)?.code if (code !== 'ENOENT') { return { success: false, message: `Failed to read ${meta.label} keybindings: ${error}` } } } const targets = targetBindings(platform) const conflicts = targets.filter(target => keybindings.some(existing => isKeybinding(existing) && bindingsConflict(existing, target)) ) if (conflicts.length) { return { success: false, message: `Existing terminal keybindings would conflict in ${keybindingsFile}: ` + conflicts.map(c => c.key).join(', ') } } let added = 0 for (const target of targets.slice().reverse()) { const exists = keybindings.some(existing => isKeybinding(existing) && sameBinding(existing, target)) if (!exists) { keybindings.unshift(target) added += 1 } } if (!added) { return { success: true, message: `${meta.label} terminal keybindings already configured.` } } if (hasExistingFile) { await backupFile(keybindingsFile, ops) } await ops.writeFile(keybindingsFile, `${JSON.stringify(keybindings, null, 2)}\n`, 'utf8') return { success: true, requiresRestart: true, message: `Added ${added} ${meta.label} terminal keybinding${added === 1 ? '' : 's'} in ${keybindingsFile}` } } catch (error) { return { success: false, message: `Failed to configure ${meta.label} terminal shortcuts: ${error}` } } } export async function configureDetectedTerminalKeybindings(options?: { env?: NodeJS.ProcessEnv fileOps?: Partial homeDir?: string platform?: NodeJS.Platform }): Promise { const detected = detectVSCodeLikeTerminal(options?.env ?? process.env) if (!detected) { return { success: false, message: 'No supported IDE terminal detected. Supported: VS Code, Cursor, Windsurf.' } } return configureTerminalKeybindings(detected, options) } export async function shouldPromptForTerminalSetup(options?: { env?: NodeJS.ProcessEnv fileOps?: Partial homeDir?: string platform?: NodeJS.Platform }): Promise { const env = options?.env ?? process.env const detected = detectVSCodeLikeTerminal(env) if (!detected || isRemoteShellSession(env)) { return false } const platform = options?.platform ?? process.platform const homeDir = options?.homeDir ?? homedir() const ops: FileOps = { ...DEFAULT_FILE_OPS, ...(options?.fileOps ?? {}) } const meta = TERMINAL_META[detected] const configDir = getVSCodeStyleConfigDir(meta.appName, platform, env, homeDir) if (!configDir) { return false } try { const content = await ops.readFile(join(configDir, 'keybindings.json'), 'utf8') const parsed: unknown = JSON.parse(stripJsonComments(content)) if (!Array.isArray(parsed)) { return true } return targetBindings(platform).some( target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target)) ) } catch { return true } }