import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { spawnSync } from "node:child_process"; export type EditorType = "wait-cli" | "windows-gui"; export interface EditorConfig { id: string; name: string; type: EditorType; command: string; detectPaths?: string[]; registryNames?: string[]; } const SHORTCUT_DIR = "C:\\Tools"; const EDITORS: EditorConfig[] = [ { id: "vscode", name: "VS Code", type: "wait-cli", command: "code --wait" }, { id: "cursor", name: "Cursor", type: "wait-cli", command: "cursor --wait" }, { id: "windsurf", name: "Windsurf", type: "wait-cli", command: "windsurf --wait" }, { id: "sublime", name: "Sublime Text", type: "wait-cli", command: "subl -w" }, { id: "atom", name: "Atom", type: "wait-cli", command: "atom --wait" }, { id: "zed", name: "Zed", type: "wait-cli", command: "zed --wait" }, { id: "neovim", name: "Neovim", type: "wait-cli", command: "nvim" }, { id: "vim", name: "Vim", type: "wait-cli", command: "vim" }, { id: "emacs", name: "Emacs", type: "wait-cli", command: "emacs -nw" }, { id: "typora", name: "Typora", type: "windows-gui", command: "C:\\Program Files\\Typora\\Typora.exe", registryNames: ["Typora"], detectPaths: [ "C:\\Program Files\\Typora\\Typora.exe", "C:\\Program Files (x86)\\Typora\\Typora.exe", join(homedir(), "AppData", "Local", "Programs", "Typora", "Typora.exe"), ], }, { id: "marktext", name: "MarkText", type: "windows-gui", command: "C:\\Program Files\\MarkText\\MarkText.exe", registryNames: ["MarkText"], detectPaths: [ "C:\\Program Files\\MarkText\\MarkText.exe", "C:\\Program Files (x86)\\MarkText\\MarkText.exe", join(homedir(), "AppData", "Local", "Programs", "MarkText", "MarkText.exe"), ], }, { id: "notepad++", name: "Notepad++", type: "windows-gui", command: "C:\\Program Files\\Notepad++\\notepad++.exe", registryNames: ["Notepad++"], detectPaths: [ "C:\\Program Files\\Notepad++\\notepad++.exe", "C:\\Program Files (x86)\\Notepad++\\notepad++.exe", ], }, { id: "notepad", name: "Notepad", type: "windows-gui", command: "C:\\Windows\\notepad.exe", detectPaths: ["C:\\Windows\\notepad.exe", "C:\\Windows\\System32\\notepad.exe"], }, ]; const FALLBACK_PRIORITY = ["vscode", "cursor", "sublime", "notepad++", "typora", "marktext", "notepad"]; let cachedDetectionResults: Map | null = null; function detectAllEditors(): Map { if (cachedDetectionResults) return cachedDetectionResults; const results = new Map(); for (const editor of EDITORS) { if (editor.type !== "wait-cli") continue; const firstWord = editor.command.split(/\s+/)[0]; const r = spawnSync(process.platform === "win32" ? "where" : "which", [firstWord], { encoding: "utf8" }); if (r.status === 0 && r.stdout.trim()) { results.set(editor.id, r.stdout.trim().split(/\r?\n/)[0]); } } if (process.platform === "win32") { const allNames = EDITORS.filter(e => e.type === "windows-gui") .flatMap(e => e.registryNames ?? [e.name]) .filter((v, i, a) => a.indexOf(v) === i) .map(n => n.toLowerCase()); if (allNames.length > 0) { const detected = runWindowsEditorDetection(allNames); for (const [name, path] of detected) { const editor = EDITORS.find(e => e.type === "windows-gui" && (e.registryNames ?? [e.name]).some(n => n.toLowerCase() === name) ); if (editor && !results.has(editor.id)) results.set(editor.id, path); } } for (const editor of EDITORS) { if (editor.type !== "windows-gui" || results.has(editor.id)) continue; for (const p of editor.detectPaths ?? []) { if (existsSync(p)) { results.set(editor.id, p); break; } } } } cachedDetectionResults = results; return results; } function runWindowsEditorDetection(names: string[]): Map { const results = new Map(); if (names.length === 0) return results; const namesArray = names.map(n => `"${n}"`).join(", "); const script = `$names = @(${namesArray}) $nameSet = New-Object System.Collections.Generic.HashSet[string] $names | ForEach-Object { [void]$nameSet.Add($_) } $WshShell = New-Object -ComObject WScript.Shell $regKeys = @( "HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*", "HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*", "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*" ) Get-ItemProperty $regKeys -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -and $nameSet.Contains($_.DisplayName.ToLower()) } | ForEach-Object { Write-Output "registry|$($_.DisplayName.ToLower())|$($_.InstallLocation)" } $startMenuDirs = @("$env:APPDATA\\Microsoft\\Windows\\Start Menu\\Programs", "$env:ProgramData\\Microsoft\\Windows\\Start Menu\\Programs") Get-ChildItem $startMenuDirs -Filter "*.lnk" -Recurse -ErrorAction SilentlyContinue | ForEach-Object { $baseName = $_.BaseName.ToLower() if ($nameSet.Contains($baseName)) { $shortcut = $WshShell.CreateShortcut($_.FullName) Write-Output "shortcut|$baseName|$($shortcut.TargetPath)" } }`; const output = runPowerShellScript(script); for (const line of output.split(/\r?\n/)) { const [kind, name, rawPath] = line.trim().split("|", 3); if (!name || !rawPath) continue; if (existsSync(rawPath)) results.set(name.toLowerCase(), rawPath); } return results; } function resolveRegistryExePath(editor: EditorConfig, installLocation: string): string | null { const safeExeName = editor.command.split("\\").pop() ?? `${editor.name}.exe`; const p1 = join(installLocation, safeExeName); if (existsSync(p1)) return p1; const p2 = join(installLocation, `${editor.name}.exe`); if (existsSync(p2)) return p2; return null; } function findInstalledPath(editor: EditorConfig): string | null { const detected = detectAllEditors(); if (editor.type === "wait-cli") return detected.get(editor.id) ?? null; const raw = detected.get(editor.id); if (raw) { if (raw.toLowerCase().endsWith(".exe")) return raw; const r = resolveRegistryExePath(editor, raw); if (r) return r; } for (const p of editor.detectPaths ?? []) { if (existsSync(p)) return p; } return null; } function getInstalledEditors(): Array<{ editor: EditorConfig; path: string }> { return EDITORS.map(editor => ({ editor, path: findInstalledPath(editor) })) .filter((item): item is { editor: EditorConfig; path: string } => item.path !== null); } function runPowerShellScript(script: string): string { const path = join(homedir(), ".pi", "agent", "tmp-powershell.ps1"); try { writeFileSync(path, script, "utf8"); const r = spawnSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path], { encoding: "utf8" }); return r.status === 0 ? r.stdout.trim() : ""; } finally { try { unlinkSync(path); } catch {} } } function createWindowsShortcut(target: string, shortcutPath: string): void { mkdirSync(dirname(shortcutPath), { recursive: true }); const vbsPath = `${shortcutPath}.vbs`; const et = target.replace(/\\/g, "\\\\"); const es = shortcutPath.replace(/\\/g, "\\\\"); const vbs = [ 'Set WshShell = WScript.CreateObject("WScript.Shell")', `Set Shortcut = WshShell.CreateShortcut("${es}")`, `Shortcut.TargetPath = "${et}"`, "Shortcut.Save", ].join("\n"); writeFileSync(vbsPath, vbs, "utf8"); try { const r = spawnSync("cscript", ["/nologo", vbsPath], { encoding: "utf8" }); if (r.status !== 0) throw new Error(r.stderr || r.stdout || `cscript exit ${r.status}`); } finally { try { unlinkSync(vbsPath); } catch {} } } function getExternalEditorCommand(editor: EditorConfig, installedPath: string): string { if (editor.type === "wait-cli") return editor.command; const safeId = editor.id.replace(/[^a-z0-9]/gi, "-").toLowerCase(); const sp = `${SHORTCUT_DIR}\\${safeId}.lnk`; createWindowsShortcut(installedPath, sp); return `cmd /c start /w C:/Tools/${safeId}.lnk`; } function updateSettingsJson(externalEditor: string): void { const p = join(homedir(), ".pi", "agent", "settings.json"); let s: Record = {}; if (existsSync(p)) s = JSON.parse(readFileSync(p, "utf8")); s.externalEditor = externalEditor; writeFileSync(p, JSON.stringify(s, null, 2) + "\n", "utf8"); } function isSupportedPlatform(): boolean { return process.platform === "win32" || process.platform === "darwin" || process.platform === "linux"; } function getFallbackEditor(): { editor: EditorConfig; path: string } | null { for (const id of FALLBACK_PRIORITY) { const match = EDITORS.find(e => e.id === id); if (!match) continue; const path = findInstalledPath(match); if (path) return { editor: match, path }; } return null; } function configureEditor(match: { editor: EditorConfig; path: string }, ctx: ExtensionContext): void { updateSettingsJson(getExternalEditorCommand(match.editor, match.path)); ctx.ui.notify(`Set external editor to ${match.editor.name}. Run /reload to apply.`, "info"); } function listEditors(installed: Array<{ editor: EditorConfig; path: string }>, ctx: ExtensionContext): void { const lines = ["Supported editors:", ...EDITORS.map(editor => { const found = installed.find(i => i.editor.id === editor.id); return `- ${editor.id}: ${editor.name} ${found ? "[found]" : "[not found]"}`; })]; ctx.ui.notify(lines.join("\n"), "info"); } function findEditorByIdOrName( installed: Array<{ editor: EditorConfig; path: string }>, query: string, ): { editor: EditorConfig; path: string } | null { const lower = query.toLowerCase(); return installed.find(i => i.editor.id.toLowerCase() === lower) ?? installed.find(i => i.editor.name.toLowerCase() === lower) ?? null; } export default function (pi: ExtensionAPI) { pi.registerCommand("editor", { description: "Pick and configure Pi's Ctrl+G external editor", getArgumentCompletions: (prefix: string) => { const subs = ["list","select","auto","vscode","cursor","windsurf","sublime","atom","zed","neovim","vim","emacs","typora","marktext","notepad++","notepad"]; const items = subs.filter(s => s.startsWith(prefix)).map(s => ({ value: s, label: s })); return items.length ? items : null; }, handler: async (args, ctx) => { if (!isSupportedPlatform()) { ctx.ui.notify("Unsupported platform.", "error"); return; } const installed = getInstalledEditors(); const raw = args?.trim() ?? ""; const parts = raw.split(/\s+/).filter(Boolean); const sub = parts[0] || "select"; const extra = parts.slice(1).join(" "); if (sub === "list") { listEditors(installed, ctx); return; } if (sub === "auto") { if (installed.length === 0) { ctx.ui.notify("No supported editors found.", "error"); return; } const fb = getFallbackEditor(); if (!fb) { ctx.ui.notify("No fallback editor available.", "error"); return; } configureEditor(fb, ctx); return; } if (sub !== "select") { const q = extra ? `${sub} ${extra}` : sub; const m = findEditorByIdOrName(installed, q); if (m) { configureEditor(m, ctx); return; } const fb = getFallbackEditor(); if (fb) { configureEditor(fb, ctx); ctx.ui.notify(`Editor '${q}' not found. Fell back to ${fb.editor.name}.`, "warning"); return; } ctx.ui.notify(`Editor '${q}' not found and no fallback available.`, "error"); return; } if (extra) { const m = findEditorByIdOrName(installed, extra); if (m) { configureEditor(m, ctx); return; } const fb = getFallbackEditor(); if (fb) { configureEditor(fb, ctx); ctx.ui.notify(`Editor '${extra}' not found. Fell back to ${fb.editor.name}.`, "warning"); return; } ctx.ui.notify(`Editor '${extra}' not found and no fallback available.`, "error"); return; } if (installed.length === 0) { ctx.ui.notify("No supported editors found.", "error"); return; } const map = new Map(installed.map(i => [`${i.editor.name} (${i.editor.id})`, i])); const choices = Array.from(map.keys()); if (choices.length === 0) { listEditors(installed, ctx); return; } const choice = await ctx.ui.select("Choose external editor:", choices); if (!choice) { ctx.ui.notify("Cancelled.", "info"); return; } const match = map.get(choice); if (!match) { ctx.ui.notify("Selection error.", "error"); return; } configureEditor(match, ctx); }, }); }