From 9a568889fd82dcb46c09b4cd2eca2dedb75ca4b0 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 13 Jul 2026 13:40:34 +0800 Subject: [PATCH 1/7] fix(tui): deliver pasted media in /skill and plugin command args via file tags Skill and plugin command args are a plain-text RPC channel, so pasted image/video placeholders never reached the model. Rewrite matched placeholders into tags pointing at cache-dir copies (the same convention pasted videos already use), and gate them on model media capabilities like the normal prompt path. --- .changeset/fix-skill-command-media.md | 5 ++ apps/kimi-code/src/tui/kimi-tui.ts | 30 +++++-- .../src/tui/utils/image-placeholder.ts | 80 ++++++++++++++++- .../test/tui/input/image-placeholder.test.ts | 86 ++++++++++++++++++- 4 files changed, 190 insertions(+), 11 deletions(-) create mode 100644 .changeset/fix-skill-command-media.md diff --git a/.changeset/fix-skill-command-media.md b/.changeset/fix-skill-command-media.md new file mode 100644 index 0000000000..60e088b137 --- /dev/null +++ b/.changeset/fix-skill-command-media.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix pasted media being dropped from /skill and plugin command arguments. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 62d1b2370b..44b2ed34ec 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -132,7 +132,7 @@ import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; -import { extractMediaAttachments } from './utils/image-placeholder'; +import { extractMediaAttachments, rewriteMediaPlaceholdersAsTags } from './utils/image-placeholder'; import { hasPatchChanges } from './utils/object-patch'; import { sessionRowsForPicker } from './utils/session-picker-rows'; import { formatBashOutputForDisplay } from './utils/shell-output'; @@ -1095,9 +1095,11 @@ export class KimiTUI { this.state.ui.requestRender(); } - private validateMediaCapabilities( - extraction: ReturnType, - ): boolean { + private validateMediaCapabilities(extraction: { + hasMedia: boolean; + imageAttachmentIds: readonly number[]; + videoAttachmentIds: readonly number[]; + }): boolean { if (!extraction.hasMedia) return true; if ( extraction.imageAttachmentIds.length > 0 && @@ -1243,8 +1245,13 @@ export class KimiTUI { } sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { + // Args are a plain-text channel, so pasted media can't ride along as + // inline parts. Rewrite placeholders into cache-file tags the model + // can open with ReadMediaFile instead of leaving dead placeholder text. + const rewrite = rewriteMediaPlaceholdersAsTags(skillArgs, this.imageStore); + if (!this.validateMediaCapabilities(rewrite)) return; this.beginSessionRequest(); - void session.activateSkill(skillName, skillArgs).catch((error: unknown) => { + void session.activateSkill(skillName, rewrite.text).catch((error: unknown) => { const message = formatErrorMessage(error); this.failSessionRequest(`Skill "${skillName}" failed: ${message}`); }); @@ -1256,11 +1263,16 @@ export class KimiTUI { commandName: string, args: string, ): void { + // Same plain-text constraint as skill args — see sendSkillActivation. + const rewrite = rewriteMediaPlaceholdersAsTags(args, this.imageStore); + if (!this.validateMediaCapabilities(rewrite)) return; this.beginSessionRequest(); - void session.activatePluginCommand(pluginId, commandName, args).catch((error: unknown) => { - const message = formatErrorMessage(error); - this.failSessionRequest(`Command "${pluginId}:${commandName}" failed: ${message}`); - }); + void session + .activatePluginCommand(pluginId, commandName, rewrite.text) + .catch((error: unknown) => { + const message = formatErrorMessage(error); + this.failSessionRequest(`Command "${pluginId}:${commandName}" failed: ${message}`); + }); } private sendMessage(session: Session, input: string, options?: SendMessageOptions): void { diff --git a/apps/kimi-code/src/tui/utils/image-placeholder.ts b/apps/kimi-code/src/tui/utils/image-placeholder.ts index 4017c4b2d1..180d217fcf 100644 --- a/apps/kimi-code/src/tui/utils/image-placeholder.ts +++ b/apps/kimi-code/src/tui/utils/image-placeholder.ts @@ -18,7 +18,7 @@ */ import { randomUUID } from 'node:crypto'; -import { copyFileSync, mkdirSync } from 'node:fs'; +import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { PromptPart } from '@moonshot-ai/kimi-code-sdk'; @@ -101,6 +101,64 @@ export function extractMediaAttachments( }; } +export interface MediaTagRewriteResult { + /** Input text with resolved placeholders replaced by media path tags. */ + text: string; + hasMedia: boolean; + imageAttachmentIds: number[]; + videoAttachmentIds: number[]; +} + +/** + * Rewrite media placeholders in slash-command args (`/skill:foo …`, + * plugin commands) into `` tags pointing at + * cache-dir copies. Command args are a plain-text channel — unlike + * `extractMediaAttachments`, which inlines image parts for the prompt + * endpoint — so the model reaches the media through `ReadMediaFile` + * instead, the same way it already handles pasted videos. + * + * Surrounding text is preserved verbatim (args are user content, not + * LLM parts), and unresolved placeholders stay literal. + */ +export function rewriteMediaPlaceholdersAsTags( + text: string, + store: ImageAttachmentStore, +): MediaTagRewriteResult { + const imageAttachmentIds: number[] = []; + const videoAttachmentIds: number[] = []; + let cursor = 0; + let out = ''; + + PLACEHOLDER_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) { + const [literal, kind, idStr] = match; + if (kind !== 'image' && kind !== 'video') continue; + if (idStr === undefined) continue; + const id = Number.parseInt(idStr, 10); + const attachment = store.get(id); + if (attachment === undefined) continue; // stale / user-typed — leave as text + if (attachment.kind !== kind) continue; + out += text.slice(cursor, match.index); + if (attachment.kind === 'video') { + out += formatMediaTag('video', materializeVideoToCache(attachment)); + videoAttachmentIds.push(id); + } else { + out += formatMediaTag('image', materializeImageToCache(attachment)); + imageAttachmentIds.push(id); + } + cursor = match.index + literal.length; + } + + const hasMedia = imageAttachmentIds.length + videoAttachmentIds.length > 0; + return { + text: hasMedia ? out + text.slice(cursor) : text, + hasMedia, + imageAttachmentIds, + videoAttachmentIds, + }; +} + function pushText(parts: PromptPart[], segment: string): void { if (segment.length === 0) return; // Keep whitespace-only segments only when they sit between non-empty @@ -131,6 +189,26 @@ function materializeVideoToCache(att: VideoAttachment): string { return target; } +const IMAGE_MIME_EXTENSION: Readonly> = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/bmp': 'bmp', + 'image/tiff': 'tif', +}; + +function materializeImageToCache(att: ImageAttachment): string { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + // ReadMediaFile sniffs the real format from the bytes, so the extension + // only needs to be a reasonable hint. + const ext = IMAGE_MIME_EXTENSION[att.mime.trim().toLowerCase()] ?? 'img'; + const target = join(cacheDir, `${randomUUID()}.${ext}`); + writeFileSync(target, att.bytes); + return target; +} + function captionForCompressedImage(att: ImageAttachment): string { const original = att.original; if (original === undefined) return ''; diff --git a/apps/kimi-code/test/tui/input/image-placeholder.test.ts b/apps/kimi-code/test/tui/input/image-placeholder.test.ts index e157cbf8c8..c73ebe18b2 100644 --- a/apps/kimi-code/test/tui/input/image-placeholder.test.ts +++ b/apps/kimi-code/test/tui/input/image-placeholder.test.ts @@ -6,7 +6,10 @@ import { describe, it, expect } from 'vitest'; import { KIMI_CODE_HOME_ENV } from '#/constant/app'; import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; -import { extractMediaAttachments } from '#/tui/utils/image-placeholder'; +import { + extractMediaAttachments, + rewriteMediaPlaceholdersAsTags, +} from '#/tui/utils/image-placeholder'; import { getCacheDir } from '#/utils/paths'; function storeWith( @@ -221,3 +224,84 @@ describe('extractMediaAttachments', () => { expect(r.parts[0]?.type).toBe('image_url'); }); }); + +describe('rewriteMediaPlaceholdersAsTags', () => { + it('returns plain text untouched with hasMedia=false', () => { + const store = new ImageAttachmentStore(); + const r = rewriteMediaPlaceholdersAsTags('just some args', store); + expect(r.text).toBe('just some args'); + expect(r.hasMedia).toBe(false); + expect(r.imageAttachmentIds).toEqual([]); + expect(r.videoAttachmentIds).toEqual([]); + }); + + it('rewrites an image placeholder into a cache-path image tag', () => { + const { cleanup } = setupTempCache(); + try { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const { store, placeholder } = storeWith(bytes); + const r = rewriteMediaPlaceholdersAsTags(`look at ${placeholder} please`, store); + expect(r.hasMedia).toBe(true); + expect(r.imageAttachmentIds).toEqual([1]); + const m = /^look at <\/image> please$/.exec(r.text); + if (!m) throw new Error(`no image tag found in: ${r.text}`); + expect(m[1]!.startsWith(getCacheDir())).toBe(true); + expect(m[1]!.endsWith('.png')).toBe(true); + expect(new Uint8Array(readFileSync(m[1]!))).toEqual(bytes); + } finally { + cleanup(); + } + }); + + it('rewrites a video placeholder into a cache-path video tag', () => { + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const srcVideo = join(srcDir, 'clip.mov'); + writeFileSync(srcVideo, 'video-bytes'); + const store = new ImageAttachmentStore(); + const att = store.addVideo('video/quicktime', srcVideo); + const r = rewriteMediaPlaceholdersAsTags(att.placeholder, store); + expect(r.hasMedia).toBe(true); + expect(r.videoAttachmentIds).toEqual([1]); + const m = /