diff --git a/.changeset/fix-linux-wayland-focus-flicker.md b/.changeset/fix-linux-wayland-focus-flicker.md new file mode 100644 index 0000000000..07741b544c --- /dev/null +++ b/.changeset/fix-linux-wayland-focus-flicker.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the terminal window repeatedly losing focus on Linux Wayland, which broke IME input. diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts index dc696d31ce..8c344d02ea 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts @@ -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 { - 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 { - 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 { - 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 { if (clip === null) return false; @@ -58,44 +21,24 @@ export async function clipboardHasImage(options?: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; clipboard?: ClipboardModule | null; - runCommand?: RunCommandAsync; }): Promise { 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 | undefined; - const xclipHasImage = (): Promise => { - 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); } diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts index 16edebb2fd..94e8411010 100644 --- a/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts +++ b/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts @@ -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 () => { @@ -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); }); });