From b499459cd9217f0af9472e8213ea1591d11b1815 Mon Sep 17 00:00:00 2001 From: jiang1997 Date: Sat, 30 May 2026 17:34:10 +0800 Subject: [PATCH] fix: remove hardcoded /bin/sh for external editor on Windows Run the external editor through Node's platform shell instead of spawning /bin/sh directly. Quote the appended temp file path per platform so Ctrl+G works on Windows while preserving shell-style editor commands. --- .changeset/fix-external-editor-windows.md | 5 +++ .../src/utils/process/external-editor.ts | 31 ++++++++++++++----- .../utils/process/external-editor.test.ts | 18 +++++------ 3 files changed, 35 insertions(+), 19 deletions(-) create mode 100644 .changeset/fix-external-editor-windows.md diff --git a/.changeset/fix-external-editor-windows.md b/.changeset/fix-external-editor-windows.md new file mode 100644 index 00000000..7a462d1e --- /dev/null +++ b/.changeset/fix-external-editor-windows.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix external editor (Ctrl+G) on Windows by removing `/bin/sh` dependency and using platform-aware shell quoting for temp file paths. diff --git a/apps/kimi-code/src/utils/process/external-editor.ts b/apps/kimi-code/src/utils/process/external-editor.ts index 7aa895d3..1f8b588c 100644 --- a/apps/kimi-code/src/utils/process/external-editor.ts +++ b/apps/kimi-code/src/utils/process/external-editor.ts @@ -28,8 +28,8 @@ export function resolveEditorCommand(configured?: string | null): string | undef * with `initialText`. Returns the edited contents on success, or * `undefined` if the editor exited non-zero / the file disappeared. * - * The command is passed to `/bin/sh -c " "` so users can - * supply argv-style strings like `"code --wait"` or `"nvim +set ft=markdown"`. + * The command is passed to the system shell (`shell: true`) so users can + * supply argv-style strings like `code --wait` or `nvim +"set ft=markdown"`. */ export async function editInExternalEditor( initialText: string, @@ -39,10 +39,13 @@ export async function editInExternalEditor( const file = join(dir, 'prompt.md'); await writeFile(file, initialText, 'utf-8'); try { + const shellCmd = `${command} ${quoteShellArg(file)}`; const code = await new Promise((resolve, reject) => { - const shellCmd = `${command} ${shellQuote(file)}`; - const child = spawn('/bin/sh', ['-c', shellCmd], { stdio: 'inherit' }); - child.on('exit', (c) =>{ resolve(c ?? 0); }); + const child = spawn(shellCmd, { + stdio: 'inherit', + shell: true, + }); + child.on('exit', (c) => { resolve(c ?? 0); }); child.on('error', reject); }); if (code !== 0) return undefined; @@ -54,7 +57,19 @@ export async function editInExternalEditor( } } -function shellQuote(path: string): string { - // Single-quote and escape any embedded single quotes. - return `'${path.replaceAll('\'', "'\\''")}'`; +/** + * Quote the appended temp-file path so spaces survive shell parsing. + */ +function quotePosixArg(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function quoteCmdArg(value: string): string { + return `"${value.replaceAll('"', '\\"')}"`; +} + +function quoteShellArg(value: string): string { + return process.platform === 'win32' + ? quoteCmdArg(value) + : quotePosixArg(value); } diff --git a/apps/kimi-code/test/utils/process/external-editor.test.ts b/apps/kimi-code/test/utils/process/external-editor.test.ts index 3e135d01..e3c8ce69 100644 --- a/apps/kimi-code/test/utils/process/external-editor.test.ts +++ b/apps/kimi-code/test/utils/process/external-editor.test.ts @@ -27,12 +27,6 @@ vi.mock('node:fs/promises', async () => { import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor'; -function shellPath(cmd: string): string { - const match = cmd.match(/'([^']+)'$/); - if (!match) throw new Error(`Could not parse temp path from: ${cmd}`); - return match[1]!; -} - afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); @@ -50,9 +44,12 @@ describe('external-editor helpers', () => { }); it('returns the edited contents on success and cleans up the temp directory', async () => { - mocks.spawn.mockImplementation((_cmd: string, args: string[]) => { + mocks.spawn.mockImplementation((cmd: string, _opts: Record) => { const child = new EventEmitter(); - void writeFile(shellPath(args[1]!), 'edited text', 'utf8').then(() => { + // Extract the file path from the shell command (last argument after quoting). + const match = cmd.match(/'([^']+prompt\.md)'/) || cmd.match(/"([^"]+prompt\.md)"/); + const tmpFile = match![1]!; + void writeFile(tmpFile, 'edited text', 'utf8').then(() => { child.emit('exit', 0); }); return child as never; @@ -60,9 +57,8 @@ describe('external-editor helpers', () => { await expect(editInExternalEditor('seed', 'code --wait')).resolves.toBe('edited text'); expect(mocks.spawn).toHaveBeenCalledWith( - '/bin/sh', - ['-c', expect.stringMatching(/^code --wait /)], - { stdio: 'inherit' }, + expect.stringContaining('code --wait'), + { stdio: 'inherit', shell: true }, ); expect(mocks.rmCalls).toHaveBeenCalled(); });