diff --git a/.changeset/clipboard-image-hint-once.md b/.changeset/clipboard-image-hint-once.md new file mode 100644 index 000000000..2e14713e4 --- /dev/null +++ b/.changeset/clipboard-image-hint-once.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Improve the image paste hint. diff --git a/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts index 922e30fdd..eccb3f3fb 100644 --- a/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts +++ b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts @@ -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; diff --git a/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts index 2999845d6..ac1fd8c11 100644 --- a/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts +++ b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts @@ -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 | undefined; private clearHintTimer: ReturnType | 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 { + 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 { 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(); diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-common.ts b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts index 9844dc4fd..dc0f60886 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-common.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts @@ -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; + + // 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'; } 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 54f5f3be1..dc696d31c 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts @@ -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 { + 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 { + 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 { 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 { 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 | undefined; + const xclipHasImage = (): Promise => { 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; diff --git a/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts b/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts index 435d1263b..0c69ebac9 100644 --- a/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts +++ b/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts @@ -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 { + 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 { + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); +} + describe('ClipboardImageHintController', () => { let platformSpy: ReturnType | 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 }> = []; - vi.mocked(clipboardHasImage).mockImplementation(() => { - let resolve: (value: boolean) => void = () => {}; - const promise = new Promise((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 }> = []; + vi.mocked(clipboardHasImage).mockImplementation(() => { + let resolve: (value: boolean) => void = () => {}; + const promise = new Promise((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/); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts new file mode 100644 index 000000000..c6aba57a4 --- /dev/null +++ b/apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts @@ -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); + }); +}); 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 5aeb0f137..16edebb2f 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,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 }; }