Skip to content
Merged
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-linux-wayland-focus-flicker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix the terminal window repeatedly losing focus on Linux Wayland, which broke IME input.
85 changes: 14 additions & 71 deletions apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,6 @@
import {
DEFAULT_LIST_TIMEOUT_MS,
isFileLikeNativeFormat,
isSupportedImageMimeType,
isWaylandSession,
isWSL,
parseTargetList,
runCommandAsync,
safeAvailableFormats,
type RunCommandAsync,
} from './clipboard-common';
import { isFileLikeNativeFormat, safeAvailableFormats } from './clipboard-common';
import { clipboard, type ClipboardModule } from './clipboard-native';

const DEFAULT_POWERSHELL_TIMEOUT_MS = 2000;

async function hasImageViaWlPaste(run: RunCommandAsync): Promise<boolean> {
const list = await run('wl-paste', ['--list-types'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
if (!list.ok) return false;
return parseTargetList(list.stdout).some((t) => isSupportedImageMimeType(t));
}

async function hasImageViaXclip(run: RunCommandAsync): Promise<boolean> {
const targets = await run('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], {
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
});
if (!targets.ok) return false;
return parseTargetList(targets.stdout).some((t) => isSupportedImageMimeType(t));
}

async function hasImageViaPowerShell(run: RunCommandAsync): Promise<boolean> {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); ($img -ne $null)";
const result = await run('powershell.exe', ['-NoProfile', '-Command', script], {
timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS,
});
if (!result.ok) return false;
const output = result.stdout.toString('utf-8').trim().toLowerCase();
return output === 'true';
}

async function hasImageViaNative(clip: ClipboardModule | null): Promise<boolean> {
if (clip === null) return false;

Expand All @@ -58,44 +21,24 @@ export async function clipboardHasImage(options?: {
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
clipboard?: ClipboardModule | null;
runCommand?: RunCommandAsync;
}): Promise<boolean> {
const env = options?.env ?? process.env;
const platform = options?.platform ?? process.platform;
const clip = options?.clipboard ?? clipboard;
const run = options?.runCommand ?? runCommandAsync;

if (env['TERMUX_VERSION'] !== undefined) return false;

if (platform === 'linux') {
const wayland = isWaylandSession(env);
const wsl = isWSL(env);

let xclipResult: Promise<boolean> | undefined;
const xclipHasImage = (): Promise<boolean> => {
xclipResult ??= hasImageViaXclip(run);
return xclipResult;
};

if (wayland || wsl) {
if (await hasImageViaWlPaste(run)) return true;
if (await xclipHasImage()) return true;
}
if (wsl && (await hasImageViaPowerShell(run))) return true;
if (!wayland) {
if (await xclipHasImage()) return true;
if (await hasImageViaNative(clip)) return true;
}
return false;
}

if (platform === 'darwin') {
return hasImageViaNative(clip);
}

if (platform === 'win32') {
return hasImageViaNative(clip);
}

return false;
// The focus-driven clipboard-image hint does not probe on Linux. The probe
// would spawn wl-paste / xclip, which on Wayland perturbs seat focus and
// re-triggers the terminal's focus event, creating a focus feedback loop
// (window repeatedly gains/loses focus, IME candidate window cannot stay
// focused — see issue #1090). macOS and Windows are fine: both use the
// in-process native module, which neither spawns a subprocess nor perturbs
// focus.
//
// Image *paste* is unaffected on all platforms: it reads the clipboard
// through readClipboardMedia() on the explicit paste path, not here.
if (platform !== 'darwin' && platform !== 'win32') return false;

return hasImageViaNative(clip);
}
149 changes: 10 additions & 139 deletions apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,8 @@ describe('clipboardHasImage', () => {

it('returns false on macOS when native clipboard reports no image', async () => {
const clip = fakeClipboard({ hasImage: vi.fn(() => false) });
const runCommand = vi.fn(async () => ({ stdout: Buffer.alloc(0), ok: false }));
const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip, runCommand });
const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip });
expect(result).toBe(false);
expect(runCommand).not.toHaveBeenCalledWith('osascript', expect.anything(), expect.anything());
});

it('returns false on macOS when native clipboard throws', async () => {
Expand All @@ -51,156 +49,29 @@ describe('clipboardHasImage', () => {
expect(clip.hasImage).not.toHaveBeenCalled();
});

it('detects image on Wayland via wl-paste list-types', async () => {
const runCommand = vi.fn(async (command: string, args: string[]) => {
if (command === 'wl-paste' && args[0] === '--list-types') {
return { stdout: Buffer.from('text/plain\nimage/png\n'), ok: true };
}
return { stdout: Buffer.alloc(0), ok: false };
});
const result = await clipboardHasImage({ platform: 'linux', env: { WAYLAND_DISPLAY: 'wayland-1' }, runCommand });
expect(result).toBe(true);
});

it('returns false on Wayland when target list contains unsupported MIME types', async () => {
const runCommand = vi.fn(async (command: string, args: string[]) => {
if (command === 'wl-paste' && args[0] === '--list-types') {
return { stdout: Buffer.from('text/plain\nimage/bmp\n'), ok: true };
}
return { stdout: Buffer.alloc(0), ok: false };
});
const clip = fakeClipboard({ hasImage: vi.fn(() => false) });
// The focus-driven hint must not probe the clipboard on Linux: spawning
// wl-paste / xclip on Wayland perturbs seat focus and re-triggers the
// terminal focus event, creating a focus feedback loop (issue #1090).
it('returns false on Linux without reading the clipboard', async () => {
const clip = fakeClipboard({ hasImage: vi.fn(() => true) });
const result = await clipboardHasImage({
platform: 'linux',
env: { WAYLAND_DISPLAY: 'wayland-1' },
runCommand,
clipboard: clip,
});
expect(result).toBe(false);
expect(clip.hasImage).not.toHaveBeenCalled();
});

it('falls back to xclip on Wayland when wl-paste reports no image', async () => {
const runCommand = vi.fn(async (command: string, args: string[]) => {
if (command === 'wl-paste' && args[0] === '--list-types') {
return { stdout: Buffer.from('text/plain\n'), ok: true };
}
if (command === 'xclip' && args.includes('TARGETS')) {
return { stdout: Buffer.from('TARGETS\nimage/png\n'), ok: true };
}
return { stdout: Buffer.alloc(0), ok: false };
});
const result = await clipboardHasImage({
platform: 'linux',
env: { WAYLAND_DISPLAY: 'wayland-1' },
runCommand,
});
expect(result).toBe(true);
expect(runCommand).toHaveBeenCalledWith('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], expect.anything());
});

it('detects image on X11 via xclip TARGETS', async () => {
const runCommand = vi.fn(async (command: string, args: string[]) => {
if (command === 'xclip' && args.includes('TARGETS')) {
return { stdout: Buffer.from('TARGETS\nimage/jpeg\n'), ok: true };
}
return { stdout: Buffer.alloc(0), ok: false };
});
const result = await clipboardHasImage({ platform: 'linux', env: {}, runCommand });
expect(result).toBe(true);
});

it('returns false on X11 when target list contains unsupported MIME types', async () => {
const runCommand = vi.fn(async (command: string, args: string[]) => {
if (command === 'xclip' && args.includes('TARGETS')) {
return { stdout: Buffer.from('TARGETS\nimage/tiff\n'), ok: true };
}
return { stdout: Buffer.alloc(0), ok: false };
});
const clip = fakeClipboard({ hasImage: vi.fn(() => false) });
const result = await clipboardHasImage({ platform: 'linux', env: {}, runCommand, clipboard: clip });
expect(result).toBe(false);
});

it('returns false on X11 when target list is empty', async () => {
const runCommand = vi.fn(async (command: string, args: string[]) => {
if (command === 'xclip' && args.includes('TARGETS')) {
return { stdout: Buffer.from('TARGETS\n'), ok: true };
}
return { stdout: Buffer.alloc(0), ok: false };
});
const clip = fakeClipboard({ hasImage: vi.fn(() => false) });
const result = await clipboardHasImage({ platform: 'linux', env: {}, runCommand, clipboard: clip });
expect(result).toBe(false);
});

it('falls back to native hasImage on Linux X11 when xclip fails', async () => {
const clip = fakeClipboard({ hasImage: vi.fn(() => true) });
const runCommand = vi.fn(async () => ({ stdout: Buffer.alloc(0), ok: false }));
const result = await clipboardHasImage({ platform: 'linux', env: {}, clipboard: clip, runCommand });
expect(result).toBe(true);
});

it('returns false on Linux X11 when xclip and native both fail', async () => {
const clip = fakeClipboard({ hasImage: vi.fn(() => false) });
const runCommand = vi.fn(async () => ({ stdout: Buffer.alloc(0), ok: false }));
const result = await clipboardHasImage({ platform: 'linux', env: {}, clipboard: clip, runCommand });
expect(result).toBe(false);
});

it('detects WSL via WSL_DISTRO_NAME and checks PowerShell', async () => {
const runCommand = vi.fn(async (command: string) => {
if (command === 'powershell.exe') {
return { stdout: Buffer.from('True\n'), ok: true };
}
return { stdout: Buffer.alloc(0), ok: false };
});
const result = await clipboardHasImage({
platform: 'linux',
env: { WSL_DISTRO_NAME: 'Ubuntu' },
runCommand,
});
expect(result).toBe(true);
});

it('detects WSL via WSLENV and checks PowerShell', async () => {
const runCommand = vi.fn(async (command: string) => {
if (command === 'powershell.exe') {
return { stdout: Buffer.from('True\n'), ok: true };
}
return { stdout: Buffer.alloc(0), ok: false };
});
const result = await clipboardHasImage({
platform: 'linux',
env: { WSLENV: 'WT_SESSION' },
runCommand,
});
expect(result).toBe(true);
});

it('returns false on Linux when runCommand fails for all fallbacks', async () => {
const runCommand = vi.fn(async () => ({ stdout: Buffer.alloc(0), ok: false }));
const clip = fakeClipboard({ hasImage: vi.fn(() => false) });
const result = await clipboardHasImage({ platform: 'linux', env: {}, runCommand, clipboard: clip });
expect(result).toBe(false);
});

it('detects image on Windows via native clipboard', async () => {
it('returns true on Windows when native clipboard reports an image', async () => {
const clip = fakeClipboard({ hasImage: vi.fn(() => true) });
const runCommand = vi.fn(async () => ({ stdout: Buffer.alloc(0), ok: false }));
const result = await clipboardHasImage({ platform: 'win32', clipboard: clip, runCommand });
const result = await clipboardHasImage({ platform: 'win32', clipboard: clip });
expect(result).toBe(true);
expect(runCommand).not.toHaveBeenCalledWith('powershell.exe', expect.anything(), expect.anything());
});

it('returns false on Windows when native clipboard reports no image', async () => {
const clip = fakeClipboard({ hasImage: vi.fn(() => false) });
const runCommand = vi.fn(async (command: string) => {
if (command === 'powershell.exe') {
return { stdout: Buffer.from('True\n'), ok: true };
}
return { stdout: Buffer.alloc(0), ok: false };
});
const result = await clipboardHasImage({ platform: 'win32', clipboard: clip, runCommand });
const result = await clipboardHasImage({ platform: 'win32', clipboard: clip });
expect(result).toBe(false);
});
});
Loading