diff --git a/.changeset/dispatch-dynamic-commands-ready.md b/.changeset/dispatch-dynamic-commands-ready.md new file mode 100644 index 00000000000..920adab0fa5 --- /dev/null +++ b/.changeset/dispatch-dynamic-commands-ready.md @@ -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. diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index afe3b88be43..3e291bdf2fc 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -213,6 +213,7 @@ export interface SlashCommandHost { ): void; readonly skillCommandMap: Map; readonly pluginCommandMap: Map; + dynamicCommandsReady?: Promise; // Controller refs readonly streamingUI: StreamingUIController; @@ -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); + }); + 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. diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts index 45c77d177b8..d6315160377 100644 --- a/apps/kimi-code/src/tui/constant/kimi-tui.ts +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -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. diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 0940e445a7c..8a9792923e1 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -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, @@ -45,8 +43,7 @@ export interface AuthFlowHost { readonly sessionEventHandler: SessionEventHandler; fetchSessions(): Promise; updateTerminalTitle(): void; - refreshSkillCommands(session?: SkillListSession): Promise; - refreshPluginCommands(session?: Session): Promise; + refreshDynamicCommands(session?: Session): Promise; } export class AuthFlowController { @@ -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 { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index da68c171c7a..2ee8beb5b25 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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'; @@ -320,6 +321,7 @@ export class KimiTUI { readonly skillCommandMap = new Map(); private pluginCommands: readonly KimiSlashCommand[] = []; readonly pluginCommandMap = new Map(); + dynamicCommandsReady?: Promise; private readonly imageStore = new ImageAttachmentStore(); // Detected lazily in startBackgroundFdAutocomplete() — detection spawns // `fd --version`, which must not happen before the workspace trust gate: @@ -517,6 +519,20 @@ export class KimiTUI { this.setupAutocomplete(); } + refreshDynamicCommands(session?: Session): Promise { + 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 { if (session === undefined) { // v2 engine: skills live on the workspace handler, not the session, so @@ -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 { diff --git a/apps/kimi-code/src/tui/utils/dynamic-commands-gate.ts b/apps/kimi-code/src/tui/utils/dynamic-commands-gate.ts new file mode 100644 index 00000000000..9c2e04784cf --- /dev/null +++ b/apps/kimi-code/src/tui/utils/dynamic-commands-gate.ts @@ -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, + onTimeout: (warning: string) => void, +): Promise { + return new Promise((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); + }); +} diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index 262e2a28194..28fe0ccfb6e 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -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 { + 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[]; @@ -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((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((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((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 }); diff --git a/apps/kimi-code/test/tui/utils/dynamic-commands-gate.test.ts b/apps/kimi-code/test/tui/utils/dynamic-commands-gate.test.ts new file mode 100644 index 00000000000..e98123d2500 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/dynamic-commands-gate.test.ts @@ -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((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((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(); + }); +});