| 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 MULTILINE_SEQUENCE = '\\\r\n' |
|
|
| const TERMINAL_META: Record<SupportedTerminal, { appName: string; label: string }> = { |
| vscode: { appName: 'Code', label: 'VS Code' }, |
| cursor: { appName: 'Cursor', label: 'Cursor' }, |
| windsurf: { appName: 'Windsurf', label: 'Windsurf' } |
| } |
|
|
| const TARGET_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' } |
| } |
| ] |
|
|
| 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 |
| } |
|
|
| |
| |
| |
| |
| |
| export function stripJsonComments(content: string): string { |
| let result = '' |
| let i = 0 |
| const len = content.length |
|
|
| while (i < len) { |
| const ch = content[i]! |
|
|
| |
| if (ch === '"') { |
| let j = i + 1 |
|
|
| while (j < len) { |
| if (content[j] === '\\') { |
| j += 2 |
| } else if (content[j] === '"') { |
| j++ |
|
|
| break |
| } else { |
| j++ |
| } |
| } |
|
|
| result += content.slice(i, j) |
| i = j |
|
|
| continue |
| } |
|
|
| |
| if (ch === '/' && content[i + 1] === '/') { |
| const eol = content.indexOf('\n', i) |
| i = eol === -1 ? len : eol |
|
|
| continue |
| } |
|
|
| |
| if (ch === '/' && content[i + 1] === '*') { |
| const end = content.indexOf('*/', i + 2) |
| i = end === -1 ? len : end + 2 |
|
|
| continue |
| } |
|
|
| result += ch |
| i++ |
| } |
|
|
| |
| 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 |
| } |
|
|
| async function backupFile(filePath: string, ops: FileOps): Promise<void> { |
| 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<FileOps> |
| homeDir?: string |
| platform?: NodeJS.Platform |
| } |
| ): Promise<TerminalSetupResult> { |
| 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 conflicts = TARGET_BINDINGS.filter(target => |
| keybindings.some( |
| existing => isKeybinding(existing) && existing.key === target.key && !sameBinding(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 TARGET_BINDINGS.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<FileOps> |
| homeDir?: string |
| platform?: NodeJS.Platform |
| }): Promise<TerminalSetupResult> { |
| 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<FileOps> |
| homeDir?: string |
| platform?: NodeJS.Platform |
| }): Promise<boolean> { |
| 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 TARGET_BINDINGS.some( |
| target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target)) |
| ) |
| } catch { |
| return true |
| } |
| } |
|
|