Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-external-editor-windows.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 23 additions & 8 deletions apps/kimi-code/src/utils/process/external-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<cmd> <tmpfile>"` 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,
Expand All @@ -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<number>((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;
Expand All @@ -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);
}
18 changes: 7 additions & 11 deletions apps/kimi-code/test/utils/process/external-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -50,19 +44,21 @@ 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<string, unknown>) => {
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;
});

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();
});
Expand Down