Skip to content
5 changes: 5 additions & 0 deletions .changeset/fix-skill-command-media.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix pasted media being dropped from /skill and plugin command arguments.
5 changes: 5 additions & 0 deletions .changeset/fix-steer-media.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix pasted images being dropped when steering with Ctrl-S.
53 changes: 45 additions & 8 deletions apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
} from '../constant/kimi-tui';
import { formatErrorMessage } from '../utils/event-payload';
import type { ImageAttachmentStore } from '../utils/image-attachment-store';
import type { PendingExit, QueuedMessage } from '../types';
import { extractMediaAttachments } from '../utils/image-placeholder';
import type { PendingExit, QueuedMessage, SteerInputItem } from '../types';
import type { TUIState } from '../tui-state';
import type { BtwPanelController } from './btw-panel';

Expand All @@ -32,7 +33,12 @@ export interface EditorKeyboardHost {

handleUserInput(text: string): void;
readonly btwPanelController: BtwPanelController;
steerMessage(session: Session, input: string[]): void;
steerMessage(session: Session, input: readonly SteerInputItem[]): void;
validateMediaCapabilities(extraction: {
hasMedia: boolean;
imageAttachmentIds: readonly number[];
videoAttachmentIds: readonly number[];
}): boolean;
recallLastQueued(): QueuedMessage | undefined;
showError(msg: string): void;
track(event: string, props?: Record<string, unknown>): void;
Expand Down Expand Up @@ -250,22 +256,53 @@ export class EditorKeyboardController {
// after the current task instead of being injected into the turn as text.
const queued = host.state.queuedMessages;
const steerable = queued.filter((m) => m.mode !== 'bash');
host.state.queuedMessages = queued.filter((m) => m.mode === 'bash');

const parts: string[] = [];
const items: SteerInputItem[] = [];
for (const m of steerable) {
const trimmed = m.text.trim();
if (trimmed.length > 0) parts.push(trimmed);
if (trimmed.length > 0) {
// Queued items carry the parts extracted when they were submitted
// (and were already capability-validated then).
items.push({ text: trimmed, parts: m.parts, imageAttachmentIds: m.imageAttachmentIds });
}
}
let editorExtraction: ReturnType<typeof extractMediaAttachments> | undefined;
if (!editorIsBash && text.length > 0) {
try {
editorExtraction = extractMediaAttachments(text, this.imageStore);
} catch (error) {
// Cache copy failed (e.g. the pasted video's source vanished) —
// leave the queue and the editor draft untouched.
host.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`);
return;
}
items.push({
text,
parts: editorExtraction.hasMedia ? editorExtraction.parts : undefined,
imageAttachmentIds:
editorExtraction.imageAttachmentIds.length > 0
? editorExtraction.imageAttachmentIds
: undefined,
});
}
if (!editorIsBash && text.length > 0) parts.push(text);

if (parts.length > 0) {
if (items.length > 0) {
// The editor draft is fresh input: gate it on the model's media
// capabilities before splicing the queue, so a rejection leaves the
// queue and the draft untouched.
if (
editorExtraction !== undefined &&
!host.validateMediaCapabilities(editorExtraction)
) {
return;
}
host.state.queuedMessages = queued.filter((m) => m.mode === 'bash');
if (!editorIsBash) editor.setText('');
const session = host.session;
if (host.state.appState.model.trim().length === 0 || session === undefined) {
host.showError(LLM_NOT_SET_MESSAGE);
} else {
host.steerMessage(session, parts);
host.steerMessage(session, items);
}
}
host.updateQueueDisplay();
Expand Down
112 changes: 95 additions & 17 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ import {
type LivePaneState,
type LoginProgressSpinnerHandle,
type QueuedMessage,
type SteerInputItem,
type TranscriptEntry,
type TUIStartupOptions,
type TUIStartupState,
Expand All @@ -132,7 +133,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, rewriteMediaPlaceholders } from './utils/image-placeholder';
import { hasPatchChanges } from './utils/object-patch';
import { sessionRowsForPicker } from './utils/session-picker-rows';
import { formatBashOutputForDisplay } from './utils/shell-output';
Expand Down Expand Up @@ -238,6 +239,50 @@ interface SendMessageOptions {
readonly hasMedia?: boolean;
}

/**
* Flatten steer items into the payload `session.steer` expects: the
* historical `'\n\n'`-joined string when nothing carries media, or a
* merged part list when any item has extracted media parts (queued image
* messages, or the editor draft after placeholder extraction).
*
* Items are separated by the historical `'\n\n'`, which merges into the
* adjacent text part. The one exception is two touching media parts: a
* standalone `{type:'text',text:'\n\n'}` between them would be rejected
* by `normalizePromptInput` as an empty text part, so the separator is
* dropped there (media parts are self-delimiting anyway).
*/
function combineSteerInput(items: readonly SteerInputItem[]): string | PromptPart[] {
const hasMedia = items.some((item) => item.parts !== undefined && item.parts.length > 0);
if (!hasMedia) return items.map((item) => item.text).join('\n\n');
const parts: PromptPart[] = [];
for (const item of items) {
const startsWithMedia =
item.parts !== undefined && item.parts.length > 0 && item.parts[0]?.type !== 'text';
const lastIsMedia = parts.length > 0 && parts.at(-1)?.type !== 'text';
if (parts.length > 0 && !(lastIsMedia && startsWithMedia)) {
appendSteerText(parts, '\n\n');
}
if (item.parts !== undefined && item.parts.length > 0) {
for (const part of item.parts) {
if (part.type === 'text') appendSteerText(parts, part.text);
else parts.push(part);
}
} else {
appendSteerText(parts, item.text);
}
}
return parts;
}

function appendSteerText(parts: PromptPart[], text: string): void {
const last = parts.at(-1);
if (last?.type === 'text') {
parts[parts.length - 1] = { type: 'text', text: last.text + text };
return;
}
parts.push({ type: 'text', text });
}

/** How long the one-shot "moved to background" footer hint stays visible. */
const DETACH_HINT_DISPLAY_MS = 4_000;

Expand Down Expand Up @@ -1095,9 +1140,11 @@ export class KimiTUI {
this.state.ui.requestRender();
}

private validateMediaCapabilities(
extraction: ReturnType<typeof extractMediaAttachments>,
): boolean {
validateMediaCapabilities(extraction: {
hasMedia: boolean;
imageAttachmentIds: readonly number[];
videoAttachmentIds: readonly number[];
}): boolean {
if (!extraction.hasMedia) return true;
if (
extraction.imageAttachmentIds.length > 0 &&
Expand Down Expand Up @@ -1243,8 +1290,22 @@ 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. Skill args are XML-escaped on render (renderSkillAttributes
// + expandSkillParameters), so rewrite placeholders into escape-proof
// plain-text file references the model can open with ReadMediaFile.
let rewrite: ReturnType<typeof rewriteMediaPlaceholders>;
try {
rewrite = rewriteMediaPlaceholders(skillArgs, this.imageStore, 'plain');
} catch (error) {
// Cache copy failed (unwritable cache dir, vanished video source…);
// nothing has been dispatched yet, so just report and keep the input.
this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`);
return;
}
if (!this.validateMediaCapabilities(rewrite)) return;
this.beginSessionRequest();
void session.activateSkill(skillName, skillArgs).catch((error: unknown) => {
void session.activateSkill(skillName, rewrite.text).catch((error: unknown) => {
Comment thread
liruifengv marked this conversation as resolved.
const message = formatErrorMessage(error);
this.failSessionRequest(`Skill "${skillName}" failed: ${message}`);
});
Expand All @@ -1256,11 +1317,24 @@ export class KimiTUI {
commandName: string,
args: string,
): void {
// Plugin command args are expanded verbatim (no XML escaping), so the
// standard <image|video path> tag convention works — see
// sendSkillActivation for the escaped-channel variant.
let rewrite: ReturnType<typeof rewriteMediaPlaceholders>;
try {
rewrite = rewriteMediaPlaceholders(args, this.imageStore, 'tag');
} catch (error) {
this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`);
return;
}
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 {
Expand All @@ -1275,31 +1349,35 @@ export class KimiTUI {
this.sendMessageInternal(session, input, options);
}

steerMessage(session: Session, input: string[]): void {
steerMessage(session: Session, input: readonly SteerInputItem[]): void {
if (this.deferUserMessages || this.state.appState.isCompacting) {
for (const part of input) {
this.enqueueMessage(part);
for (const item of input) {
this.enqueueMessage(item.text, item);
}
return;
}
if (this.state.appState.streamingPhase === 'idle') {
for (const part of input) {
this.sendMessageInternal(session, part);
for (const item of input) {
this.sendMessageInternal(session, item.text, item);
}
return;
}

for (const part of input) {
for (const item of input) {
this.appendTranscriptEntry({
id: nextTranscriptId(),
kind: 'user',
turnId: this.streamingUI.getTurnContext().turnId,
renderMode: 'plain',
content: part,
content: item.text,
imageAttachmentIds:
item.imageAttachmentIds !== undefined && item.imageAttachmentIds.length > 0
? item.imageAttachmentIds
: undefined,
});
}

void session.steer(input.join('\n\n')).catch((error: unknown) => {
void session.steer(combineSteerInput(input)).catch((error: unknown) => {
const message = formatErrorMessage(error);
this.showError(`Failed to steer: ${message}`);
});
Expand Down
12 changes: 12 additions & 0 deletions apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,18 @@ export interface QueuedMessage {
readonly mode?: 'prompt' | 'bash';
}

/**
* One unit of Ctrl-S steer input: a queued message or the editor draft,
* with the media parts extracted at submit/paste time so images and video
* tags survive the steer path (which accepts full prompt parts, not just
* text).
*/
export interface SteerInputItem {
readonly text: string;
readonly parts?: readonly PromptPart[];
readonly imageAttachmentIds?: readonly number[];
}

export const INITIAL_LIVE_PANE: LivePaneState = {
mode: 'idle',
pendingApproval: null,
Expand Down
Loading
Loading