mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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 0fd50dcc9b.
* 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
This commit is contained in:
parent
866b91c8f5
commit
be77d5da03
15 changed files with 1207 additions and 93 deletions
5
.changeset/clipboard-image-footer-hint.md
Normal file
5
.changeset/clipboard-image-footer-hint.md
Normal file
|
|
@ -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.
|
||||
5
.changeset/fix-clear-on-shrink.md
Normal file
5
.changeset/fix-clear-on-shrink.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix stale rows occasionally leaving duplicate input boxes after tall content shrinks.
|
||||
5
.changeset/fix-inline-image-truncation.md
Normal file
5
.changeset/fix-inline-image-truncation.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix inline images being rendered as broken escape sequences in the transcript.
|
||||
|
|
@ -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=');
|
||||
}
|
||||
|
|
|
|||
4
apps/kimi-code/src/tui/constant/clipboard-image-hint.ts
Normal file
4
apps/kimi-code/src/tui/constant/clipboard-image-hint.ts
Normal file
|
|
@ -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;
|
||||
124
apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts
Normal file
124
apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts
Normal file
|
|
@ -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<typeof setTimeout> | undefined;
|
||||
private clearHintTimer: ReturnType<typeof setTimeout> | 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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
85
apps/kimi-code/src/utils/clipboard/clipboard-common.ts
Normal file
85
apps/kimi-code/src/utils/clipboard/clipboard-common.ts
Normal file
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
101
apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts
Normal file
101
apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts
Normal file
|
|
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<Record<string, string>> = Object.freeze({
|
||||
|
|
@ -75,10 +80,8 @@ const VIDEO_MIME_BY_SUFFIX: Readonly<Record<string, string>> = 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 }> {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
553
apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts
Normal file
553
apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts
Normal file
|
|
@ -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<typeof vi.spyOn> | 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<boolean>((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<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 = {
|
||||
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();
|
||||
});
|
||||
});
|
||||
206
apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts
Normal file
206
apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts
Normal file
|
|
@ -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>): 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue