mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix(kimi-code): avoid terminal focus flicker on Linux Wayland (#1094)
The focus-driven clipboard image hint spawned wl-paste/xclip on every terminal focus event. On Wayland this perturbs seat focus and re-triggers the focus event, creating a feedback loop that made the terminal window repeatedly gain and lose focus and broke IME input. Limit the probe to macOS and Windows, which use the in-process native module and do not perturb focus. Image paste is unaffected. Resolve #1090
This commit is contained in:
parent
ea03f30e51
commit
8ee5c0ff81
3 changed files with 28 additions and 209 deletions
5
.changeset/fix-linux-wayland-focus-flicker.md
Normal file
5
.changeset/fix-linux-wayland-focus-flicker.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix the terminal window repeatedly losing focus on Linux Wayland, which broke IME input.
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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);
|
||||
// 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;
|
||||
|
||||
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;
|
||||
return hasImageViaNative(clip);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
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: 'linux', env: {}, clipboard: clip, runCommand });
|
||||
const result = await clipboardHasImage({ platform: 'win32', clipboard: clip });
|
||||
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 () => {
|
||||
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 });
|
||||
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…
Add table
Add a link
Reference in a new issue