Skip to content
Closed
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/mid-prompt-slash-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add mid-prompt slash commands: type `/` anywhere in the prompt to complete skills and plugins (they activate on submit with the surrounding text as input), or to run commands like /plan and /model immediately on selection.
5 changes: 5 additions & 0 deletions .changeset/pi-tui-mid-prompt-completion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/pi-tui": patch
---

Trigger slash completion at mid-line token boundaries (not only at line start), and let an autocomplete completion declare preventSubmit so confirming it does not submit the editor text.
48 changes: 42 additions & 6 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,29 +172,62 @@ export interface SlashCommandHost {
// ---------------------------------------------------------------------------

export function dispatchInput(host: SlashCommandHost, text: string): void {
if (parseSlashInput(text) !== null) {
void executeSlashCommand(host, text);
const intent = resolveSlashCommandInput({
input: text,
skillCommandMap: host.skillCommandMap,
pluginCommandMap: host.pluginCommandMap,
isStreaming: host.state.appState.streamingPhase !== 'idle',
isCompacting: host.state.appState.isCompacting,
});
if (intent.kind === 'not-command') {
host.sendNormalUserInput(text);
return;
}
host.sendNormalUserInput(text);
void executeSlashIntent(host, text, intent);
}

async function executeSlashCommand(host: SlashCommandHost, input: string): Promise<void> {
const parsedCommand = parseSlashInput(input);
/**
* Run a built-in slash command triggered by selecting it from the mid-prompt
* autocomplete. Unlike dispatchInput the editor draft is still in place, so a
* blocked command only reports the error — it must not touch the draft.
*/
export function triggerImmediateSlashCommand(host: SlashCommandHost, commandName: string): void {
const intent = resolveSlashCommandInput({
input,
input: `/${commandName}`,
skillCommandMap: host.skillCommandMap,
pluginCommandMap: host.pluginCommandMap,
isStreaming: host.state.appState.streamingPhase !== 'idle',
isCompacting: host.state.appState.isCompacting,
});
if (intent.kind === 'blocked') {
host.track('input_command_invalid', { reason: 'blocked', command: intent.commandName });
host.showError(slashBusyMessage(intent.commandName, intent.reason));
return;
}
if (intent.kind !== 'builtin') return;
host.track('input_command', { command: intent.name });
void handleBuiltInSlashCommand(host, intent.name, intent.args).catch((error: unknown) => {
host.showError(formatErrorMessage(error));
});
}

async function executeSlashIntent(
host: SlashCommandHost,
originalInput: string,
intent: ReturnType<typeof resolveSlashCommandInput>,
): Promise<void> {
const leadingInput = originalInput.trimStart();
const parsedCommand = parseSlashInput(leadingInput);

switch (intent.kind) {
case 'not-command':
return;
case 'blocked':
host.track('input_command_invalid', { reason: 'blocked', command: intent.commandName });
host.showError(slashBusyMessage(intent.commandName, intent.reason));
// Editor clears on submit before dispatch; put the draft back so a
// mid-prompt skill blocked while streaming is not lost.
host.restoreInputText(originalInput);
return;
case 'invalid':
host.track('input_command_invalid', {
Expand All @@ -207,6 +240,7 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi
const session = host.session;
if (host.state.appState.model.trim().length === 0 || session === undefined) {
host.showError(LLM_NOT_SET_MESSAGE);
host.restoreInputText(originalInput);
return;
}
host.track('input_command', {
Expand All @@ -219,11 +253,13 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi
case 'plugin-command': {
if (host.state.appState.model.trim().length === 0) {
host.showError(LLM_NOT_SET_MESSAGE);
host.restoreInputText(originalInput);
return;
}
const session = host.session;
if (session === undefined) {
host.showError(LLM_NOT_SET_MESSAGE);
host.restoreInputText(originalInput);
return;
}
host.track('input_command', { command: `${intent.pluginId}:${intent.commandName}` });
Expand Down
77 changes: 77 additions & 0 deletions apps/kimi-code/src/tui/commands/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,80 @@ export function parseSlashInput(input: string): ParsedSlashInput | null {
if (name.includes('/') && !name.includes(':')) return null;
return { name, args };
}

export interface SlashTokenAtCursor {
/** The `/partial` token including the leading slash (name portion only). */
readonly token: string;
/** Index of `/` in `textBeforeCursor`. */
readonly startIndex: number;
/**
* True when only whitespace precedes the token — i.e. the input is still a
* leading slash command (with optional indent), not a mid-prompt embed.
*/
readonly isLeading: boolean;
}

/**
* If `textBeforeCursor` ends inside a slash-command name token (started at a
* token boundary), return that token. Does not match once a space has been
* typed after the command name (argument completion uses a different path).
*/
export function extractSlashTokenAtCursor(textBeforeCursor: string): SlashTokenAtCursor | null {
let tokenStart = 0;
for (let i = textBeforeCursor.length - 1; i >= 0; i -= 1) {
const ch = textBeforeCursor[i];
if (ch === ' ' || ch === '\t') {
tokenStart = i + 1;
break;
}
}
if (textBeforeCursor[tokenStart] !== '/') return null;
const before = textBeforeCursor.slice(0, tokenStart);
// Already inside a leading slash command's arguments (e.g. `/add-dir /tmp`).
// The trailing `/…` is a path, not a new command name token.
const leading = before.trimStart();
if (leading.startsWith('/') && /\s/.test(leading)) return null;

const token = textBeforeCursor.slice(tokenStart);
if (token.length === 0) return null;
const name = token.slice(1);
// Incomplete bare `/` is a valid slash-command prefix.
if (name.includes('/') && !name.includes(':')) return null;
const isLeading = before.trim().length === 0;
return { token, startIndex: tokenStart, isLeading };
}

export interface InlineSlashMatch {
readonly name: string;
/** Text before the `/name` token, trimmed on the right. */
readonly before: string;
/** Text after the `/name` token, trimmed on the left. */
readonly after: string;
/** `before` and `after` joined with a single space (empty parts dropped). */
readonly surroundingText: string;
}

/**
* Enumerate mid-prompt `/name` tokens that are not the leading command of the
* whole input. Callers decide which names are real skill/plugin commands.
*/
export function findInlineSlashTokens(input: string): readonly InlineSlashMatch[] {
if (parseSlashInput(input.trimStart()) !== null) {
// Whole input is already a leading slash command (optional indent).
return [];
}
const matches: InlineSlashMatch[] = [];
const re = /(^|\s)\/(\S+)/g;
for (const match of input.matchAll(re)) {
const name = match[2] ?? '';
if (name.length === 0) continue;
if (name.includes('/') && !name.includes(':')) continue;
const fullIndex = (match.index ?? 0) + (match[1]?.length ?? 0);
const tokenLength = 1 + name.length;
const before = input.slice(0, fullIndex).trimEnd();
const after = input.slice(fullIndex + tokenLength).trimStart();
const surroundingText = [before, after].filter((part) => part.length > 0).join(' ');
matches.push({ name, before, after, surroundingText });
}
return matches;
}
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/plugin-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function buildPluginSlashCommands(defs: readonly PluginCommandDef[]): Plu
name: commandName,
aliases: [],
description: def.description,
midPrompt: true,
} satisfies KimiSlashCommand;
});
return { commands, commandMap };
Expand Down
15 changes: 15 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,34 +139,39 @@ export const BUILTIN_SLASH_COMMANDS = [
description: 'Toggle YOLO mode: auto-approve tool actions, but the agent may still ask questions.',
priority: 101,
availability: 'always',
midPrompt: true,
},
{
name: 'auto',
aliases: [],
description: 'Toggle Auto mode: fully autonomous, agent decides everything without asking.',
priority: 99,
availability: 'always',
midPrompt: true,
},
{
name: 'permission',
aliases: [],
description: 'Select permission mode',
priority: 100,
availability: 'always',
midPrompt: true,
},
{
name: 'settings',
aliases: ['config'],
description: 'Open TUI settings',
priority: 100,
availability: 'always',
midPrompt: true,
},
{
name: 'plan',
aliases: [],
description: 'Toggle plan mode',
priority: 100,
availability: (args) => (args.trim().toLowerCase() === 'clear' ? 'idle-only' : 'always'),
midPrompt: true,
},
{
name: 'swarm',
Expand All @@ -183,6 +188,7 @@ export const BUILTIN_SLASH_COMMANDS = [
description: 'Switch LLM model',
priority: 100,
availability: 'always',
midPrompt: true,
},
{
name: 'secondary_model',
Expand All @@ -191,20 +197,23 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 90,
availability: 'always',
experimentalFlag: 'secondary-model',
midPrompt: true,
},
{
name: 'effort',
aliases: ['thinking'],
description: 'Switch thinking effort',
priority: 95,
availability: 'always',
midPrompt: true,
},
{
name: 'provider',
aliases: ['providers'],
description: 'Manage AI providers (add / delete / refresh)',
priority: 95,
availability: 'always',
midPrompt: true,
},
{
name: 'btw',
Expand All @@ -219,6 +228,7 @@ export const BUILTIN_SLASH_COMMANDS = [
description: 'Show available commands and shortcuts',
priority: 80,
availability: 'always',
midPrompt: true,
},
{
name: 'new',
Expand All @@ -238,20 +248,23 @@ export const BUILTIN_SLASH_COMMANDS = [
description: 'Browse background tasks',
priority: 80,
availability: 'always',
midPrompt: true,
},
{
name: 'mcp',
aliases: [],
description: 'Show MCP server status',
priority: 60,
availability: 'always',
midPrompt: true,
},
{
name: 'plugins',
aliases: [],
description: 'Manage plugins',
priority: 60,
availability: 'always',
midPrompt: true,
},
{
name: 'add-dir',
Expand Down Expand Up @@ -332,13 +345,15 @@ export const BUILTIN_SLASH_COMMANDS = [
description: 'Show session tokens + context window + plan quotas',
priority: 60,
availability: 'always',
midPrompt: true,
},
{
name: 'status',
aliases: [],
description: 'Show current session and runtime status',
priority: 60,
availability: 'always',
midPrompt: true,
},
{
name: 'feedback',
Expand Down
Loading