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

Fix image paste from the Windows clipboard when Kimi Code runs in WSL.
79 changes: 57 additions & 22 deletions apps/kimi-code/src/utils/clipboard/clipboard-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ const VIDEO_MIME_BY_SUFFIX: Readonly<Record<string, string>> = Object.freeze({

const DEFAULT_READ_TIMEOUT_MS = 3000;
const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000;
const WSL_CLIPBOARD_IMAGE_ENV = 'KIMI_WSL_CLIPBOARD_IMAGE_PATH';
const WSL_CLIPBOARD_IMAGE_WSLENV_ENTRY = `${WSL_CLIPBOARD_IMAGE_ENV}/w`;

const MACOS_FILE_PATH_SCRIPT = String.raw`
ObjC.import('AppKit');
Expand Down Expand Up @@ -128,8 +130,7 @@ function selectPreferredImageMimeType(candidates: string[]): string | null {
const match = normalized.find((t) => t.base === preferred);
if (match !== undefined) return match.raw;
}
const anyImage = normalized.find((t) => t.base.startsWith('image/'));
return anyImage?.raw ?? null;
return null;
}

function videoMimeFromPath(path: string): string | null {
Expand Down Expand Up @@ -241,8 +242,8 @@ function runCommand(command: string, args: string[], options?: RunCommandOptions
});
}

function readClipboardFileMediaViaWlPaste(): ClipboardMedia | null {
const list = runCommand('wl-paste', ['--list-types'], {
function readClipboardFileMediaViaWlPaste(run: RunCommand): ClipboardMedia | null {
const list = run('wl-paste', ['--list-types'], {
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
});
if (!list.ok) return null;
Expand All @@ -251,26 +252,26 @@ function readClipboardFileMediaViaWlPaste(): ClipboardMedia | null {
const uriType = types.find((t) => baseMimeType(t) === 'text/uri-list');
if (uriType === undefined) return null;

const uris = runCommand('wl-paste', ['--type', uriType, '--no-newline']);
const uris = run('wl-paste', ['--type', uriType, '--no-newline']);
return uris.ok ? readMediaFromText(uris.stdout.toString('utf-8')) : null;
}

function readClipboardImageViaWlPaste(): ClipboardImage | null {
const list = runCommand('wl-paste', ['--list-types'], {
function readClipboardImageViaWlPaste(run: RunCommand): ClipboardImage | null {
const list = run('wl-paste', ['--list-types'], {
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
});
if (!list.ok) return null;

const selected = selectPreferredImageMimeType(parseTargetList(list.stdout));
if (selected === null) return null;

const data = runCommand('wl-paste', ['--type', selected, '--no-newline']);
const data = run('wl-paste', ['--type', selected, '--no-newline']);
if (!data.ok || data.stdout.length === 0) return null;
return { kind: 'image', bytes: data.stdout, mimeType: baseMimeType(selected) };
}

function readClipboardFileMediaViaXclip(): ClipboardMedia | null {
const targets = runCommand('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], {
function readClipboardFileMediaViaXclip(run: RunCommand): ClipboardMedia | null {
const targets = run('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], {
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
});
if (!targets.ok) return null;
Expand All @@ -279,12 +280,12 @@ function readClipboardFileMediaViaXclip(): ClipboardMedia | null {
const uriType = candidates.find((t) => baseMimeType(t) === 'text/uri-list');
if (uriType === undefined) return null;

const uris = runCommand('xclip', ['-selection', 'clipboard', '-t', uriType, '-o']);
const uris = run('xclip', ['-selection', 'clipboard', '-t', uriType, '-o']);
return uris.ok ? readMediaFromText(uris.stdout.toString('utf-8')) : null;
}

function readClipboardImageViaXclip(): ClipboardImage | null {
const targets = runCommand('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], {
function readClipboardImageViaXclip(run: RunCommand): ClipboardImage | null {
const targets = run('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], {
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
});

Expand All @@ -296,7 +297,7 @@ function readClipboardImageViaXclip(): ClipboardImage | null {
: [...SUPPORTED_IMAGE_MIME_TYPES];

for (const mime of tryTypes) {
const data = runCommand('xclip', ['-selection', 'clipboard', '-t', mime, '-o']);
const data = run('xclip', ['-selection', 'clipboard', '-t', mime, '-o']);
if (data.ok && data.stdout.length > 0) {
return { kind: 'image', bytes: data.stdout, mimeType: baseMimeType(mime) };
}
Expand All @@ -310,10 +311,10 @@ function readClipboardImageViaXclip(): ClipboardImage | null {
* we round-trip via a temp PNG because binary stdout is unreliable
* across the WSL interop boundary.
*/
function readClipboardImageViaPowerShell(): ClipboardImage | null {
function readClipboardImageViaPowerShell(run: RunCommand, env: NodeJS.ProcessEnv): 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 @@ -323,14 +324,48 @@ function readClipboardImageViaPowerShell(): ClipboardImage | null {
const psScript = [
'Add-Type -AssemblyName System.Windows.Forms',
'Add-Type -AssemblyName System.Drawing',
'$path = $env:KIMI_WSL_CLIPBOARD_IMAGE_PATH',
`$path = $env:${WSL_CLIPBOARD_IMAGE_ENV}`,
'$img = [System.Windows.Forms.Clipboard]::GetImage()',
"if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
].join('; ');

const result = runCommand('powershell.exe', ['-NoProfile', '-Command', psScript], {
const wslEnvEntries = env['WSLENV']
?.split(':')
.filter((entry) => entry.length > 0) ?? [];
const wslEnvOutputEntries: string[] = [];
let wslClipboardImageEnvEntryIndex: number | null = null;
let wslClipboardImageEnvFlags = '';
for (const entry of wslEnvEntries) {
const [name, flags = ''] = entry.split('/');
if (name !== WSL_CLIPBOARD_IMAGE_ENV) {
wslEnvOutputEntries.push(entry);
continue;
}
wslClipboardImageEnvEntryIndex ??= wslEnvOutputEntries.push('') - 1;
for (const flag of flags) {
if (!wslClipboardImageEnvFlags.includes(flag)) {
wslClipboardImageEnvFlags += flag;
}
}
}
if (!wslClipboardImageEnvFlags.includes('w')) {
wslClipboardImageEnvFlags += 'w';
}
if (wslClipboardImageEnvEntryIndex === null) {
wslEnvOutputEntries.push(WSL_CLIPBOARD_IMAGE_WSLENV_ENTRY);
} else {
wslEnvOutputEntries[wslClipboardImageEnvEntryIndex] = `${WSL_CLIPBOARD_IMAGE_ENV}/${wslClipboardImageEnvFlags}`;
}
const wslEnv = wslEnvOutputEntries.join(':');

const result = run('powershell.exe', ['-NoProfile', '-Command', psScript], {
timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS,
env: { ...process.env, KIMI_WSL_CLIPBOARD_IMAGE_PATH: winPath },
env: {
...process.env,
...env,
[WSL_CLIPBOARD_IMAGE_ENV]: winPath,
WSLENV: wslEnv,
},
});
if (!result.ok) return null;
if (result.stdout.toString('utf-8').trim() !== 'ok') return null;
Expand Down Expand Up @@ -419,12 +454,12 @@ export async function readClipboardMedia(options?: {
const wsl = isWSL(env);

if (wayland || wsl) {
const fileMedia = readClipboardFileMediaViaWlPaste() ?? readClipboardFileMediaViaXclip();
const fileMedia = readClipboardFileMediaViaWlPaste(run) ?? readClipboardFileMediaViaXclip(run);
if (fileMedia !== null) return fileMedia;
image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip();
image = readClipboardImageViaWlPaste(run) ?? readClipboardImageViaXclip(run);
}
if (image === null && wsl) {
image = readClipboardImageViaPowerShell();
image = readClipboardImageViaPowerShell(run, env);
}
if (image === null && !wayland) {
const nativeFileMedia = await readClipboardFileMediaViaNativeText(clip);
Expand Down
139 changes: 139 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 @@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url';
import { describe, expect, it, vi } from 'vitest';

import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image';
import type { RunCommand } from '#/utils/clipboard/clipboard-common';
import type { ClipboardModule } from '#/utils/clipboard/clipboard-native';

function png(width: number, height: number): Uint8Array {
Expand Down Expand Up @@ -36,6 +37,33 @@ function noMacOsPaths(): { stdout: Buffer; ok: boolean } {
return { stdout: Buffer.alloc(0), ok: false };
}

function wslPowerShellImageRunner(imageBytes: Uint8Array): {
runCommand: RunCommand;
getPowerShellEnv: () => NodeJS.ProcessEnv | null;
} {
let linuxPath: string | null = null;
let powerShellEnv: NodeJS.ProcessEnv | null = null;
const runCommand: RunCommand = (command, args, options) => {
if (command === 'wslpath') {
const candidate = args[1];
linuxPath = candidate ?? null;
return { stdout: Buffer.from('C:\\Users\\example\\clip.png\n'), ok: linuxPath !== null };
}
if (command === 'powershell.exe') {
powerShellEnv = options?.env ?? null;
if (linuxPath !== null) {
writeFileSync(linuxPath, imageBytes);
}
return { stdout: Buffer.from('ok\n'), ok: true };
}
if (command === 'wl-paste' || command === 'xclip') {
return { stdout: Buffer.alloc(0), ok: false };
}
return { stdout: Buffer.alloc(0), ok: false };
};
return { runCommand, getPowerShellEnv: () => powerShellEnv };
}

describe('readClipboardMedia', () => {
it('reads a copied image file from its real path instead of the Finder preview icon', async () => {
const dir = mkdtempSync(join(tmpdir(), 'kimi-code-clip-'));
Expand Down Expand Up @@ -158,4 +186,115 @@ describe('readClipboardMedia', () => {
rmSync(dir, { recursive: true, force: true });
}
});

it('reads a WSL Wayland BMP clipboard through PowerShell when wl-paste exposes only image/bmp', async () => {
const imageBytes = png(16, 17);
const { runCommand: runPowerShellImageCommand } = wslPowerShellImageRunner(imageBytes);
const runCommand: RunCommand = (command, args, options) => {
if (command === 'wl-paste' && args.includes('--list-types')) {
return { stdout: Buffer.from('image/bmp\n'), ok: true };
}
if (command === 'wl-paste' && args.includes('image/bmp')) {
return { stdout: Buffer.from([0x42, 0x4d, 0x01]), ok: true };
}
return runPowerShellImageCommand(command, args, options);
};

const media = await readClipboardMedia({
platform: 'linux',
env: { WSL_DISTRO_NAME: 'Ubuntu', WAYLAND_DISPLAY: 'wayland-0' },
clipboard: null,
runCommand,
});

expect(media).toEqual({ kind: 'image', bytes: imageBytes, mimeType: 'image/png' });
});

it('reads a WSL clipboard image through PowerShell when WSLENV needs the image path entry added', async () => {
const imageBytes = png(8, 9);
const { runCommand, getPowerShellEnv } = wslPowerShellImageRunner(imageBytes);

const media = await readClipboardMedia({
platform: 'linux',
env: { WSL_DISTRO_NAME: 'Ubuntu', WSLENV: 'HOME/p:KIMI_EXISTING/u' },
clipboard: null,
runCommand,
});

expect(media).toEqual({ kind: 'image', bytes: imageBytes, mimeType: 'image/png' });
expect(getPowerShellEnv()).toMatchObject({
KIMI_WSL_CLIPBOARD_IMAGE_PATH: 'C:\\Users\\example\\clip.png',
WSLENV: 'HOME/p:KIMI_EXISTING/u:KIMI_WSL_CLIPBOARD_IMAGE_PATH/w',
});
});

it('reads a WSL clipboard image through PowerShell when WSLENV already has the image path entry once', async () => {
const imageBytes = png(10, 11);
const { runCommand, getPowerShellEnv } = wslPowerShellImageRunner(imageBytes);

const media = await readClipboardMedia({
platform: 'linux',
env: {
WSL_DISTRO_NAME: 'Ubuntu',
WSLENV: 'HOME/p:KIMI_WSL_CLIPBOARD_IMAGE_PATH/w:KIMI_EXISTING/u',
},
clipboard: null,
runCommand,
});

expect(media).toEqual({ kind: 'image', bytes: imageBytes, mimeType: 'image/png' });
expect(getPowerShellEnv()).toMatchObject({
KIMI_WSL_CLIPBOARD_IMAGE_PATH: 'C:\\Users\\example\\clip.png',
WSLENV: 'HOME/p:KIMI_WSL_CLIPBOARD_IMAGE_PATH/w:KIMI_EXISTING/u',
});
});

it('reads a WSL clipboard image through PowerShell when WSLENV has duplicate image path entries, merges flags and adds w', async () => {
const imageBytes = png(12, 13);
const { runCommand, getPowerShellEnv } = wslPowerShellImageRunner(imageBytes);

const media = await readClipboardMedia({
platform: 'linux',
env: {
WSL_DISTRO_NAME: 'Ubuntu',
WSLENV: 'HOME/p:KIMI_WSL_CLIPBOARD_IMAGE_PATH/u:KIMI_EXISTING/u:KIMI_WSL_CLIPBOARD_IMAGE_PATH/l',
},
clipboard: null,
runCommand,
});

expect(media).toEqual({ kind: 'image', bytes: imageBytes, mimeType: 'image/png' });
expect(getPowerShellEnv()).toMatchObject({
KIMI_WSL_CLIPBOARD_IMAGE_PATH: 'C:\\Users\\example\\clip.png',
WSLENV: 'HOME/p:KIMI_WSL_CLIPBOARD_IMAGE_PATH/ulw:KIMI_EXISTING/u',
});
});

it('reads a WSL clipboard image through PowerShell when explicit env omits WSLENV, ignores host WSLENV', async () => {
const previousWslEnv = process.env['WSLENV'];
process.env['WSLENV'] = 'HOST_SENTINEL/p';
try {
const imageBytes = png(14, 15);
const { runCommand, getPowerShellEnv } = wslPowerShellImageRunner(imageBytes);

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

expect(media).toEqual({ kind: 'image', bytes: imageBytes, mimeType: 'image/png' });
expect(getPowerShellEnv()).toMatchObject({
KIMI_WSL_CLIPBOARD_IMAGE_PATH: 'C:\\Users\\example\\clip.png',
WSLENV: 'KIMI_WSL_CLIPBOARD_IMAGE_PATH/w',
});
} finally {
if (previousWslEnv === undefined) {
delete process.env['WSLENV'];
} else {
process.env['WSLENV'] = previousWslEnv;
}
}
});
});