From be77d5da03b96ebc24169ef563be1dc1c545590f Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 20:49:01 +0800 Subject: [PATCH] feat(kimi-code): show clipboard image paste hint in footer (#1028) * feat(kimi-code): add lightweight clipboard image detection * fix(clipboard): correct Linux X11 image detection and extract shared helpers - Extract shared clipboard constants/helpers into clipboard-common.ts - Fix Linux X11 branch calling macOS-only osascript - Use native hasImage() on Linux X11, macOS, and Windows with fallbacks - Compute xclip result once and reuse on Linux - Add test coverage for unsupported MIME types, empty targets, failures, WSL, and native fallbacks * fix(kimi-code): restore Wayland/WSL xclip fallback in clipboard image detection * feat(kimi-code): add clipboard image hint controller * fix(kimi-code): clipboard image hint focus race and cleanup * fix(kimi-code): prevent clipboard image hint from clearing unrelated hints and stale reads * fix(clipboard-image-hint): lifecycle issues and platform-dependent tests * fix(kimi-code): invalidate pending clipboard hint read on stop * feat(kimi-code): wire clipboard image hint controller into TUI * style(kimi-code): wrap void expression in braces to fix lint warning * style(kimi-code): prefer nullish coalescing in clipboard image detection * chore: add changeset for clipboard image footer hint * fix(kimi-code): let clipboard image hint observe non-consuming focus events * fix(kimi-code): extend clipboard image hint display duration to 4 seconds * docs: mention clipboard image footer hint in interaction guide * chore: downgrade clipboard image hint changeset to patch * Revert "docs: mention clipboard image footer hint in interaction guide" This reverts commit 0fd50dcc9b89c2b4a8fe1e919de820656d42c46b. * fix(cli): avoid treating copied Finder files as images on macOS Filter file-like native clipboard formats in clipboardHasImage() before calling native hasImage(), mirroring the guard already used by readClipboardMedia(). This prevents copied Finder files from being mis-detected as pasteable images because macOS exposes their thumbnails as image data. * fix(cli): align image detection with paste path on macOS and Windows Remove osascript and PowerShell fallbacks from clipboardHasImage() on macOS and Windows. The paste reader (readClipboardMedia()) only uses the native clipboard module for images on those platforms, so detecting images via methods the reader cannot consume produced misleading footer hints. Linux fallbacks (wl-paste / xclip / PowerShell under WSL) remain because they match the actual paste path. * fix(tui): do not truncate inline image escape sequences UserMessageComponent applied truncateToWidth() to every rendered line, including the Kitty / iTerm2 inline image escape sequences produced by ImageThumbnail. pi-tui treats the embedded base64 payload inside those sequences as visible text, so truncation chopped the escape code and left behind '0m...' garbage instead of the image. Skip truncation for lines that contain an inline image protocol sequence; the image already respects maxWidthCells via ImageThumbnail. * fix(tui): clear stale rows when content shrinks Enable pi-tui's setClearOnShrink so that when a tall inline image is replaced by shorter content (e.g. after sending a message), the terminal rows the image previously occupied are cleared. Without this, pi-tui's differential renderer can leave behind artifacts such as duplicated input boxes. * chore: add changesets for inline image rendering fixes * test(cli): stabilize pi-tui capability mocks in concurrent test runs * test(cli): use setCapabilities instead of mocked getCapabilities --- .changeset/clipboard-image-footer-hint.md | 5 + .changeset/fix-clear-on-shrink.md | 5 + .changeset/fix-inline-image-truncation.md | 5 + .../tui/components/messages/user-message.ts | 14 +- .../src/tui/constant/clipboard-image-hint.ts | 4 + .../tui/controllers/clipboard-image-hint.ts | 124 ++++ apps/kimi-code/src/tui/kimi-tui.ts | 17 + apps/kimi-code/src/tui/tui-state.ts | 5 + .../src/utils/clipboard/clipboard-common.ts | 85 +++ .../utils/clipboard/clipboard-has-image.ts | 101 ++++ .../src/utils/clipboard/clipboard-image.ts | 93 +-- .../components/media/image-thumbnail.test.ts | 19 +- .../components/messages/user-message.test.ts | 64 +- .../controllers/clipboard-image-hint.test.ts | 553 ++++++++++++++++++ .../clipboard/clipboard-has-image.test.ts | 206 +++++++ 15 files changed, 1207 insertions(+), 93 deletions(-) create mode 100644 .changeset/clipboard-image-footer-hint.md create mode 100644 .changeset/fix-clear-on-shrink.md create mode 100644 .changeset/fix-inline-image-truncation.md create mode 100644 apps/kimi-code/src/tui/constant/clipboard-image-hint.ts create mode 100644 apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts create mode 100644 apps/kimi-code/src/utils/clipboard/clipboard-common.ts create mode 100644 apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts create mode 100644 apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts create mode 100644 apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts diff --git a/.changeset/clipboard-image-footer-hint.md b/.changeset/clipboard-image-footer-hint.md new file mode 100644 index 000000000..028641827 --- /dev/null +++ b/.changeset/clipboard-image-footer-hint.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show a transient footer hint when an image is detected in the clipboard, displaying the platform-appropriate paste shortcut. diff --git a/.changeset/fix-clear-on-shrink.md b/.changeset/fix-clear-on-shrink.md new file mode 100644 index 000000000..51cf00e03 --- /dev/null +++ b/.changeset/fix-clear-on-shrink.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix stale rows occasionally leaving duplicate input boxes after tall content shrinks. diff --git a/.changeset/fix-inline-image-truncation.md b/.changeset/fix-inline-image-truncation.md new file mode 100644 index 000000000..3595c0474 --- /dev/null +++ b/.changeset/fix-inline-image-truncation.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix inline images being rendered as broken escape sequences in the transcript. diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index 778a0407a..43ed13459 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -57,6 +57,18 @@ export class UserMessageComponent implements Component { } } - return lines.map((line) => truncateToWidth(line, safeWidth, '…')); + return lines.map((line) => { + // Inline image sequences (Kitty / iTerm2) carry their own placement + // information and have zero visible width, but pi-tui's truncateToWidth + // treats the embedded base64 payload as visible text and would chop the + // escape sequence in half, leaving garbage like "0m...". Skip truncation + // for those lines; the image itself already respects maxWidthCells. + if (isImageLine(line)) return line; + return truncateToWidth(line, safeWidth, '…'); + }); } } + +function isImageLine(line: string): boolean { + return line.includes('\u001B_G') || line.includes('\u001B]1337;File='); +} diff --git a/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts new file mode 100644 index 000000000..922e30fdd --- /dev/null +++ b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts @@ -0,0 +1,4 @@ +// 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 new file mode 100644 index 000000000..2999845d6 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts @@ -0,0 +1,124 @@ +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 { TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT } from '../utils/terminal-focus'; +import type { FooterComponent } from '../components/chrome/footer'; + +export interface ClipboardImageHintHost { + readonly ui: TUI; + readonly footer: FooterComponent; + getModelSupportsImage(): boolean; + requestRender(): void; +} + +function getPasteImageShortcut(): string { + return process.platform === 'win32' ? 'Alt+V' : 'Ctrl+V'; +} + +export class ClipboardImageHintController { + private readonly host: ClipboardImageHintHost; + 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; + + constructor(host: ClipboardImageHintHost) { + this.host = host; + } + + start(): void { + this.disposeInputListener = this.host.ui.addInputListener((data) => { + this.handleInput(data); + }); + } + + stop(): void { + this.clearDebounceTimer(); + this.clearClearHintTimer(); + this.disposeInputListener?.(); + this.disposeInputListener = undefined; + + this.checkGeneration += 1; + this.clearOwnedHint(); + this.lastHintAtMs = 0; + } + + private handleInput(data: string): void { + if (data === TERMINAL_FOCUS_IN) { + this.focused = true; + this.scheduleCheck(); + return; + } + if (data === TERMINAL_FOCUS_OUT) { + this.focused = false; + this.clearDebounceTimer(); + return; + } + } + + private scheduleCheck(): void { + this.clearDebounceTimer(); + this.checkGeneration += 1; + const generation = this.checkGeneration; + this.debounceTimer = setTimeout(() => void this.runCheck(generation), FOCUS_DEBOUNCE_MS); + } + + private clearDebounceTimer(): void { + if (this.debounceTimer !== undefined) { + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; + } + } + + private clearClearHintTimer(): void { + if (this.clearHintTimer !== undefined) { + clearTimeout(this.clearHintTimer); + this.clearHintTimer = undefined; + } + } + + private clearOwnedHint(): void { + if (this.host.footer.getTransientHint() === this.lastHintText) { + this.host.footer.setTransientHint(null); + this.host.requestRender(); + } + this.lastHintText = undefined; + } + + 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 { + hasImage = await clipboardHasImage(); + } catch { + return; + } + + if (generation !== this.checkGeneration) return; + if (!this.focused) return; + if (!hasImage) return; + + const hintText = `Image in clipboard · ${getPasteImageShortcut()} to paste`; + this.clearClearHintTimer(); + this.lastHintText = hintText; + this.host.footer.setTransientHint(hintText); + this.host.requestRender(); + this.lastHintAtMs = Date.now(); + + this.clearHintTimer = setTimeout(() => { + this.clearOwnedHint(); + }, HINT_DISPLAY_MS); + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index b18125711..3a1023f05 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -94,6 +94,7 @@ import { CHROME_GUTTER } from './constant/rendering'; import { MAX_TERMINAL_TITLE_LENGTH } from './constant/terminal'; import { AuthFlowController } from './controllers/auth-flow'; import { BtwPanelController } from './controllers/btw-panel'; +import { ClipboardImageHintController } from './controllers/clipboard-image-hint'; import { EditorKeyboardController } from './controllers/editor-keyboard'; import { SessionEventHandler } from './controllers/session-event-handler'; import { SessionReplayRenderer } from './controllers/session-replay'; @@ -239,6 +240,7 @@ export class KimiTUI { aborted = false; private terminalFocusTrackingDispose: (() => void) | undefined; private terminalThemeTrackingDispose: (() => void) | undefined; + private clipboardImageHintController: ClipboardImageHintController | undefined; private uninstallRainbowDance: () => void; private signalCleanupHandlers: Array<() => void> = []; private isShuttingDown = false; @@ -515,10 +517,23 @@ export class KimiTUI { private startEventLoop(): void { this.state.ui.start(); + this.startClipboardImageHintController(); this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state); this.refreshTerminalThemeTracking(); } + private startClipboardImageHintController(): void { + this.clipboardImageHintController = new ClipboardImageHintController({ + ui: this.state.ui, + footer: this.state.footer, + getModelSupportsImage: () => this.supportsCurrentModelCapability('image_in'), + requestRender: () => { + this.state.ui.requestRender(); + }, + }); + this.clipboardImageHintController.start(); + } + private startBackgroundFdAutocomplete(): void { if (this.fdPath !== null || this.fdDownloadStarted) return; this.fdDownloadStarted = true; @@ -765,6 +780,8 @@ export class KimiTUI { private disposeTerminalTracking(): void { this.stopTerminalThemeTracking(); + this.clipboardImageHintController?.stop(); + this.clipboardImageHintController = undefined; this.terminalFocusTrackingDispose?.(); this.terminalFocusTrackingDispose = undefined; } diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 6a9594f01..8625d31be 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -60,6 +60,11 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const terminal = new ProcessTerminal(); const ui = new TUI(terminal); + // Content shrinks (e.g. a tall inline image is replaced by a short + // placeholder or text) can leave stale rows behind because pi-tui's + // differential renderer does not clear them by default. Enable clearing so + // artifacts like duplicated input boxes do not accumulate. + ui.setClearOnShrink(true); const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-common.ts b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts new file mode 100644 index 000000000..9844dc4fd --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; + +import type { ClipboardModule } from './clipboard-native'; + +export type RunCommandOptions = { timeoutMs?: number; env?: NodeJS.ProcessEnv }; +export type RunCommand = ( + command: string, + args: string[], + options?: RunCommandOptions, +) => { stdout: Buffer; ok: boolean }; + +export const SUPPORTED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; + +export const DEFAULT_LIST_TIMEOUT_MS = 1000; +export const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024; + +export function baseMimeType(raw: string): string { + return raw.split(';')[0]?.trim().toLowerCase() ?? raw.toLowerCase(); +} + +export function isSupportedImageMimeType(mime: string): boolean { + const base = baseMimeType(mime); + return (SUPPORTED_IMAGE_MIME_TYPES as readonly string[]).includes(base); +} + +export function parseTargetList(output: Buffer): string[] { + return output + .toString('utf-8') + .split(/\r?\n/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + +export function runCommand( + command: string, + args: string[], + options?: RunCommandOptions, +): { stdout: Buffer; ok: boolean } { + const result = spawnSync(command, args, { + timeout: options?.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS, + maxBuffer: DEFAULT_MAX_BUFFER_BYTES, + env: options?.env, + }); + if (result.error !== undefined || result.status !== 0) { + return { ok: false, stdout: Buffer.alloc(0) }; + } + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? ''); + return { ok: true, stdout }; +} + +export function isWaylandSession(env: NodeJS.ProcessEnv): boolean { + return Boolean(env['WAYLAND_DISPLAY']) || env['XDG_SESSION_TYPE'] === 'wayland'; +} + +export function isWSL(env: NodeJS.ProcessEnv): boolean { + if (env['WSL_DISTRO_NAME'] !== undefined || env['WSLENV'] !== undefined) return true; + try { + return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf-8')); + } catch { + return false; + } +} + +export function isFileLikeNativeFormat(format: string): boolean { + const f = format.toLowerCase(); + const base = baseMimeType(format); + return ( + f.includes('file-url') || + f.includes('file url') || + f.includes('nsfilenames') || + f.includes('com.apple.finder') || + base === 'text/uri-list' || + base === 'public.url' + ); +} + +export function safeAvailableFormats(clip: ClipboardModule | null): string[] { + if (clip?.availableFormats === undefined) return []; + try { + return clip.availableFormats(); + } catch { + return []; + } +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts new file mode 100644 index 000000000..54f5f3be1 --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts @@ -0,0 +1,101 @@ +import { + DEFAULT_LIST_TIMEOUT_MS, + isFileLikeNativeFormat, + isSupportedImageMimeType, + isWaylandSession, + isWSL, + parseTargetList, + runCommand, + safeAvailableFormats, + type RunCommand, +} 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 }); + 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'], { + timeoutMs: DEFAULT_LIST_TIMEOUT_MS, + }); + if (!targets.ok) return false; + return parseTargetList(targets.stdout).some((t) => isSupportedImageMimeType(t)); +} + +function hasImageViaPowerShell(run: RunCommand): 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], { + timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS, + }); + if (!result.ok) return false; + const output = result.stdout.toString('utf-8').trim().toLowerCase(); + return output === 'true'; +} + +async function hasImageViaNative(clip: ClipboardModule | null): Promise { + if (clip === null) return false; + + // Finder exposes file icons/thumbnails as image data when a non-image file + // is copied. Treat file-like clipboard contents as "not a pasteable image" + // to match the read path in clipboard-image.ts. + const formats = safeAvailableFormats(clip); + if (formats.some(isFileLikeNativeFormat)) return false; + + try { + return clip.hasImage(); + } catch { + return false; + } +} + +export async function clipboardHasImage(options?: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + clipboard?: ClipboardModule | null; + runCommand?: RunCommand; +}): Promise { + const env = options?.env ?? process.env; + const platform = options?.platform ?? process.platform; + const clip = options?.clipboard ?? clipboard; + const run = options?.runCommand ?? runCommand; + + 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 => { + xclipResult ??= hasImageViaXclip(run); + return xclipResult; + }; + + if (wayland || wsl) { + if (hasImageViaWlPaste(run)) return true; + if (xclipHasImage()) return true; + } + if (wsl && hasImageViaPowerShell(run)) return true; + if (!wayland) { + if (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; +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts index 0bb8d3c76..6aae761c4 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts @@ -16,7 +16,6 @@ * supported, or every fallback fails. */ -import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { readFileSync, statSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -25,6 +24,20 @@ import { fileURLToPath } from 'node:url'; import { parseImageMeta } from '#/utils/image/image-mime'; +import { + DEFAULT_LIST_TIMEOUT_MS, + SUPPORTED_IMAGE_MIME_TYPES, + baseMimeType, + isFileLikeNativeFormat, + isSupportedImageMimeType, + isWaylandSession, + isWSL, + parseTargetList, + runCommand as runCommandBase, + safeAvailableFormats, + type RunCommand, + type RunCommandOptions, +} from './clipboard-common'; import { clipboard, type ClipboardModule } from './clipboard-native'; export interface ClipboardImage { @@ -49,14 +62,6 @@ export class ClipboardMediaError extends Error { } } -type RunCommandOptions = { timeoutMs?: number; env?: NodeJS.ProcessEnv }; -type RunCommand = ( - command: string, - args: string[], - options?: RunCommandOptions, -) => { stdout: Buffer; ok: boolean }; - -const SUPPORTED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; const MAX_VIDEO_BYTES = 100 * 1024 * 1024; const VIDEO_MIME_BY_SUFFIX: Readonly> = Object.freeze({ @@ -75,10 +80,8 @@ const VIDEO_MIME_BY_SUFFIX: Readonly> = Object.freeze({ '.3g2': 'video/3gpp2', }); -const DEFAULT_LIST_TIMEOUT_MS = 1000; const DEFAULT_READ_TIMEOUT_MS = 3000; const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000; -const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024; const MACOS_FILE_PATH_SCRIPT = String.raw` ObjC.import('AppKit'); @@ -115,28 +118,6 @@ if (String(pb) !== '[id nil]') { out.join('\n'); `.trim(); -function isWaylandSession(env: NodeJS.ProcessEnv): boolean { - return Boolean(env['WAYLAND_DISPLAY']) || env['XDG_SESSION_TYPE'] === 'wayland'; -} - -function isWSL(env: NodeJS.ProcessEnv): boolean { - if (env['WSL_DISTRO_NAME'] !== undefined || env['WSLENV'] !== undefined) return true; - try { - return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf-8')); - } catch { - return false; - } -} - -function baseMimeType(raw: string): string { - return raw.split(';')[0]?.trim().toLowerCase() ?? raw.toLowerCase(); -} - -function isSupportedImageMimeType(mime: string): boolean { - const base = baseMimeType(mime); - return (SUPPORTED_IMAGE_MIME_TYPES as readonly string[]).includes(base); -} - function selectPreferredImageMimeType(candidates: string[]): string | null { const normalized = candidates .map((t) => t.trim()) @@ -253,29 +234,11 @@ function readMediaFromText(text: string): ClipboardMedia | null { return readMediaFromPaths(parseClipboardPaths(text)); } -function runCommand( - command: string, - args: string[], - options?: RunCommandOptions, -): { stdout: Buffer; ok: boolean } { - const result = spawnSync(command, args, { - timeout: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS, - maxBuffer: DEFAULT_MAX_BUFFER_BYTES, +function runCommand(command: string, args: string[], options?: RunCommandOptions): { stdout: Buffer; ok: boolean } { + return runCommandBase(command, args, { + timeoutMs: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS, env: options?.env, }); - if (result.error !== undefined || result.status !== 0) { - return { ok: false, stdout: Buffer.alloc(0) }; - } - const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? ''); - return { ok: true, stdout }; -} - -function parseTargetList(output: Buffer): string[] { - return output - .toString('utf-8') - .split(/\r?\n/) - .map((t) => t.trim()) - .filter((t) => t.length > 0); } function readClipboardFileMediaViaWlPaste(): ClipboardMedia | null { @@ -394,28 +357,6 @@ function readClipboardFilePathsViaMacOs(run: RunCommand): string[] { return parseClipboardPaths(result.stdout.toString('utf-8')); } -function isFileLikeNativeFormat(format: string): boolean { - const f = format.toLowerCase(); - const base = baseMimeType(format); - return ( - f.includes('file-url') || - f.includes('file url') || - f.includes('nsfilenames') || - f.includes('com.apple.finder') || - base === 'text/uri-list' || - base === 'public.url' - ); -} - -function safeAvailableFormats(clip: ClipboardModule | null): string[] { - if (clip?.availableFormats === undefined) return []; - try { - return clip.availableFormats(); - } catch { - return []; - } -} - async function readClipboardFileMediaViaNativeText( clip: ClipboardModule | null, ): Promise<{ media: ClipboardMedia | null; lookedFileLike: boolean }> { diff --git a/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts b/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts index fb070f6c1..d2bac1561 100644 --- a/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts +++ b/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts @@ -1,19 +1,9 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@earendil-works/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; -const getCapabilitiesMock = vi.hoisted(() => vi.fn()); - -vi.mock('@earendil-works/pi-tui', async () => { - const actual = (await vi.importActual('@earendil-works/pi-tui')) as Record; - return { - ...actual, - getCapabilities: getCapabilitiesMock, - }; -}); - const image: ImageAttachment = { id: 1, kind: 'image', @@ -26,11 +16,13 @@ const image: ImageAttachment = { describe('ImageThumbnail', () => { afterEach(() => { + resetCapabilitiesCache(); vi.restoreAllMocks(); }); it('keeps rendered output within narrow widths', () => { - getCapabilitiesMock.mockReturnValue({ images: undefined } as never); + setCapabilities({ images: null, trueColor: false, hyperlinks: false }); + const component = new ImageThumbnail(image); for (const width of [39, 20, 3, 1]) { @@ -41,7 +33,8 @@ describe('ImageThumbnail', () => { }); it('does not rebuild inline image children on repeated same-width renders', () => { - getCapabilitiesMock.mockReturnValue({ images: 'kitty' } as never); + setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true }); + const bufferFrom = vi.spyOn(Buffer, 'from'); const component = new ImageThumbnail(image); bufferFrom.mockClear(); diff --git a/apps/kimi-code/test/tui/components/messages/user-message.test.ts b/apps/kimi-code/test/tui/components/messages/user-message.test.ts index 4c2cb3642..3d957eb5b 100644 --- a/apps/kimi-code/test/tui/components/messages/user-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/user-message.test.ts @@ -1,15 +1,21 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import { describe, expect, it } from 'vitest'; +import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@earendil-works/pi-tui'; +import { afterEach, describe, expect, it } from 'vitest'; import { UserMessageComponent } from '#/tui/components/messages/user-message'; -import { darkColors } from '#/tui/theme/colors'; +import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } describe('UserMessageComponent', () => { + afterEach(() => { + resetCapabilitiesCache(); + }); + it('renders video placeholders as plain text, not inline image escapes', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + const component = new UserMessageComponent( 'please inspect [video #1 sample.mov]', [], @@ -23,6 +29,8 @@ describe('UserMessageComponent', () => { }); it('keeps user lines within very narrow widths', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + const component = new UserMessageComponent('please inspect the attached output', []); for (const width of [1, 2, 4, 10, 39]) { @@ -31,4 +39,54 @@ describe('UserMessageComponent', () => { } } }); + + it('does not truncate inline image escape sequences', () => { + setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true }); + + // Minimal 2000x1302 PNG bytes so the inline Kitty sequence is long enough + // to exceed a typical terminal width if treated as visible text. + const pngSignature = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const ihdrLength = new Uint8Array([0x00, 0x00, 0x00, 0x0d]); + const ihdrType = new Uint8Array([0x49, 0x48, 0x44, 0x52]); + const widthBytes = new Uint8Array([ + (2000 >> 24) & 0xff, + (2000 >> 16) & 0xff, + (2000 >> 8) & 0xff, + 2000 & 0xff, + ]); + const heightBytes = new Uint8Array([ + (1302 >> 24) & 0xff, + (1302 >> 16) & 0xff, + (1302 >> 8) & 0xff, + 1302 & 0xff, + ]); + const rest = new Uint8Array([0x08, 0x02, 0x00, 0x00, 0x00]); + const bytes = new Uint8Array([ + ...pngSignature, + ...ihdrLength, + ...ihdrType, + ...widthBytes, + ...heightBytes, + ...rest, + ]); + + const attachment: ImageAttachment = { + id: 1, + kind: 'image', + bytes, + mime: 'image/png', + width: 2000, + height: 1302, + placeholder: '[image #1 (2000×1302)]', + }; + + const component = new UserMessageComponent('', [attachment]); + const lines = component.render(80); + + const imageLine = lines.find((l) => l.includes('\u001B_G')); + expect(imageLine).toBeDefined(); + expect(imageLine).not.toContain('\u001B[0m'); + expect(imageLine).not.toContain('…'); + expect(imageLine).toContain('\u001B\\'); // intact Kitty terminator + }); }); 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 new file mode 100644 index 000000000..435d1263b --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts @@ -0,0 +1,553 @@ +import type { TUI } from '@earendil-works/pi-tui'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + ClipboardImageHintController, + type ClipboardImageHintHost, +} from '#/tui/controllers/clipboard-image-hint'; +import type { FooterComponent } from '#/tui/components/chrome/footer'; +import { TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT } from '#/tui/utils/terminal-focus'; +import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; + +vi.mock('#/utils/clipboard/clipboard-has-image', () => ({ + clipboardHasImage: vi.fn(async () => false), +})); + +type FakeTUI = TUI & { emitInput(data: string): void }; + +interface FakeFooter { + hint: string | null; + setTransientHint(hint: string | null): void; + getTransientHint(): string | null; +} + +function createFakeFooter(): FooterComponent { + const footer: FakeFooter = { + hint: null, + setTransientHint(hint: string | null): void { + this.hint = hint; + }, + getTransientHint(): string | null { + return this.hint; + }, + }; + return footer as unknown as FooterComponent; +} + +function createFakeTUI(): FakeTUI { + const listeners = new Set<(data: string) => { consume?: boolean; data?: string } | undefined>(); + return { + addInputListener: vi.fn((listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }), + emitInput: (data: string) => { + for (const listener of listeners) { + listener(data); + } + }, + requestRender: vi.fn(), + } as unknown as FakeTUI; +} + +function createFakeTUIWithConsumingFocusTracker(): FakeTUI { + const listeners = new Set<(data: string) => { consume?: boolean; data?: string } | undefined>(); + return { + addInputListener: vi.fn((listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }), + emitInput: (data: string) => { + for (const listener of listeners) { + const result = listener(data); + if (result?.consume) return; + } + }, + requestRender: vi.fn(), + } as unknown as FakeTUI; +} + +describe('ClipboardImageHintController', () => { + let platformSpy: ReturnType | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + vi.mocked(clipboardHasImage).mockResolvedValue(false); + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + }); + + afterEach(() => { + platformSpy?.mockRestore(); + vi.useRealTimers(); + }); + + it('shows hint when focus returns and clipboard has image', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + + await vi.advanceTimersByTimeAsync(1000); + + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + expect(footer.getTransientHint()).toMatch(/Ctrl\+V/); + + controller.stop(); + }); + + it('does not show hint when model does not support images', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => false, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('respects cooldown between hints', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(footer.getTransientHint()).not.toBeNull(); + + footer.setTransientHint(null); + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('clears hint after 2 seconds', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(footer.getTransientHint()).not.toBeNull(); + + await vi.advanceTimersByTimeAsync(4000); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('cancels a pending debounced check when focus is lost', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + ui.emitInput(TERMINAL_FOCUS_OUT); + await vi.advanceTimersByTimeAsync(1000); + + expect(clipboardHasImage).not.toHaveBeenCalled(); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + 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 = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + for (let i = 0; i < 5; i++) { + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + } + + await vi.advanceTimersByTimeAsync(1000); + + expect(clipboardHasImage).toHaveBeenCalledTimes(1); + expect(footer.getTransientHint()).not.toBeNull(); + + controller.stop(); + }); + + it('ignores stale clipboard read result when focus is lost', async () => { + vi.mocked(clipboardHasImage).mockImplementation( + () => new Promise((resolve) => setTimeout(() => { resolve(true); }, 1500)), + ); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(clipboardHasImage).toHaveBeenCalledTimes(1); + + ui.emitInput(TERMINAL_FOCUS_OUT); + await vi.advanceTimersByTimeAsync(1500); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('ignores a pending clipboard read result after stop', async () => { + let resolveDeferred: (value: boolean) => void = () => {}; + vi.mocked(clipboardHasImage).mockImplementation( + () => new Promise((resolve) => { + resolveDeferred = resolve; + }), + ); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(clipboardHasImage).toHaveBeenCalledTimes(1); + + controller.stop(); + resolveDeferred(true); + await vi.advanceTimersByTimeAsync(0); + expect(footer.getTransientHint()).toBeNull(); + }); + + it('clears a displayed hint when stopped', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(footer.getTransientHint()).not.toBeNull(); + + controller.stop(); + expect(footer.getTransientHint()).toBeNull(); + expect(host.requestRender).toHaveBeenCalled(); + }); + + it('does not clear a hint set by another caller when stopped', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const requestRender = vi.fn(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender, + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + const otherHint = 'Other hint'; + footer.setTransientHint(otherHint); + + const requestRenderCalls = requestRender.mock.calls.length; + controller.stop(); + expect(footer.getTransientHint()).toBe(otherHint); + expect(host.requestRender).toHaveBeenCalledTimes(requestRenderCalls); + }); + + 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 = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(clipboardHasImage).toHaveBeenCalledTimes(1); + + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(clipboardHasImage).toHaveBeenCalledTimes(2); + + deferreds[0]!.resolve(true); + await vi.advanceTimersByTimeAsync(0); + expect(footer.getTransientHint()).toBeNull(); + + deferreds[1]!.resolve(true); + await vi.advanceTimersByTimeAsync(0); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + 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 = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + 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); + + // The previous hint should still be visible because its auto-clear timer + // was preserved through the re-check. + expect(footer.getTransientHint()).not.toBeNull(); + + // Advance the remaining original display duration and verify it expires. + await vi.advanceTimersByTimeAsync(3000); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + 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 = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + const hintText = footer.getTransientHint(); + expect(hintText).not.toBeNull(); + + await vi.advanceTimersByTimeAsync(4000); + expect(footer.getTransientHint()).toBeNull(); + + // Another caller sets the same hint text the controller previously used. + footer.setTransientHint(hintText); + + controller.stop(); + expect(footer.getTransientHint()).toBe(hintText); + }); + + it('does not inherit cooldown after stop and restart', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(footer.getTransientHint()).not.toBeNull(); + + controller.stop(); + controller.start(); + + footer.setTransientHint(null); + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + expect(footer.getTransientHint()).not.toBeNull(); + + 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 = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Register a second listener that consumes focus events, like installTerminalFocusTracking. + const consumedEvents: string[] = []; + ui.addInputListener((data) => { + if (data === TERMINAL_FOCUS_IN || data === TERMINAL_FOCUS_OUT) { + consumedEvents.push(data); + return { consume: true }; + } + return undefined; + }); + + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + expect(consumedEvents).toEqual([TERMINAL_FOCUS_OUT, TERMINAL_FOCUS_IN]); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + 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(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + expect(footer.getTransientHint()).toMatch(/Alt\+V/); + + controller.stop(); + }); +}); 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 new file mode 100644 index 000000000..5aeb0f137 --- /dev/null +++ b/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; +import type { ClipboardModule } from '#/utils/clipboard/clipboard-native'; + +function fakeClipboard(overrides: Partial): ClipboardModule { + return { + hasImage: vi.fn(() => false), + getImageBinary: vi.fn(async () => []), + ...overrides, + }; +} + +describe('clipboardHasImage', () => { + it('returns false on Termux', async () => { + const result = await clipboardHasImage({ env: { TERMUX_VERSION: '0.118' }, platform: 'linux' }); + expect(result).toBe(false); + }); + + it('returns true when native clipboard reports an image on macOS', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); + expect(result).toBe(true); + }); + + 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 result = await clipboardHasImage({ platform: 'darwin', clipboard: clip, runCommand }); + expect(result).toBe(false); + expect(runCommand).not.toHaveBeenCalledWith('osascript', expect.anything(), expect.anything()); + }); + + it('returns false on macOS when native clipboard throws', async () => { + const clip = fakeClipboard({ + hasImage: vi.fn(() => { + throw new Error('native error'); + }), + }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); + expect(result).toBe(false); + }); + + it('returns false on macOS when clipboard contains a file-like native format', async () => { + const clip = fakeClipboard({ + hasImage: vi.fn(() => true), + availableFormats: vi.fn(() => ['public.file-url', 'public.png']), + }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); + expect(result).toBe(false); + expect(clip.hasImage).not.toHaveBeenCalled(); + }); + + it('detects image on Wayland via wl-paste list-types', async () => { + const runCommand = vi.fn((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((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) }); + const result = await clipboardHasImage({ + platform: 'linux', + env: { WAYLAND_DISPLAY: 'wayland-1' }, + runCommand, + clipboard: clip, + }); + expect(result).toBe(false); + }); + + it('falls back to xclip on Wayland when wl-paste reports no image', async () => { + const runCommand = vi.fn((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((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((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((command: string, args: string[]) => { + if (command === 'xclip' && args.includes('TARGETS')) { + return { stdout: Buffer.from('TARGETS\n'), ok: true }; + } + return { stdout: Buffer.alloc(0), ok: false }; + }); + const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); + const result = await clipboardHasImage({ platform: 'linux', env: {}, runCommand, clipboard: clip }); + expect(result).toBe(false); + }); + + it('falls back to native hasImage on Linux X11 when xclip fails', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); + const runCommand = vi.fn(() => ({ 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 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) => { + 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((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(() => ({ 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(() => ({ 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((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 }); + expect(result).toBe(false); + }); +});