Skip to content
Open
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/lazy-global-search-startup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix several seconds of startup lag: the global search index (used only by the web UI's search) was being opened and synced in every terminal session, including ones that never search. It now loads on demand, so interactive startup stays fast.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

这个是在修复启动的时候,要十几秒卡住的 bug。如果 master 已修,可以删掉。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

目前 master 还没修掉

5 changes: 5 additions & 0 deletions .changeset/queue-skill-commands-while-busy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Queue slash skill commands entered while the agent is busy instead of rejecting them with "Cannot /<cmd> while streaming" — they now behave exactly like normal input: queued visibly by default, and Ctrl-S steers them into the running turn as real skill activations.
5 changes: 5 additions & 0 deletions .changeset/tower-slash-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add the /tower slash command to orchestrate multiple agents iterating on one repo in parallel — you act as the control tower while worker agents execute missions in their own git worktrees. Run /tower to start.
1 change: 1 addition & 0 deletions GOAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,4 @@ goal ID 不应暴露给模型,因为它只是 runtime/UI 内部标识,没有
goal 创建、暂停、恢复、阻塞、完成、清除都应发出 goal updated 事件。lifecycle 变化和 completion 变化应区分。completion 是一次终局事件,然后 snapshot 变 null。blocked/paused 保留 snapshot,UI 可以继续展示可恢复 goal。

session 恢复时,active goal 会变 paused,避免重启后自动继续。fork session 时不继承 goal,并提醒模型不要继续源 session 的目标。

12 changes: 4 additions & 8 deletions apps/kimi-code/src/tui/commands/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,10 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla

const skillName = resolveSkillCommand(options.skillCommandMap, parsed.name);
if (skillName !== undefined) {
const busyReason = slashCommandBusyReason(options);
if (busyReason !== undefined) {
return {
kind: 'blocked',
commandName: parsed.name,
reason: busyReason,
};
}
// Skill activations are never blocked by a busy session: the TUI queues

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

未声明 busy 的 skill 会从"被拒"变成"延迟自动执行",看能否接受。

// them behind the running turn exactly like normal messages (see
// sendSkillActivation), and Ctrl-S steers them as real activations, so
// commands like /tower can be issued any time.
return {
kind: 'skill',
commandName: parsed.name,
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/components/panes/queue-pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class QueuePaneComponent extends Container {

if (options.messages.length > 0) {
// Bash commands (`! …`) are not steerable, so only advertise Ctrl-S when
// there is at least one plain-text item that steering would actually send.
// there is at least one plain-text or skill item steering would send.
const hasSteerable = options.messages.some((m) => m.mode !== 'bash');
const canSteer = options.canSteerImmediately && hasSteerable;
this.hint =
Expand Down
43 changes: 35 additions & 8 deletions apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface EditorKeyboardHost {
handleUserInput(text: string): void;
readonly btwPanelController: BtwPanelController;
steerMessage(session: Session, input: readonly SteerInputItem[]): void;
steerSkillActivation(session: Session, skillName: string, skillArgs: string): void;
validateMediaCapabilities(extraction: {
hasMedia: boolean;
imageAttachmentIds: readonly number[];
Expand Down Expand Up @@ -270,18 +271,35 @@ export class EditorKeyboardController {
const text = editor.getText().trim();
const editorIsBash = editor.inputMode === 'bash';

// Bash commands (`! …`) are not steerable: keep them queued so they run
// after the current task instead of being injected into the turn as text.
// Bash commands (`! …`) are not steerable: they stay queued so they run
// after the current task. Everything else steers in queue order —
// plain text as a steered message, slash-skill items as activations
// fired into the running turn (never as literal text).
const queued = host.state.queuedMessages;
const steerable = queued.filter((m) => m.mode !== 'bash');

const items: SteerInputItem[] = [];
type SteerRun =
| { readonly kind: 'text'; readonly items: SteerInputItem[] }
| { readonly kind: 'skill'; readonly skillName: string; readonly skillArgs: string };
const runs: SteerRun[] = [];
let textRun: SteerInputItem[] = [];
const flushTextRun = (): void => {
if (textRun.length > 0) {
runs.push({ kind: 'text', items: textRun });
textRun = [];
}
};
for (const m of steerable) {
if (m.mode === 'skill' && m.skillName !== undefined) {
flushTextRun();
runs.push({ kind: 'skill', skillName: m.skillName, skillArgs: m.skillArgs ?? '' });
continue;
}
const trimmed = m.text.trim();
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 });
textRun.push({ text: trimmed, parts: m.parts, imageAttachmentIds: m.imageAttachmentIds });
}
}
let editorExtraction: ReturnType<typeof extractMediaAttachments> | undefined;
Expand All @@ -294,7 +312,7 @@ export class EditorKeyboardController {
host.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`);
return;
}
items.push({
textRun.push({
text,
parts: editorExtraction.hasMedia ? editorExtraction.parts : undefined,
imageAttachmentIds:
Expand All @@ -303,8 +321,9 @@ export class EditorKeyboardController {
: undefined,
});
}
flushTextRun();

if (items.length > 0) {
if (runs.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.
Expand All @@ -320,7 +339,13 @@ export class EditorKeyboardController {
if (host.state.appState.model.trim().length === 0 || session === undefined) {
host.showError(LLM_NOT_SET_MESSAGE);
} else {
host.steerMessage(session, items);
for (const run of runs) {
if (run.kind === 'text') {
host.steerMessage(session, run.items);
} else {
host.steerSkillActivation(session, run.skillName, run.skillArgs);
}
}
}
}
host.updateQueueDisplay();
Expand Down Expand Up @@ -354,7 +379,9 @@ export class EditorKeyboardController {
editor.setText(recalled.text);
// Restore the queued item's mode so a recalled `!` command runs as a
// shell command again instead of being submitted as a normal prompt.
const mode = recalled.mode ?? 'prompt';
// Skill activations recall as prompt mode: their text is the original
// `/name args` slash command, which re-parses on submit.
const mode = recalled.mode === 'bash' ? 'bash' : 'prompt';
if (editor.inputMode !== mode) {
editor.inputMode = mode;
editor.onInputModeChange?.(mode);
Expand Down
38 changes: 38 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1481,6 +1481,12 @@ export class KimiTUI {
void this.runShellCommandFromInput(item.text);
return;
}
if (item.mode === 'skill' && item.skillName !== undefined) {
// sendSkillActivation re-checks the busy state, so a premature drain
// re-queues at the tail instead of racing the running turn.
this.sendSkillActivation(session, item.skillName, item.skillArgs ?? '');
return;
}
this.harness.withInteractiveAgent(item.agentId ?? MAIN_AGENT_ID, () => {
this.sendMessageInternal(session, item.text, {
parts: item.parts,
Expand Down Expand Up @@ -1547,6 +1553,29 @@ export class KimiTUI {
return;
}
if (!this.validateMediaCapabilities(rewrite)) return;
// Compacting (or deferred input): queue behind it — visible and recallable.
// Slash-skill items steer like any queued input on Ctrl-S (the activation
// fires into the running turn instead of the literal text) — see
// editor-keyboard.ts.
// A running turn queues the activation too: every skill behaves like
// plain input — queued by default, steered on demand — because the engine
// steers activations into a running turn exactly like a steered user
// message (v2 `prompt.inject`, v1 `SkillManager.recordActivation`).
const turnRunning = this.state.appState.streamingPhase !== 'idle';
if (this.deferUserMessages || this.state.appState.isCompacting || turnRunning) {
const args = rewrite.text.trim();
this.state.queuedMessages.push({
text: `/${skillName}${args.length > 0 ? ` ${args}` : ''}`,
agentId: this.harness.interactiveAgentId,
mode: 'skill',
skillName,
skillArgs: rewrite.text,
});
this.track('input_queue');
this.updateQueueDisplay();
this.state.ui.requestRender();
return;
}
this.beginSessionRequest();
void session.activateSkill(skillName, rewrite.text).catch((error: unknown) => {
const message = formatErrorMessage(error);
Expand Down Expand Up @@ -1626,6 +1655,15 @@ export class KimiTUI {
});
}

steerSkillActivation(session: Session, skillName: string, skillArgs: string): void {
// Ctrl-S on a queued slash-skill item: the activation fires into the
// running turn (the engine steers it there, never the literal text). No
// beginSessionRequest — the live pane belongs to the running turn.
void session.activateSkill(skillName, skillArgs).catch((error: unknown) => {
this.showError(`Skill "${skillName}" failed: ${formatErrorMessage(error)}`);
});
}

// =========================================================================
// State & Accessors
// =========================================================================
Expand Down
8 changes: 7 additions & 1 deletion apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,14 @@ export interface QueuedMessage {
readonly parts?: readonly PromptPart[];
readonly imageAttachmentIds?: readonly number[];
/** `bash` for a `!` shell command queued while another command is running;
* `skill` for a slash-skill activation queued while the session is busy;
* undefined (=`prompt`) for a normal message. */
readonly mode?: 'prompt' | 'bash';
readonly mode?: 'prompt' | 'bash' | 'skill';
/** Set when mode === 'skill': the skill to activate when the item drains.
* `text` then holds the display/recall string (`/name args`). */
readonly skillName?: string;
/** Set when mode === 'skill': the raw (media-rewritten) args to activate with. */
readonly skillArgs?: string;
}

/**
Expand Down
14 changes: 8 additions & 6 deletions apps/kimi-code/test/tui/commands/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ describe('resolveSlashCommandInput', () => {
});
});

it('resolves skill commands and blocks them while busy', () => {
it('resolves skill commands and keeps them resolvable while busy (queued downstream)', () => {
const skillCommandMap = new Map([['skill:review', 'review']]);

expect(resolve('/skill:review src/app.ts', { skillCommandMap })).toEqual({
Expand All @@ -205,13 +205,14 @@ describe('resolveSlashCommandInput', () => {
args: 'src/app.ts',
});
expect(resolve('/skill:review src/app.ts', { skillCommandMap, isStreaming: true })).toEqual({
kind: 'blocked',
kind: 'skill',
commandName: 'skill:review',
reason: 'streaming',
skillName: 'review',
args: 'src/app.ts',
});
});

it('resolves unprefixed built-in skill commands and blocks them while busy', () => {
it('resolves unprefixed built-in skill commands and keeps them resolvable while busy', () => {
const skillCommandMap = new Map([['mcp-config', 'mcp-config']]);

expect(resolve('/mcp-config', { skillCommandMap })).toEqual({
Expand All @@ -221,9 +222,10 @@ describe('resolveSlashCommandInput', () => {
args: '',
});
expect(resolve('/mcp-config', { skillCommandMap, isCompacting: true })).toEqual({
kind: 'blocked',
kind: 'skill',
commandName: 'mcp-config',
reason: 'compacting',
skillName: 'mcp-config',
args: '',
});
});

Expand Down
62 changes: 62 additions & 0 deletions apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,65 @@ describe('EditorKeyboardController Shift-Tab plan toggle', () => {
expect(handlePlanToggle).not.toHaveBeenCalled();
});
});


/**
* Ctrl-S steering of the TUI queue: plain-text items steer as messages,
* slash-skill items fire as real activations into the running turn (never as
* literal text), bash items stay queued — all in queue order.
*/
describe('EditorKeyboardController Ctrl-S steering', () => {
it('steers text as a message, skill items as activations, and keeps bash queued', () => {
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {
setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown,
setInputMode: vi.fn() as unknown as (...args: never[]) => unknown,
getText: vi.fn(() => '') as unknown as (...args: never[]) => unknown,
setText: vi.fn() as unknown as (...args: never[]) => unknown,
inputMode: 'prompt' as unknown as (...args: never[]) => unknown,
};
const steerMessage = vi.fn();
const steerSkillActivation = vi.fn();
const updateQueueDisplay = vi.fn();
const session = { id: 'ses-1' };
const host = {
state: {
editor,
queuedMessages: [
{ text: 'queued text', agentId: 'main' },
{ text: '/tower status', agentId: 'main', mode: 'skill', skillName: 'tower', skillArgs: 'status' },
{ text: '!ls', agentId: 'main', mode: 'bash' },
],
appState: {
streamingPhase: 'waiting',
isCompacting: false,
model: 'mock-model',
},
ui: { requestRender: vi.fn() },
},
session,
steerMessage,
steerSkillActivation,
updateQueueDisplay,
validateMediaCapabilities: vi.fn(() => true),
showError: vi.fn(),
track: vi.fn(),
} as unknown as EditorKeyboardHost;

const controller = new EditorKeyboardController(
host,
undefined as unknown as ImageAttachmentStore,
);
controller.install();

const handler = editor['onCtrlS'];
expect(handler).toBeDefined();
(handler as () => void)();

expect(steerMessage).toHaveBeenCalledWith(session, [
{ text: 'queued text', parts: undefined, imageAttachmentIds: undefined },
]);
expect(steerSkillActivation).toHaveBeenCalledWith(session, 'tower', 'status');
expect(host.state.queuedMessages).toEqual([{ text: '!ls', agentId: 'main', mode: 'bash' }]);
expect(updateQueueDisplay).toHaveBeenCalled();
});
});
Loading
Loading