Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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/dispatch-dynamic-commands-ready.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix slash commands typed right after startup being sent to the model as plain text instead of activating the skill.
8 changes: 8 additions & 0 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export interface SlashCommandHost {
): void;
readonly skillCommandMap: Map<string, string>;
readonly pluginCommandMap: Map<string, string>;
dynamicCommandsReady?: Promise<void>;

// Controller refs
readonly streamingUI: StreamingUIController;
Expand All @@ -226,6 +227,13 @@ export interface SlashCommandHost {
// ---------------------------------------------------------------------------

export function dispatchInput(host: SlashCommandHost, text: string): void {
const pending = host.dynamicCommandsReady;
if (pending !== undefined) {
void pending.then(() => {
dispatchInput(host, text);
});
Comment on lines +232 to +234

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize deferred submissions before redispatching

When multiple inputs arrive during this gate, these independent promise callbacks preserve only callback-start order, not actual submission order. For example, with an active session, if the first prompt contains freshly pasted media, sendNormalUserInput pauses at pendingMediaIngestions (lines 1352–1364), while a following plain prompt proceeds synchronously and starts the turn; when the first resumes, it is queued behind the second. Drain deferred inputs serially through the point where each input is accepted or queued so the FIFO guarantee is maintained for asynchronous preparation paths.

Useful? React with 👍 / 👎.

return;
}
if (parseSlashInput(text) !== null) {
// A leading skill command combined with further inline skill tokens
// (`/skill:a args /skill:b`) is one grouped submission on the v2 engine.
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/constant/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export const TOWER_STATUS_PROMPT =
export const TOWER_TEARDOWN_PROMPT =
'Tear down the tower: call TowerTeardown and report what it did. It refuses to destroy dirty worktrees unless forced.';
export const EXIT_CONFIRM_WINDOW_MS = 1500;
// Fallback for the dynamic (skill/plugin) slash-command readiness gate: if the
// catalog load never settles (wedged IPC), the gate clears after this long so
// input stops queueing — a wedged load must not swallow submissions forever.
export const DYNAMIC_COMMANDS_READY_TIMEOUT_MS = 10_000;
// Time window for treating two consecutive Esc presses as a double-Esc, which
// opens the undo selector. Kept short (double-click feel) so two deliberate
// presses far apart don't accidentally trigger undo.
Expand Down
8 changes: 2 additions & 6 deletions apps/kimi-code/src/tui/controllers/auth-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import {

import { createKimiCodeUserAgent } from '#/cli/version';

import type { SkillListSession } from '../commands';

import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui';
import {
refreshAllProviderModels,
Expand Down Expand Up @@ -45,8 +43,7 @@ export interface AuthFlowHost {
readonly sessionEventHandler: SessionEventHandler;
fetchSessions(): Promise<void>;
updateTerminalTitle(): void;
refreshSkillCommands(session?: SkillListSession): Promise<void>;
refreshPluginCommands(session?: Session): Promise<void>;
refreshDynamicCommands(session?: Session): Promise<void>;
}

export class AuthFlowController {
Expand Down Expand Up @@ -129,8 +126,7 @@ export class AuthFlowController {
host.sessionEventHandler.startSubscription();
void host.fetchSessions();
host.updateTerminalTitle();
void host.refreshSkillCommands(host.session);
void host.refreshPluginCommands(host.session);
void host.refreshDynamicCommands(host.session);
}

async refreshConfigAfterLogin(): Promise<void> {
Expand Down
19 changes: 17 additions & 2 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ import {
} from './types';
import { hasDispose, isExpandable } from './utils/component-capabilities';
import { isDeadTerminalError } from './utils/dead-terminal';
import { createDynamicCommandsGate } from './utils/dynamic-commands-gate';
import { formatErrorMessage } from './utils/event-payload';
import { pickForegroundTasks } from './utils/foreground-task';
import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store';
Expand Down Expand Up @@ -320,6 +321,7 @@ export class KimiTUI {
readonly skillCommandMap = new Map<string, string>();
private pluginCommands: readonly KimiSlashCommand[] = [];
readonly pluginCommandMap = new Map<string, string>();
dynamicCommandsReady?: Promise<void>;
private readonly imageStore = new ImageAttachmentStore();
// Detected lazily in startBackgroundFdAutocomplete() — detection spawns
// `fd --version`, which must not happen before the workspace trust gate:
Expand Down Expand Up @@ -517,6 +519,20 @@ export class KimiTUI {
this.setupAutocomplete();
}

refreshDynamicCommands(session?: Session): Promise<void> {
const ready = createDynamicCommandsGate(
Promise.all([this.refreshSkillCommands(session), this.refreshPluginCommands(session)]),
(warning) => {
this.showStatus(warning, 'warning');
},
);
this.dynamicCommandsReady = ready;
void ready.finally(() => {
if (this.dynamicCommandsReady === ready) this.dynamicCommandsReady = undefined;
});
return ready;
}

async refreshSkillCommands(session?: SkillListSession): Promise<void> {
if (session === undefined) {
// v2 engine: skills live on the workspace handler, not the session, so
Expand Down Expand Up @@ -817,8 +833,7 @@ export class KimiTUI {
if (this.session !== undefined) {
this.updateTerminalTitle();
}
void this.refreshSkillCommands(this.session);
void this.refreshPluginCommands(this.session);
void this.refreshDynamicCommands(this.session);
}

private async showSessionWarnings(session: Session): Promise<void> {
Expand Down
42 changes: 42 additions & 0 deletions apps/kimi-code/src/tui/utils/dynamic-commands-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Readiness gate for the dynamic (skill/plugin) slash-command catalog. While
* the gate promise is pending, `dispatchInput` defers every submission so a
* slash command typed right after startup still resolves against the loaded
* catalog instead of falling through to the model as plain text.
*
* The gate resolves when the catalog load settles — success or failure, the
* gate is infallible by construction so queued drains can never be dropped by
* a rejection — or when the fallback timeout fires, whichever comes first. On
* timeout `onTimeout` receives the user-facing warning and the gate clears
* anyway: a wedged load (e.g. stuck IPC) must not queue input forever. A load
* that settles after the timeout still applies its results; the gate only
* bounds how long input dispatch waits.
*/

import { DYNAMIC_COMMANDS_READY_TIMEOUT_MS } from '#/tui/constant/kimi-tui';

export function createDynamicCommandsGate(
load: Promise<unknown>,
onTimeout: (warning: string) => void,
): Promise<void> {
return new Promise<void>((resolve) => {
let settled = false;
const timer = setTimeout(() => {
settle();
onTimeout(
'Skill and plugin catalogs are still loading — slash commands may be incomplete for a moment.',
);
}, DYNAMIC_COMMANDS_READY_TIMEOUT_MS);
// Never hold the process open for the fallback: quitting while a catalog
// load is wedged must not wait out the timer.
timer.unref();
function settle(): void {
if (settled) return;
settled = true;
clearTimeout(timer);
// oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the single resolve; the rule cannot see it
resolve();
}
void load.then(settle, settle);
});
}
97 changes: 97 additions & 0 deletions apps/kimi-code/test/tui/commands/goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,18 @@ function makeHost(
return { host, session };
}

// Mirror of the KimiTUI.refreshDynamicCommands wiring: arm the gate and clear
// it once it settles, so deferred submissions re-dispatch against the loaded
// maps instead of queueing again.
function armDynamicCommandsGate(host: SlashCommandHost, ready: Promise<void>): void {
Object.assign(host, { dynamicCommandsReady: ready });
void ready.finally(() => {
if (host.dynamicCommandsReady === ready) {
Object.assign(host, { dynamicCommandsReady: undefined });
}
});
}

interface TestPicker {
handleInput(data: string): void;
render(width: number): string[];
Expand Down Expand Up @@ -807,6 +819,91 @@ describe('dispatchInput /goal integration', () => {
expect(host.sendNormalUserInput).not.toHaveBeenCalledWith('/goal Ship feature X');
});

it('waits for dynamic commands readiness before resolving the input', async () => {
const { host } = makeHost();
host.skillCommandMap.set('mcp-config', 'mcp-config');
Object.assign(host, { sendSkillActivation: vi.fn() });
let resolveReady!: () => void;
const ready = new Promise<void>((resolve) => {
resolveReady = resolve;
});
armDynamicCommandsGate(host, ready);

dispatchInput(host, '/mcp-config enable context7');

expect(host.sendSkillActivation).not.toHaveBeenCalled();
expect(host.sendNormalUserInput).not.toHaveBeenCalled();

resolveReady();
await vi.waitFor(() => {
expect(host.sendSkillActivation).toHaveBeenCalledWith(
expect.anything(),
'mcp-config',
'enable context7',
);
});
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
});

it('drains multiple queued submissions in submission order', async () => {
const { host } = makeHost();
host.skillCommandMap.set('skill-a', 'skill-a');
host.skillCommandMap.set('skill-b', 'skill-b');
const dispatched: string[] = [];
Object.assign(host, {
sendSkillActivation: vi.fn((_session: unknown, skillName: string) => {
dispatched.push(skillName);
}),
});
let resolveReady!: () => void;
const ready = new Promise<void>((resolve) => {
resolveReady = resolve;
});
armDynamicCommandsGate(host, ready);

dispatchInput(host, '/skill-a first');
dispatchInput(host, '/skill-b second');

expect(host.sendSkillActivation).not.toHaveBeenCalled();

resolveReady();
// The drains were queued before this await, so both have run once the
// gate promise resumes here (the skill path is synchronous with a session).
await ready;

expect(dispatched).toEqual(['skill-a', 'skill-b']);
});

it('queues plain text behind the gate and keeps its order relative to a slash submission', async () => {
const { host } = makeHost();
host.skillCommandMap.set('skill-a', 'skill-a');
const dispatched: string[] = [];
Object.assign(host, {
sendNormalUserInput: vi.fn((text: string) => {
dispatched.push(`text:${text}`);
}),
sendSkillActivation: vi.fn((_session: unknown, skillName: string) => {
dispatched.push(`skill:${skillName}`);
}),
});
let resolveReady!: () => void;
const ready = new Promise<void>((resolve) => {
resolveReady = resolve;
});
armDynamicCommandsGate(host, ready);

dispatchInput(host, 'plain prompt one');
dispatchInput(host, '/skill-a second');

expect(host.sendNormalUserInput).not.toHaveBeenCalled();
expect(host.sendSkillActivation).not.toHaveBeenCalled();

resolveReady();
await ready;

expect(dispatched).toEqual(['text:plain prompt one', 'skill:skill-a']);
});

it('restores the input when /goal is rejected by the busy gate while streaming', async () => {
const { host, session } = makeHost({ streaming: true });

Expand Down
58 changes: 58 additions & 0 deletions apps/kimi-code/test/tui/utils/dynamic-commands-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest';

import { DYNAMIC_COMMANDS_READY_TIMEOUT_MS } from '#/tui/constant/kimi-tui';
import { createDynamicCommandsGate } from '#/tui/utils/dynamic-commands-gate';

describe('createDynamicCommandsGate', () => {
it('clears the gate with a warning when the catalog load does not settle in time', async () => {
vi.useFakeTimers();
try {
const onTimeout = vi.fn();
let resolveLoad!: () => void;
const load = new Promise<void>((resolve) => {
resolveLoad = resolve;
});
const ready = createDynamicCommandsGate(load, onTimeout);

await vi.advanceTimersByTimeAsync(DYNAMIC_COMMANDS_READY_TIMEOUT_MS);

expect(onTimeout).toHaveBeenCalledWith(
'Skill and plugin catalogs are still loading — slash commands may be incomplete for a moment.',
);
await ready;

// A load that settles after the timeout still resolves quietly — the
// warning fires once.
resolveLoad();
await ready;
expect(onTimeout).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});

it('resolves without warning when the load settles before the timeout', async () => {
vi.useFakeTimers();
try {
const onTimeout = vi.fn();
let resolveLoad!: () => void;
const load = new Promise<void>((resolve) => {
resolveLoad = resolve;
});
const ready = createDynamicCommandsGate(load, onTimeout);

resolveLoad();
await ready;
await vi.advanceTimersByTimeAsync(DYNAMIC_COMMANDS_READY_TIMEOUT_MS * 2);

expect(onTimeout).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});

it('resolves even when the load rejects, so queued drains are never dropped', async () => {
const ready = createDynamicCommandsGate(Promise.reject(new Error('wedged IPC')), vi.fn());
await expect(ready).resolves.toBeUndefined();
});
});
Loading