Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/clipboard-image-hint-once.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Improve the image paste hint.
1 change: 0 additions & 1 deletion apps/kimi-code/src/tui/constant/clipboard-image-hint.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// Timing constants for the clipboard-image hint controller.
export const FOCUS_DEBOUNCE_MS = 1_000;
export const HINT_COOLDOWN_MS = 30_000;
export const HINT_DISPLAY_MS = 4_000;
63 changes: 53 additions & 10 deletions apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@ import type { TUI } from '@earendil-works/pi-tui';

import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image';

import {
FOCUS_DEBOUNCE_MS,
HINT_COOLDOWN_MS,
HINT_DISPLAY_MS,
} from '../constant/clipboard-image-hint';
import { FOCUS_DEBOUNCE_MS, HINT_DISPLAY_MS } from '../constant/clipboard-image-hint';
import { TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT } from '../utils/terminal-focus';
import type { FooterComponent } from '../components/chrome/footer';

Expand All @@ -26,10 +22,19 @@ export class ClipboardImageHintController {
private disposeInputListener: (() => void) | undefined;
private debounceTimer: ReturnType<typeof setTimeout> | undefined;
private clearHintTimer: ReturnType<typeof setTimeout> | undefined;
private lastHintAtMs = 0;
private lastHintText: string | undefined;
private checkGeneration = 0;
private focused = true;
// Whether the controller has completed its first clipboard observation since
// start. The first observation only establishes a baseline: an image already
// in the clipboard when the session starts is not "new", so it must not
// trigger a hint during initialization.
private initialized = false;
// Whether a detected clipboard image is allowed to trigger a hint. After
// showing a hint for an image it disarms so the same lingering image does
// not nag on every focus. A focus check that finds the clipboard empty
// re-arms it, so the next genuinely new image notifies again.
private armed = true;

constructor(host: ClipboardImageHintHost) {
this.host = host;
Expand All @@ -39,6 +44,7 @@ export class ClipboardImageHintController {
this.disposeInputListener = this.host.ui.addInputListener((data) => {
this.handleInput(data);
});
void this.establishInitialBaseline();
Comment thread
liruifengv marked this conversation as resolved.
}

stop(): void {
Expand All @@ -49,7 +55,8 @@ export class ClipboardImageHintController {

this.checkGeneration += 1;
this.clearOwnedHint();
this.lastHintAtMs = 0;
this.initialized = false;
this.armed = true;
}

private handleInput(data: string): void {
Expand Down Expand Up @@ -94,10 +101,28 @@ export class ClipboardImageHintController {
this.lastHintText = undefined;
}

private async establishInitialBaseline(): Promise<void> {
if (!this.host.getModelSupportsImage()) return;

this.checkGeneration += 1;
const generation = this.checkGeneration;

let hasImage = false;
try {
hasImage = await clipboardHasImage();
} catch {
return;
}

if (generation !== this.checkGeneration) return;

this.initialized = true;
this.armed = !hasImage;
}

private async runCheck(generation: number): Promise<void> {
if (!this.focused) return;
if (!this.host.getModelSupportsImage()) return;
if (Date.now() - this.lastHintAtMs < HINT_COOLDOWN_MS) return;

let hasImage = false;
try {
Expand All @@ -108,14 +133,32 @@ export class ClipboardImageHintController {

if (generation !== this.checkGeneration) return;
if (!this.focused) return;
if (!hasImage) return;

// First observation after start only establishes the baseline. An image
// already in the clipboard when the session began is not "new", so we
// record the state and stay quiet instead of nagging during initialization.
if (!this.initialized) {
this.initialized = true;
this.armed = !hasImage;
return;
Comment thread
liruifengv marked this conversation as resolved.
}

if (!hasImage) {
// Clipboard holds no image, so the next image that appears is a new one
// worth notifying about. Re-arm and bail out.
this.armed = true;
return;
}

// Same image we already notified about — stay quiet until it changes.
if (!this.armed) return;

const hintText = `Image in clipboard · ${getPasteImageShortcut()} to paste`;
this.clearClearHintTimer();
this.lastHintText = hintText;
this.armed = false;
this.host.footer.setTransientHint(hintText);
this.host.requestRender();
this.lastHintAtMs = Date.now();

this.clearHintTimer = setTimeout(() => {
this.clearOwnedHint();
Expand Down
75 changes: 74 additions & 1 deletion apps/kimi-code/src/utils/clipboard/clipboard-common.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { spawn, spawnSync } from 'node:child_process';

import type { ClipboardModule } from './clipboard-native';

Expand All @@ -9,6 +9,11 @@ export type RunCommand = (
args: string[],
options?: RunCommandOptions,
) => { stdout: Buffer; ok: boolean };
export type RunCommandAsync = (
command: string,
args: string[],
options?: RunCommandOptions,
) => Promise<{ stdout: Buffer; ok: boolean }>;

export const SUPPORTED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const;

Expand Down Expand Up @@ -49,6 +54,74 @@ export function runCommand(
return { ok: true, stdout };
}

/**
* Non-blocking counterpart of `runCommand`. Used by the clipboard image probe
* on the startup path so a slow or wedged helper (notably `powershell.exe` on
* WSL, or a stuck `wl-paste`/`xclip`) cannot freeze the event loop. The child
* is killed and the promise resolves with `ok: false` once `timeoutMs` elapses
* or the captured stdout exceeds `DEFAULT_MAX_BUFFER_BYTES`.
*/
export function runCommandAsync(
command: string,
args: string[],
options?: RunCommandOptions,
): Promise<{ stdout: Buffer; ok: boolean }> {
const timeoutMs = options?.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS;
return new Promise((resolve) => {
let child;
try {
child = spawn(command, args, {
env: options?.env,
stdio: ['ignore', 'pipe', 'ignore'],
});
} catch {
resolve({ ok: false, stdout: Buffer.alloc(0) });
return;
}

const chunks: Buffer[] = [];
let totalBytes = 0;
let settled = false;
let timer: ReturnType<typeof setTimeout>;

// Marks the promise as settled and clears the timeout. Returns true only for
// the first caller, so each event handler below resolves at most once.
const claim = (): boolean => {
if (settled) return false;
settled = true;
clearTimeout(timer);
return true;
};

timer = setTimeout(() => {
child.kill();
if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) });
}, timeoutMs);

child.stdout?.on('data', (chunk: Buffer) => {
totalBytes += chunk.length;
if (totalBytes > DEFAULT_MAX_BUFFER_BYTES) {
child.kill();
if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) });
return;
}
chunks.push(chunk);
});

child.on('error', () => {
if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) });
});

child.on('close', (code) => {
if (code !== 0) {
if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) });
return;
}
if (claim()) resolve({ ok: true, stdout: Buffer.concat(chunks) });
});
});
}

export function isWaylandSession(env: NodeJS.ProcessEnv): boolean {
return Boolean(env['WAYLAND_DISPLAY']) || env['XDG_SESSION_TYPE'] === 'wayland';
}
Expand Down
32 changes: 16 additions & 16 deletions apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,32 @@ import {
isWaylandSession,
isWSL,
parseTargetList,
runCommand,
runCommandAsync,
safeAvailableFormats,
type RunCommand,
type RunCommandAsync,
} from './clipboard-common';
import { clipboard, type ClipboardModule } from './clipboard-native';

const DEFAULT_POWERSHELL_TIMEOUT_MS = 2000;

function hasImageViaWlPaste(run: RunCommand): boolean {
const list = run('wl-paste', ['--list-types'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
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));
}

function hasImageViaXclip(run: RunCommand): boolean {
const targets = run('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], {
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));
}

function hasImageViaPowerShell(run: RunCommand): boolean {
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 = run('powershell.exe', ['-NoProfile', '-Command', script], {
const result = await run('powershell.exe', ['-NoProfile', '-Command', script], {
timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS,
});
if (!result.ok) return false;
Expand Down Expand Up @@ -58,32 +58,32 @@ export async function clipboardHasImage(options?: {
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
clipboard?: ClipboardModule | null;
runCommand?: RunCommand;
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 ?? runCommand;
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: boolean | undefined;
const xclipHasImage = (): boolean => {
let xclipResult: Promise<boolean> | undefined;
const xclipHasImage = (): Promise<boolean> => {
xclipResult ??= hasImageViaXclip(run);
return xclipResult;
};

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