diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6f6108547..3c52df29d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Ctrl+G external editors failing to launch on Windows because Bun re-quoted the embedded `cmd.exe /c` command line ([#8544](https://github.com/can1357/oh-my-pi/issues/8544)). + ## [17.3.3] - 2026-08-14 ### Fixed diff --git a/packages/coding-agent/src/utils/external-editor.ts b/packages/coding-agent/src/utils/external-editor.ts index 35cea1c87..0a237e7d1 100644 --- a/packages/coding-agent/src/utils/external-editor.ts +++ b/packages/coding-agent/src/utils/external-editor.ts @@ -33,6 +33,27 @@ export interface OpenInEditorOptions { trimTrailingNewline?: boolean; } +/** Subprocess argv and Windows quoting mode used to launch an external editor. */ +export interface EditorSpawnCommand { + cmd: string[]; + windowsVerbatimArguments: boolean; +} + +/** Resolves shell argv without letting the host runtime re-quote the editor command. */ +export function resolveEditorSpawnCommand( + editorCmd: string, + tmpFile: string, + platform: NodeJS.Platform = process.platform, +): EditorSpawnCommand { + const windows = platform === "win32"; + // cmd.exe strips the outer /s /c quote pair; Bun must pass the embedded + // editor/path quotes verbatim instead of applying argv escaping to them. + const cmd = windows + ? ["cmd.exe", "/d", "/s", "/c", `"${editorCmd} "${tmpFile}""`] + : [$which("sh") ?? "sh", "-c", `${editorCmd} "$1"`, "sh", tmpFile]; + return { cmd, windowsVerbatimArguments: windows }; +} + /** * Opens `content` in the user's external editor and returns the edited text. * Returns `null` if the editor exits with a non-zero code. @@ -50,15 +71,13 @@ export async function openInEditor( try { await Bun.write(tmpFile, content); + const spawnCommand = resolveEditorSpawnCommand(editorCmd, tmpFile); const [stdin, stdout, stderr] = options?.stdio ?? ["inherit", "inherit", "inherit"]; - const cmd = - process.platform === "win32" - ? ["cmd", "/c", `${editorCmd} "${tmpFile}"`] - : [$which("sh") ?? "sh", "-c", `${editorCmd} "$1"`, "sh", tmpFile]; - const child = Bun.spawn(cmd, { + const child = Bun.spawn(spawnCommand.cmd, { stdin, stdout, stderr, + windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments, }); const exitCode = await child.exited; if (exitCode === 0) { diff --git a/packages/coding-agent/test/external-editor.test.ts b/packages/coding-agent/test/external-editor.test.ts index 4e1b7ab17..b0654b594 100644 --- a/packages/coding-agent/test/external-editor.test.ts +++ b/packages/coding-agent/test/external-editor.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { getEditorCommand, openInEditor } from "../src/utils/external-editor"; +import { getEditorCommand, openInEditor, resolveEditorSpawnCommand } from "../src/utils/external-editor"; interface MutableProcess { platform: NodeJS.Platform; @@ -66,6 +66,21 @@ describe("getEditorCommand", () => { }); describe("openInEditor", () => { + it("passes the cmd.exe command line verbatim on Windows", () => { + const tmpFile = String.raw`C:\Users\Example User\AppData\Local\Temp\omp-editor-123.omp.md`; + + expect(resolveEditorSpawnCommand('"C:\\Program Files\\Code.exe" --wait', tmpFile, "win32")).toEqual({ + cmd: [ + "cmd.exe", + "/d", + "/s", + "/c", + String.raw`""C:\Program Files\Code.exe" --wait "C:\Users\Example User\AppData\Local\Temp\omp-editor-123.omp.md""`, + ], + windowsVerbatimArguments: true, + }); + }); + it.skipIf(process.platform === "win32")("supports quoted editor paths containing spaces", async () => { const tempDir = TempDir.createSync("@external-editor-"); try {