diff --git a/.changeset/fix-windows-console-windows.md b/.changeset/fix-windows-console-windows.md new file mode 100644 index 0000000000..0a9fbe3138 --- /dev/null +++ b/.changeset/fix-windows-console-windows.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Windows: stop flashing a console window when `kimi web` opens the browser and when the web UI launches files/editors. `openUrl`, `launchDetached`, and the win32 `where` probe now pass `windowsHide: true`, matching the earlier fixes for spawned commands (#957), the background updater (#1336), and hooks (#1466). diff --git a/apps/kimi-code/src/utils/open-url.ts b/apps/kimi-code/src/utils/open-url.ts index 4112d9c6f0..33ae63efbf 100644 --- a/apps/kimi-code/src/utils/open-url.ts +++ b/apps/kimi-code/src/utils/open-url.ts @@ -1,5 +1,10 @@ import { execFile } from 'node:child_process'; +// `windowsHide` is ignored off Windows (kept unconditional, matching kaos). +// Without it, a console-less parent (e.g. a GUI-launched `kimi web`) makes +// Windows allocate a visible console for the transient `cmd /c start`. +const SPAWN_OPTIONS = { windowsHide: true } as const; + export function openUrl(url: string): void { const command: [string, string[]] = process.platform === 'darwin' @@ -7,5 +12,5 @@ export function openUrl(url: string): void { : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]]; - execFile(command[0], command[1], () => {}); + execFile(command[0], command[1], SPAWN_OPTIONS, () => {}); } diff --git a/apps/kimi-code/test/utils/open-url.test.ts b/apps/kimi-code/test/utils/open-url.test.ts new file mode 100644 index 0000000000..d72d5667d3 --- /dev/null +++ b/apps/kimi-code/test/utils/open-url.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + execFile: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execFile: mocks.execFile, +})); + +import { openUrl } from '#/utils/open-url'; + +describe('openUrl', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('passes windowsHide so opening the browser never flashes a console window on Windows', () => { + openUrl('http://127.0.0.1:58627/#token=t'); + expect(mocks.execFile).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ windowsHide: true }), + expect.any(Function), + ); + }); + + it('uses cmd /c start on win32, open on darwin, xdg-open elsewhere', () => { + const originalPlatform = process.platform; + try { + for (const [platform, expectedCommand] of [ + ['win32', 'cmd'], + ['darwin', 'open'], + ['linux', 'xdg-open'], + ] as const) { + Object.defineProperty(process, 'platform', { value: platform }); + mocks.execFile.mockClear(); + openUrl('http://example.com'); + expect(mocks.execFile).toHaveBeenCalledWith( + expectedCommand, + expect.any(Array), + expect.objectContaining({ windowsHide: true }), + expect.any(Function), + ); + } + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); +}); diff --git a/packages/kap-server/src/lib/fileLaunch.ts b/packages/kap-server/src/lib/fileLaunch.ts index a2071cdbf5..3377a80449 100644 --- a/packages/kap-server/src/lib/fileLaunch.ts +++ b/packages/kap-server/src/lib/fileLaunch.ts @@ -122,6 +122,8 @@ function commandExists(command: string, platform: NodeJS.Platform): boolean { if (platform === 'win32') { const result = spawnSync('cmd', ['/c', 'where', command], { stdio: 'ignore', + // Hide the transient cmd.exe console window on Windows. + windowsHide: true, }); return result.status === 0; } @@ -184,6 +186,15 @@ function openInMacApp( return openFileCommandFor(absolutePath, undefined, process.env, platform); } +/** True when the spawned child is console-subsystem on Windows (a shell or + * cmd.exe itself), so its console window should be hidden. GUI binaries + * (explorer.exe, editors) keep their windows visible. */ +function launchesConsole(cmd: LaunchCommand): boolean { + if (cmd.shell === true) return true; + const base = cmd.command.split(/[\\/]/).pop()?.toLowerCase(); + return base === 'cmd' || base === 'cmd.exe'; +} + export async function launchDetached(cmd: LaunchCommand): Promise { return new Promise((resolve, reject) => { let settled = false; @@ -191,6 +202,11 @@ export async function launchDetached(cmd: LaunchCommand): Promise { detached: true, stdio: 'ignore', shell: cmd.shell, + // Hide the console window Windows allocates for console-subsystem + // children (shell shims / cmd.exe). GUI targets (explorer.exe, + // editors) must stay visible — SW_HIDE would hide their first + // window too. + windowsHide: launchesConsole(cmd), }); child.once('error', (err) => { if (settled) return; diff --git a/packages/kap-server/test/fileLaunch.test.ts b/packages/kap-server/test/fileLaunch.test.ts new file mode 100644 index 0000000000..19e41e3793 --- /dev/null +++ b/packages/kap-server/test/fileLaunch.test.ts @@ -0,0 +1,65 @@ +import { EventEmitter } from 'node:events'; + +import { describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + spawn: vi.fn(), + spawnSync: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + spawn: mocks.spawn, + spawnSync: mocks.spawnSync, +})); + +import { getAvailableOpenInApps, launchDetached } from '../src/lib/fileLaunch'; + +describe('launchDetached', () => { + async function launchAndCapture(cmd: Parameters[0]) { + const child = Object.assign(new EventEmitter(), { unref: vi.fn() }); + mocks.spawn.mockReturnValue(child); + const promise = launchDetached(cmd); + child.emit('spawn'); + await promise; + return mocks.spawn.mock.calls.at(-1); + } + + it('hides the console window for shell-shim launches', async () => { + await launchAndCapture({ command: 'code file.ts', args: [], shell: true }); + expect(mocks.spawn).toHaveBeenCalledWith( + 'code file.ts', + [], + expect.objectContaining({ detached: true, windowsHide: true }), + ); + }); + + it('hides the console window for direct cmd.exe launches', async () => { + await launchAndCapture({ command: 'cmd', args: ['/c', 'start', '""', 'C:\\f'] }); + expect(mocks.spawn).toHaveBeenCalledWith( + 'cmd', + ['/c', 'start', '""', 'C:\\f'], + expect.objectContaining({ windowsHide: true }), + ); + }); + + it('keeps GUI windows visible (explorer.exe reveal)', async () => { + await launchAndCapture({ command: 'explorer.exe', args: ['/select,C:\\f'] }); + expect(mocks.spawn).toHaveBeenCalledWith( + 'explorer.exe', + ['/select,C:\\f'], + expect.objectContaining({ windowsHide: false }), + ); + }); +}); + +describe('commandExists probe on win32', () => { + it('hides the transient cmd.exe console window', () => { + mocks.spawnSync.mockReturnValue({ status: 0 }); + getAvailableOpenInApps('win32'); + expect(mocks.spawnSync).toHaveBeenCalledWith( + 'cmd', + ['/c', 'where', expect.any(String)], + expect.objectContaining({ stdio: 'ignore', windowsHide: true }), + ); + }); +});