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/wsl-clipboard-image-sta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix WSL image paste by running the Windows clipboard helper in STA mode and preferring the PNG clipboard format.
42 changes: 33 additions & 9 deletions apps/kimi-code/src/utils/clipboard/clipboard-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,11 +309,16 @@ function readClipboardImageViaXclip(): ClipboardImage | null {
* Linux clipboard. PowerShell reaches the Windows clipboard directly;
* we round-trip via a temp PNG because binary stdout is unreliable
* across the WSL interop boundary.
*
* WinForms Clipboard APIs require a single-threaded apartment — without
* `-STA`, `GetImage()` silently returns $null under `powershell.exe`
* (default MTA), so Ctrl+V appeared to do nothing in WSL (see #2425).
* Prefer the PNG clipboard format when present; fall back to GetImage().
*/
function readClipboardImageViaPowerShell(): ClipboardImage | null {
function readClipboardImageViaPowerShell(run: RunCommand): ClipboardImage | null {
const tmpFile = join(tmpdir(), `kimi-wsl-clip-${randomUUID()}.png`);
try {
const winPathResult = runCommand('wslpath', ['-w', tmpFile], {
const winPathResult = run('wslpath', ['-w', tmpFile], {
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
});
if (!winPathResult.ok) return null;
Expand All @@ -324,14 +329,33 @@ function readClipboardImageViaPowerShell(): ClipboardImage | null {
'Add-Type -AssemblyName System.Windows.Forms',
'Add-Type -AssemblyName System.Drawing',
'$path = $env:KIMI_WSL_CLIPBOARD_IMAGE_PATH',
'$img = [System.Windows.Forms.Clipboard]::GetImage()',
"if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
'$ok = $false',
'$obj = [System.Windows.Forms.Clipboard]::GetDataObject()',
"if ($obj -and $obj.GetDataPresent('PNG')) {",
" $stream = $obj.GetData('PNG')",
' if ($stream -is [System.IO.MemoryStream]) {',
" [System.IO.File]::WriteAllBytes($path, $stream.ToArray()); $ok = $true",
' }',
'}',
'if (-not $ok) {',
' $img = [System.Windows.Forms.Clipboard]::GetImage()',
' if ($img) {',
' $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)',
' $ok = $true',
' }',
'}',
"if ($ok) { Write-Output 'ok' } else { Write-Output 'empty' }",
].join('; ');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Join the PowerShell block with newlines

When the WSL fallback runs, joining these control-flow fragments with '; ' generates constructs such as if (...) {; $stream = ..., which Windows PowerShell rejects as a parse error. The command therefore fails before accessing the clipboard and this function returns null; the added mock test misses the regression because it never parses or executes the generated script. Join the fragments with newlines, or avoid inserting a separator immediately after opening braces.

Useful? React with 👍 / 👎.


const result = runCommand('powershell.exe', ['-NoProfile', '-Command', psScript], {
timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS,
env: { ...process.env, KIMI_WSL_CLIPBOARD_IMAGE_PATH: winPath },
});
// `-STA` is required for WinForms clipboard access from powershell.exe.
const result = run(
'powershell.exe',
['-STA', '-NoProfile', '-NonInteractive', '-Command', psScript],
{
timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS,
env: { ...process.env, KIMI_WSL_CLIPBOARD_IMAGE_PATH: winPath },
},
);
if (!result.ok) return null;
if (result.stdout.toString('utf-8').trim() !== 'ok') return null;

Expand Down Expand Up @@ -424,7 +448,7 @@ export async function readClipboardMedia(options?: {
image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip();
}
if (image === null && wsl) {
image = readClipboardImageViaPowerShell();
image = readClipboardImageViaPowerShell(run);
}
if (image === null && !wayland) {
const nativeFileMedia = await readClipboardFileMediaViaNativeText(clip);
Expand Down
67 changes: 67 additions & 0 deletions apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,73 @@ describe('readClipboardMedia', () => {
expect(getImageBinary).not.toHaveBeenCalled();
});

it('reads a WSL clipboard image through powershell.exe -STA (WinForms requires STA)', async () => {
const imageBytes = png(8, 8);
let linuxTmpPath = '';
const runCommand = vi.fn((command: string, args: string[], options?: { env?: NodeJS.ProcessEnv }) => {
if (command === 'wslpath') {
linuxTmpPath = args[1] ?? '';
return { ok: true, stdout: Buffer.from('C:\\Users\\test\\AppData\\Local\\Temp\\kimi.png') };
}
if (command === 'powershell.exe') {
expect(args).toContain('-STA');
expect(args).toContain('-NoProfile');
expect(args).toContain('-NonInteractive');
expect(args).toContain('-Command');
expect(options?.env?.['KIMI_WSL_CLIPBOARD_IMAGE_PATH']).toBe(
'C:\\Users\\test\\AppData\\Local\\Temp\\kimi.png',
);
// PowerShell writes via the Windows path; under WSL that is the same inode
// as the Linux temp file created before wslpath.
writeFileSync(linuxTmpPath, imageBytes);
return { ok: true, stdout: Buffer.from('ok\n') };
}
// wl-paste / xclip miss on WSL so the PowerShell fallback runs.
return { ok: false, stdout: Buffer.alloc(0) };
});

const media = await readClipboardMedia({
platform: 'linux',
env: { WSL_DISTRO_NAME: 'Ubuntu' },
clipboard: fakeClipboard({}),
runCommand,
});

expect(media).toEqual({
kind: 'image',
bytes: imageBytes,
mimeType: 'image/png',
});
expect(runCommand).toHaveBeenCalledWith(
'powershell.exe',
expect.arrayContaining(['-STA', '-NoProfile', '-NonInteractive', '-Command']),
expect.objectContaining({
env: expect.objectContaining({ KIMI_WSL_CLIPBOARD_IMAGE_PATH: expect.any(String) }),
}),
);
});

it('returns null when the WSL PowerShell clipboard fallback reports empty', async () => {
const runCommand = vi.fn((command: string) => {
if (command === 'wslpath') {
return { ok: true, stdout: Buffer.from('C:\\Temp\\kimi.png') };
}
if (command === 'powershell.exe') {
return { ok: true, stdout: Buffer.from('empty\n') };
}
return { ok: false, stdout: Buffer.alloc(0) };
});

const media = await readClipboardMedia({
platform: 'linux',
env: { WSL_DISTRO_NAME: 'Ubuntu' },
clipboard: fakeClipboard({}),
runCommand,
});

expect(media).toBeNull();
});

it('rejects pasted videos larger than 100 MB', async () => {
const dir = mkdtempSync(join(tmpdir(), 'kimi-code-clip-'));
try {
Expand Down
Loading