Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
809ad2c
feat(kimi-code): add lightweight clipboard image detection
liruifengv Jun 23, 2026
65f78b6
fix(clipboard): correct Linux X11 image detection and extract shared …
liruifengv Jun 23, 2026
f39a6ec
fix(kimi-code): restore Wayland/WSL xclip fallback in clipboard image…
liruifengv Jun 23, 2026
3ff75ca
feat(kimi-code): add clipboard image hint controller
liruifengv Jun 23, 2026
307f6e1
fix(kimi-code): clipboard image hint focus race and cleanup
liruifengv Jun 23, 2026
6f348f0
fix(kimi-code): prevent clipboard image hint from clearing unrelated …
liruifengv Jun 23, 2026
1e947af
fix(clipboard-image-hint): lifecycle issues and platform-dependent tests
liruifengv Jun 23, 2026
0b89ce0
fix(kimi-code): invalidate pending clipboard hint read on stop
liruifengv Jun 23, 2026
205bef4
feat(kimi-code): wire clipboard image hint controller into TUI
liruifengv Jun 23, 2026
4ce6274
style(kimi-code): wrap void expression in braces to fix lint warning
liruifengv Jun 23, 2026
92815f8
style(kimi-code): prefer nullish coalescing in clipboard image detection
liruifengv Jun 23, 2026
f84f035
chore: add changeset for clipboard image footer hint
liruifengv Jun 23, 2026
b78be6f
fix(kimi-code): let clipboard image hint observe non-consuming focus …
liruifengv Jun 23, 2026
e3d9c1b
fix(kimi-code): extend clipboard image hint display duration to 4 sec…
liruifengv Jun 23, 2026
0fd50dc
docs: mention clipboard image footer hint in interaction guide
liruifengv Jun 23, 2026
ea303a0
chore: downgrade clipboard image hint changeset to patch
liruifengv Jun 23, 2026
3a38cab
Revert "docs: mention clipboard image footer hint in interaction guide"
liruifengv Jun 23, 2026
5191221
fix(cli): avoid treating copied Finder files as images on macOS
liruifengv Jun 23, 2026
3ece4f7
Merge branch 'main' into feat/clipboard-image-footer-hint
liruifengv Jun 23, 2026
f9544bf
fix(cli): align image detection with paste path on macOS and Windows
liruifengv Jun 23, 2026
b4c625b
Merge branch 'main' into feat/clipboard-image-footer-hint
liruifengv Jun 23, 2026
5a6d21d
fix(tui): do not truncate inline image escape sequences
liruifengv Jun 23, 2026
5f43623
fix(tui): clear stale rows when content shrinks
liruifengv Jun 23, 2026
511c80d
chore: add changesets for inline image rendering fixes
liruifengv Jun 23, 2026
313664b
Merge branch 'main' into feat/clipboard-image-footer-hint
liruifengv Jun 23, 2026
9ee0c43
test(cli): stabilize pi-tui capability mocks in concurrent test runs
liruifengv Jun 23, 2026
8dd8f80
test(cli): use setCapabilities instead of mocked getCapabilities
liruifengv Jun 23, 2026
abe011e
Merge branch 'main' into feat/clipboard-image-footer-hint
liruifengv Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clipboard-image-footer-hint.md
Original file line number Diff line number Diff line change
@@ -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 changes: 5 additions & 0 deletions .changeset/fix-clear-on-shrink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix stale rows occasionally leaving duplicate input boxes after tall content shrinks.
5 changes: 5 additions & 0 deletions .changeset/fix-inline-image-truncation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix inline images being rendered as broken escape sequences in the transcript.
14 changes: 13 additions & 1 deletion apps/kimi-code/src/tui/components/messages/user-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/constant/clipboard-image-hint.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 124 additions & 0 deletions apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
liruifengv marked this conversation as resolved.
this.host.requestRender();
this.lastHintAtMs = Date.now();

this.clearHintTimer = setTimeout(() => {
this.clearOwnedHint();
}, HINT_DISPLAY_MS);
}
}
17 changes: 17 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -765,6 +780,8 @@ export class KimiTUI {

private disposeTerminalTracking(): void {
this.stopTerminalThemeTracking();
this.clipboardImageHintController?.stop();
this.clipboardImageHintController = undefined;
this.terminalFocusTrackingDispose?.();
this.terminalFocusTrackingDispose = undefined;
}
Expand Down
5 changes: 5 additions & 0 deletions apps/kimi-code/src/tui/tui-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
85 changes: 85 additions & 0 deletions apps/kimi-code/src/utils/clipboard/clipboard-common.ts
Original file line number Diff line number Diff line change
@@ -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 [];
}
}
Loading
Loading