fix(kimi-code): show clipboard image paste hint only for newly copied images (#1072)

* fix(kimi-code): show clipboard image paste hint only once per image

The footer hint repeated on every terminal focus whenever an image remained in the clipboard, which became noisy. Replace the 30s time-based cooldown with a per-image gate: the hint shows once for a given image and stays quiet until the clipboard is observed empty and a new image appears.

* fix(kimi-code): suppress clipboard image hint for images present at startup

The footer hint fired during initialization whenever an image was already in the clipboard, treating it as new. The first clipboard observation after start now only establishes a baseline, so only images copied during the session trigger the hint.

* fix(kimi-code): show hint for first image copied after startup

* fix(kimi-code): make clipboard image probe non-blocking

The startup baseline probe in ClipboardImageHintController calls clipboardHasImage(), which on Linux/WSL ran wl-paste/xclip/powershell via spawnSync. The probe only reaches its first await after those synchronous calls, so a slow or wedged helper could freeze the TUI launch for up to the 1s-2s tool timeouts even when the user never focuses with an image.

Add an async runCommandAsync built on spawn with timeout-based kill, and route the Linux/WSL image detection through it so the event loop is never blocked. Keep the synchronous runCommand for the explicit paste-read path.

* chore: add changeset for non-blocking clipboard probe

* chore: simplify clipboard image hint changeset
This commit is contained in:
liruifengv 2026-06-24 21:07:14 +08:00 committed by GitHub
parent 3aaf1e5803
commit a86bb9757d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 374 additions and 105 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Improve the image paste hint.

View file

@ -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;

View file

@ -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';
@ -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;
@ -39,6 +44,7 @@ export class ClipboardImageHintController {
this.disposeInputListener = this.host.ui.addInputListener((data) => {
this.handleInput(data);
});
void this.establishInitialBaseline();
}
stop(): void {
@ -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 {
@ -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 {
@ -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;
}
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();

View file

@ -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';
@ -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;
@ -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';
}

View file

@ -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;
@ -58,12 +58,12 @@ 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;
@ -71,19 +71,19 @@ export async function clipboardHasImage(options?: {
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;

View file

@ -71,6 +71,22 @@ function createFakeTUIWithConsumingFocusTracker(): FakeTUI {
} as unknown as FakeTUI;
}
// Drive the controller through its first clipboard observation with an empty
// clipboard. The first observation only establishes a baseline and never shows
// a hint, leaving the controller armed and ready for the next new image.
async function primeEmptyBaseline(ui: FakeTUI): Promise<void> {
vi.mocked(clipboardHasImage).mockResolvedValue(false);
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
}
// Simulate the user returning to the terminal and let the debounced check fire.
async function focusReturnAndFlush(ui: FakeTUI): Promise<void> {
ui.emitInput(TERMINAL_FOCUS_OUT);
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
}
describe('ClipboardImageHintController', () => {
let platformSpy: ReturnType<typeof vi.spyOn> | undefined;
@ -86,7 +102,7 @@ describe('ClipboardImageHintController', () => {
vi.useRealTimers();
});
it('shows hint when focus returns and clipboard has image', async () => {
it('does not show a hint for an image already in the clipboard at startup', async () => {
vi.mocked(clipboardHasImage).mockResolvedValue(true);
const footer = createFakeFooter();
@ -101,11 +117,62 @@ describe('ClipboardImageHintController', () => {
const controller = new ClipboardImageHintController(host);
controller.start();
ui.emitInput(TERMINAL_FOCUS_OUT);
// Startup baseline observes the image already present; the focus check
// runs too but must stay quiet for that same image.
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
// One call is the startup baseline; the second is the focus check.
expect(clipboardHasImage).toHaveBeenCalledTimes(2);
expect(footer.getTransientHint()).toBeNull();
controller.stop();
});
it('shows hint when a new image is copied during the session', async () => {
const footer = createFakeFooter();
const ui = createFakeTUI();
const host: ClipboardImageHintHost = {
ui,
footer,
getModelSupportsImage: () => true,
requestRender: vi.fn(),
};
const controller = new ClipboardImageHintController(host);
controller.start();
await primeEmptyBaseline(ui);
vi.mocked(clipboardHasImage).mockResolvedValue(true);
await focusReturnAndFlush(ui);
expect(footer.getTransientHint()).toMatch(/Image in clipboard/);
expect(footer.getTransientHint()).toMatch(/Ctrl\+V/);
controller.stop();
});
it('shows hint for the first image copied after startup when startup baseline was empty', async () => {
const footer = createFakeFooter();
const ui = createFakeTUI();
const host: ClipboardImageHintHost = {
ui,
footer,
getModelSupportsImage: () => true,
requestRender: vi.fn(),
};
const controller = new ClipboardImageHintController(host);
controller.start();
await vi.advanceTimersByTimeAsync(0);
expect(clipboardHasImage).toHaveBeenCalledTimes(1);
expect(footer.getTransientHint()).toBeNull();
vi.mocked(clipboardHasImage).mockResolvedValue(true);
await focusReturnAndFlush(ui);
expect(footer.getTransientHint()).toMatch(/Image in clipboard/);
expect(footer.getTransientHint()).toMatch(/Ctrl\+V/);
@ -135,9 +202,7 @@ describe('ClipboardImageHintController', () => {
controller.stop();
});
it('respects cooldown between hints', async () => {
vi.mocked(clipboardHasImage).mockResolvedValue(true);
it('does not repeat the hint for the same lingering image', async () => {
const footer = createFakeFooter();
const ui = createFakeTUI();
const host: ClipboardImageHintHost = {
@ -150,22 +215,23 @@ describe('ClipboardImageHintController', () => {
const controller = new ClipboardImageHintController(host);
controller.start();
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
expect(footer.getTransientHint()).not.toBeNull();
// Establish the baseline and show a hint for the first image.
await primeEmptyBaseline(ui);
vi.mocked(clipboardHasImage).mockResolvedValue(true);
await focusReturnAndFlush(ui);
expect(footer.getTransientHint()).toMatch(/Image in clipboard/);
// The same image is still in the clipboard: focusing again must not nag.
vi.mocked(clipboardHasImage).mockClear();
footer.setTransientHint(null);
ui.emitInput(TERMINAL_FOCUS_OUT);
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
await focusReturnAndFlush(ui);
expect(footer.getTransientHint()).toBeNull();
expect(clipboardHasImage).toHaveBeenCalledTimes(1);
controller.stop();
});
it('clears hint after 2 seconds', async () => {
vi.mocked(clipboardHasImage).mockResolvedValue(true);
it('shows the hint again for a new image after the clipboard is cleared', async () => {
const footer = createFakeFooter();
const ui = createFakeTUI();
const host: ClipboardImageHintHost = {
@ -178,8 +244,42 @@ describe('ClipboardImageHintController', () => {
const controller = new ClipboardImageHintController(host);
controller.start();
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
// Establish the baseline, then show a hint for the first image.
await primeEmptyBaseline(ui);
vi.mocked(clipboardHasImage).mockResolvedValue(true);
await focusReturnAndFlush(ui);
expect(footer.getTransientHint()).toMatch(/Image in clipboard/);
// Clipboard cleared: the empty check re-arms the controller.
vi.mocked(clipboardHasImage).mockResolvedValue(false);
footer.setTransientHint(null);
await focusReturnAndFlush(ui);
expect(footer.getTransientHint()).toBeNull();
// A genuinely new image: hint shows again.
vi.mocked(clipboardHasImage).mockResolvedValue(true);
await focusReturnAndFlush(ui);
expect(footer.getTransientHint()).toMatch(/Image in clipboard/);
controller.stop();
});
it('clears the hint after the display duration', async () => {
const footer = createFakeFooter();
const ui = createFakeTUI();
const host: ClipboardImageHintHost = {
ui,
footer,
getModelSupportsImage: () => true,
requestRender: vi.fn(),
};
const controller = new ClipboardImageHintController(host);
controller.start();
await primeEmptyBaseline(ui);
vi.mocked(clipboardHasImage).mockResolvedValue(true);
await focusReturnAndFlush(ui);
expect(footer.getTransientHint()).not.toBeNull();
await vi.advanceTimersByTimeAsync(4000);
@ -203,6 +303,9 @@ describe('ClipboardImageHintController', () => {
const controller = new ClipboardImageHintController(host);
controller.start();
await vi.advanceTimersByTimeAsync(0);
vi.mocked(clipboardHasImage).mockClear();
ui.emitInput(TERMINAL_FOCUS_IN);
ui.emitInput(TERMINAL_FOCUS_OUT);
await vi.advanceTimersByTimeAsync(1000);
@ -214,8 +317,6 @@ describe('ClipboardImageHintController', () => {
});
it('handles rapid focus churn without duplicate checks or hints', async () => {
vi.mocked(clipboardHasImage).mockResolvedValue(true);
const footer = createFakeFooter();
const ui = createFakeTUI();
const host: ClipboardImageHintHost = {
@ -228,6 +329,10 @@ describe('ClipboardImageHintController', () => {
const controller = new ClipboardImageHintController(host);
controller.start();
await primeEmptyBaseline(ui);
vi.mocked(clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardHasImage).mockClear();
for (let i = 0; i < 5; i++) {
ui.emitInput(TERMINAL_FOCUS_OUT);
ui.emitInput(TERMINAL_FOCUS_IN);
@ -260,7 +365,8 @@ describe('ClipboardImageHintController', () => {
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
expect(clipboardHasImage).toHaveBeenCalledTimes(1);
// One call is the startup baseline; the second is the debounced focus check.
expect(clipboardHasImage).toHaveBeenCalledTimes(2);
ui.emitInput(TERMINAL_FOCUS_OUT);
await vi.advanceTimersByTimeAsync(1500);
@ -291,7 +397,8 @@ describe('ClipboardImageHintController', () => {
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
expect(clipboardHasImage).toHaveBeenCalledTimes(1);
// One call is the startup baseline; the second is the debounced focus check.
expect(clipboardHasImage).toHaveBeenCalledTimes(2);
controller.stop();
resolveDeferred(true);
@ -300,8 +407,6 @@ describe('ClipboardImageHintController', () => {
});
it('clears a displayed hint when stopped', async () => {
vi.mocked(clipboardHasImage).mockResolvedValue(true);
const footer = createFakeFooter();
const ui = createFakeTUI();
const host: ClipboardImageHintHost = {
@ -314,8 +419,9 @@ describe('ClipboardImageHintController', () => {
const controller = new ClipboardImageHintController(host);
controller.start();
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
await primeEmptyBaseline(ui);
vi.mocked(clipboardHasImage).mockResolvedValue(true);
await focusReturnAndFlush(ui);
expect(footer.getTransientHint()).not.toBeNull();
controller.stop();
@ -339,6 +445,7 @@ describe('ClipboardImageHintController', () => {
const controller = new ClipboardImageHintController(host);
controller.start();
// First observation only establishes the baseline and sets no hint.
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
const otherHint = 'Other hint';
@ -351,16 +458,6 @@ describe('ClipboardImageHintController', () => {
});
it('uses only the latest clipboard read result after focus churn', async () => {
const deferreds: Array<{ resolve: (value: boolean) => void; promise: Promise<boolean> }> = [];
vi.mocked(clipboardHasImage).mockImplementation(() => {
let resolve: (value: boolean) => void = () => {};
const promise = new Promise<boolean>((res) => {
resolve = res;
});
deferreds.push({ resolve, promise });
return promise;
});
const footer = createFakeFooter();
const ui = createFakeTUI();
const host: ClipboardImageHintHost = {
@ -373,14 +470,28 @@ describe('ClipboardImageHintController', () => {
const controller = new ClipboardImageHintController(host);
controller.start();
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
expect(clipboardHasImage).toHaveBeenCalledTimes(1);
// Establish an empty baseline with a normal resolved read first.
await primeEmptyBaseline(ui);
const deferreds: Array<{ resolve: (value: boolean) => void; promise: Promise<boolean> }> = [];
vi.mocked(clipboardHasImage).mockImplementation(() => {
let resolve: (value: boolean) => void = () => {};
const promise = new Promise<boolean>((res) => {
resolve = res;
});
deferreds.push({ resolve, promise });
return promise;
});
ui.emitInput(TERMINAL_FOCUS_OUT);
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
expect(clipboardHasImage).toHaveBeenCalledTimes(2);
expect(deferreds).toHaveLength(1);
ui.emitInput(TERMINAL_FOCUS_OUT);
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
expect(deferreds).toHaveLength(2);
deferreds[0]!.resolve(true);
await vi.advanceTimersByTimeAsync(0);
@ -394,8 +505,6 @@ describe('ClipboardImageHintController', () => {
});
it('keeps the existing auto-clear timer when a re-check exits early', async () => {
vi.mocked(clipboardHasImage).mockResolvedValue(true);
const footer = createFakeFooter();
const ui = createFakeTUI();
const host: ClipboardImageHintHost = {
@ -408,15 +517,14 @@ describe('ClipboardImageHintController', () => {
const controller = new ClipboardImageHintController(host);
controller.start();
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
await primeEmptyBaseline(ui);
vi.mocked(clipboardHasImage).mockResolvedValue(true);
await focusReturnAndFlush(ui);
expect(footer.getTransientHint()).not.toBeNull();
// Trigger a re-check that exits early because the clipboard is now empty.
vi.mocked(clipboardHasImage).mockResolvedValue(false);
ui.emitInput(TERMINAL_FOCUS_OUT);
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
await focusReturnAndFlush(ui);
// The previous hint should still be visible because its auto-clear timer
// was preserved through the re-check.
@ -430,8 +538,6 @@ describe('ClipboardImageHintController', () => {
});
it('does not clear a matching hint owned by another caller after auto-clear', async () => {
vi.mocked(clipboardHasImage).mockResolvedValue(true);
const footer = createFakeFooter();
const ui = createFakeTUI();
const host: ClipboardImageHintHost = {
@ -444,8 +550,9 @@ describe('ClipboardImageHintController', () => {
const controller = new ClipboardImageHintController(host);
controller.start();
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
await primeEmptyBaseline(ui);
vi.mocked(clipboardHasImage).mockResolvedValue(true);
await focusReturnAndFlush(ui);
const hintText = footer.getTransientHint();
expect(hintText).not.toBeNull();
@ -459,7 +566,7 @@ describe('ClipboardImageHintController', () => {
expect(footer.getTransientHint()).toBe(hintText);
});
it('does not inherit cooldown after stop and restart', async () => {
it('re-establishes the baseline after stop and restart', async () => {
vi.mocked(clipboardHasImage).mockResolvedValue(true);
const footer = createFakeFooter();
@ -474,26 +581,32 @@ describe('ClipboardImageHintController', () => {
const controller = new ClipboardImageHintController(host);
controller.start();
// Image already present at start: baseline only, no hint.
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
expect(footer.getTransientHint()).not.toBeNull();
expect(footer.getTransientHint()).toBeNull();
controller.stop();
controller.start();
footer.setTransientHint(null);
ui.emitInput(TERMINAL_FOCUS_OUT);
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
// After restart the image is still present: baseline again, no hint.
await focusReturnAndFlush(ui);
expect(footer.getTransientHint()).toBeNull();
expect(footer.getTransientHint()).not.toBeNull();
// Clipboard cleared: re-arms the controller.
vi.mocked(clipboardHasImage).mockResolvedValue(false);
await focusReturnAndFlush(ui);
expect(footer.getTransientHint()).toBeNull();
// A genuinely new image: hint shows.
vi.mocked(clipboardHasImage).mockResolvedValue(true);
await focusReturnAndFlush(ui);
expect(footer.getTransientHint()).toMatch(/Image in clipboard/);
controller.stop();
});
it('observes focus events even when another listener consumes them', async () => {
vi.mocked(clipboardHasImage).mockResolvedValue(true);
const footer = createFakeFooter();
const ui = createFakeTUIWithConsumingFocusTracker();
const host: ClipboardImageHintHost = {
@ -516,11 +629,17 @@ describe('ClipboardImageHintController', () => {
return undefined;
});
// Baseline observation (consumed), then a new image on the next focus.
vi.mocked(clipboardHasImage).mockResolvedValue(false);
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
vi.mocked(clipboardHasImage).mockResolvedValue(true);
ui.emitInput(TERMINAL_FOCUS_OUT);
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
expect(consumedEvents).toEqual([TERMINAL_FOCUS_OUT, TERMINAL_FOCUS_IN]);
expect(consumedEvents).toEqual([TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT, TERMINAL_FOCUS_IN]);
expect(footer.getTransientHint()).toMatch(/Image in clipboard/);
controller.stop();
@ -529,7 +648,6 @@ describe('ClipboardImageHintController', () => {
it('shows Alt+V shortcut on Windows', async () => {
platformSpy?.mockRestore();
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
vi.mocked(clipboardHasImage).mockResolvedValue(true);
const footer = createFakeFooter();
const ui = createFakeTUI();
@ -543,8 +661,9 @@ describe('ClipboardImageHintController', () => {
const controller = new ClipboardImageHintController(host);
controller.start();
ui.emitInput(TERMINAL_FOCUS_IN);
await vi.advanceTimersByTimeAsync(1000);
await primeEmptyBaseline(ui);
vi.mocked(clipboardHasImage).mockResolvedValue(true);
await focusReturnAndFlush(ui);
expect(footer.getTransientHint()).toMatch(/Alt\+V/);

View file

@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { runCommandAsync } from '#/utils/clipboard/clipboard-common';
describe('runCommandAsync', () => {
it('resolves with stdout for a successful command', async () => {
const result = await runCommandAsync(process.execPath, ['-e', 'process.stdout.write("hello")']);
expect(result.ok).toBe(true);
expect(result.stdout.toString('utf-8')).toBe('hello');
});
it('resolves ok:false for a non-zero exit', async () => {
const result = await runCommandAsync(process.execPath, ['-e', 'process.exit(3)']);
expect(result.ok).toBe(false);
});
it('does not block when the command exceeds the timeout', async () => {
const timeoutMs = 100;
const start = Date.now();
// The child would idle for 30s if left running; runCommandAsync must kill
// it and resolve well before that so a wedged helper cannot freeze launch.
const result = await runCommandAsync(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], {
timeoutMs,
});
const elapsed = Date.now() - start;
expect(result.ok).toBe(false);
expect(elapsed).toBeLessThan(5000);
});
});

View file

@ -25,7 +25,7 @@ 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(() => ({ stdout: Buffer.alloc(0), ok: false }));
const runCommand = vi.fn(async () => ({ stdout: Buffer.alloc(0), ok: false }));
const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip, runCommand });
expect(result).toBe(false);
expect(runCommand).not.toHaveBeenCalledWith('osascript', expect.anything(), expect.anything());
@ -52,7 +52,7 @@ describe('clipboardHasImage', () => {
});
it('detects image on Wayland via wl-paste list-types', async () => {
const runCommand = vi.fn((command: string, args: string[]) => {
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 };
}
@ -63,7 +63,7 @@ describe('clipboardHasImage', () => {
});
it('returns false on Wayland when target list contains unsupported MIME types', async () => {
const runCommand = vi.fn((command: string, args: string[]) => {
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 };
}
@ -80,7 +80,7 @@ describe('clipboardHasImage', () => {
});
it('falls back to xclip on Wayland when wl-paste reports no image', async () => {
const runCommand = vi.fn((command: string, args: string[]) => {
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 };
}
@ -99,7 +99,7 @@ describe('clipboardHasImage', () => {
});
it('detects image on X11 via xclip TARGETS', async () => {
const runCommand = vi.fn((command: string, args: string[]) => {
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 };
}
@ -110,7 +110,7 @@ describe('clipboardHasImage', () => {
});
it('returns false on X11 when target list contains unsupported MIME types', async () => {
const runCommand = vi.fn((command: string, args: string[]) => {
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 };
}
@ -122,7 +122,7 @@ describe('clipboardHasImage', () => {
});
it('returns false on X11 when target list is empty', async () => {
const runCommand = vi.fn((command: string, args: string[]) => {
const runCommand = vi.fn(async (command: string, args: string[]) => {
if (command === 'xclip' && args.includes('TARGETS')) {
return { stdout: Buffer.from('TARGETS\n'), ok: true };
}
@ -135,20 +135,20 @@ describe('clipboardHasImage', () => {
it('falls back to native hasImage on Linux X11 when xclip fails', async () => {
const clip = fakeClipboard({ hasImage: vi.fn(() => true) });
const runCommand = vi.fn(() => ({ stdout: Buffer.alloc(0), ok: 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(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(() => ({ stdout: Buffer.alloc(0), ok: 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((command: string) => {
const runCommand = vi.fn(async (command: string) => {
if (command === 'powershell.exe') {
return { stdout: Buffer.from('True\n'), ok: true };
}
@ -163,7 +163,7 @@ describe('clipboardHasImage', () => {
});
it('detects WSL via WSLENV and checks PowerShell', async () => {
const runCommand = vi.fn((command: string) => {
const runCommand = vi.fn(async (command: string) => {
if (command === 'powershell.exe') {
return { stdout: Buffer.from('True\n'), ok: true };
}
@ -178,7 +178,7 @@ describe('clipboardHasImage', () => {
});
it('returns false on Linux when runCommand fails for all fallbacks', async () => {
const runCommand = vi.fn(() => ({ stdout: Buffer.alloc(0), ok: false }));
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);
@ -186,7 +186,7 @@ describe('clipboardHasImage', () => {
it('detects image on Windows via native clipboard', async () => {
const clip = fakeClipboard({ hasImage: vi.fn(() => true) });
const runCommand = vi.fn(() => ({ stdout: Buffer.alloc(0), ok: false }));
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());
@ -194,7 +194,7 @@ describe('clipboardHasImage', () => {
it('returns false on Windows when native clipboard reports no image', async () => {
const clip = fakeClipboard({ hasImage: vi.fn(() => false) });
const runCommand = vi.fn((command: string) => {
const runCommand = vi.fn(async (command: string) => {
if (command === 'powershell.exe') {
return { stdout: Buffer.from('True\n'), ok: true };
}