From 9682d9f2864a5179f223476d508cd0c83c7e711e Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Sat, 1 Aug 2026 02:11:10 +0000 Subject: [PATCH 1/5] feat(tui): support mid-prompt slash autocomplete and skill activation Allow selected slash commands to appear when typing `/` mid-prompt, and activate skills/plugins embedded in free text on submit. Builtins that only make sense at the start stay leading-only. --- apps/kimi-code/src/tui/commands/dispatch.ts | 31 +++++-- apps/kimi-code/src/tui/commands/parse.ts | 77 ++++++++++++++++ .../src/tui/commands/plugin-commands.ts | 1 + apps/kimi-code/src/tui/commands/registry.ts | 9 ++ apps/kimi-code/src/tui/commands/resolve.ts | 70 +++++++++++++- apps/kimi-code/src/tui/commands/skills.ts | 1 + apps/kimi-code/src/tui/commands/types.ts | 6 ++ .../editor/file-mention-provider.ts | 78 ++++++++++++---- apps/kimi-code/src/tui/kimi-tui.ts | 1 + .../test/tui/commands/resolve.test.ts | 81 +++++++++++++++++ .../test/tui/commands/skills.test.ts | 2 + .../editor/file-mention-provider.test.ts | 91 +++++++++++++++++++ packages/pi-tui/src/components/editor.ts | 25 ++++- 13 files changed, 434 insertions(+), 39 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index b2feffaebe..242bb2f9c8 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -172,22 +172,27 @@ export interface SlashCommandHost { // --------------------------------------------------------------------------- export function dispatchInput(host: SlashCommandHost, text: string): void { - if (parseSlashInput(text) !== null) { - void executeSlashCommand(host, text); - return; - } - host.sendNormalUserInput(text); -} - -async function executeSlashCommand(host: SlashCommandHost, input: string): Promise { - const parsedCommand = parseSlashInput(input); const intent = resolveSlashCommandInput({ - input, + 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; + } + void executeSlashIntent(host, text, intent); +} + +async function executeSlashIntent( + host: SlashCommandHost, + originalInput: string, + intent: ReturnType, +): Promise { + const leadingInput = originalInput.trimStart(); + const parsedCommand = parseSlashInput(leadingInput); switch (intent.kind) { case 'not-command': @@ -195,6 +200,9 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi 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', { @@ -207,6 +215,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', { @@ -219,11 +228,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}` }); diff --git a/apps/kimi-code/src/tui/commands/parse.ts b/apps/kimi-code/src/tui/commands/parse.ts index 1fcc01548f..ca9542b60b 100644 --- a/apps/kimi-code/src/tui/commands/parse.ts +++ b/apps/kimi-code/src/tui/commands/parse.ts @@ -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('/') && leading.includes(' ')) 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; +} diff --git a/apps/kimi-code/src/tui/commands/plugin-commands.ts b/apps/kimi-code/src/tui/commands/plugin-commands.ts index a26e6fdb55..ddfda53bd2 100644 --- a/apps/kimi-code/src/tui/commands/plugin-commands.ts +++ b/apps/kimi-code/src/tui/commands/plugin-commands.ts @@ -21,6 +21,7 @@ export function buildPluginSlashCommands(defs: readonly PluginCommandDef[]): Plu name: commandName, aliases: [], description: def.description, + midPrompt: true, } satisfies KimiSlashCommand; }); return { commands, commandMap }; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 2cea404918..ae9dac929c 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -139,6 +139,7 @@ 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', @@ -146,6 +147,7 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Toggle Auto mode: fully autonomous, agent decides everything without asking.', priority: 99, availability: 'always', + midPrompt: true, }, { name: 'permission', @@ -153,6 +155,7 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Select permission mode', priority: 100, availability: 'always', + midPrompt: true, }, { name: 'settings', @@ -167,6 +170,7 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Toggle plan mode', priority: 100, availability: (args) => (args.trim().toLowerCase() === 'clear' ? 'idle-only' : 'always'), + midPrompt: true, }, { name: 'swarm', @@ -183,6 +187,7 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Switch LLM model', priority: 100, availability: 'always', + midPrompt: true, }, { name: 'secondary_model', @@ -191,6 +196,7 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 90, availability: 'always', experimentalFlag: 'secondary-model', + midPrompt: true, }, { name: 'effort', @@ -198,6 +204,7 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Switch thinking effort', priority: 95, availability: 'always', + midPrompt: true, }, { name: 'provider', @@ -332,6 +339,7 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Show session tokens + context window + plan quotas', priority: 60, availability: 'always', + midPrompt: true, }, { name: 'status', @@ -339,6 +347,7 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Show current session and runtime status', priority: 60, availability: 'always', + midPrompt: true, }, { name: 'feedback', diff --git a/apps/kimi-code/src/tui/commands/resolve.ts b/apps/kimi-code/src/tui/commands/resolve.ts index e67457a94b..9fd6379d86 100644 --- a/apps/kimi-code/src/tui/commands/resolve.ts +++ b/apps/kimi-code/src/tui/commands/resolve.ts @@ -5,7 +5,7 @@ import { type BuiltinSlashCommandName, } from './registry'; import { isExperimentalFlagEnabled } from './experimental-flags'; -import { parseSlashInput } from './parse'; +import { findInlineSlashTokens, parseSlashInput } from './parse'; import type { KimiSlashCommand, SlashCommandBusyReason, @@ -52,10 +52,72 @@ export interface ResolveSlashCommandInput { readonly isCompacting: boolean; } +/** + * Resolve a submitted editor string. Leading slash commands (optional indent) + * use the existing path; free text may embed a mid-prompt skill/plugin token + * whose surrounding words become the skill/plugin args. + */ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): SlashCommandIntent { - const parsed = parseSlashInput(options.input); - if (parsed === null) return { kind: 'not-command' }; + const leadingInput = options.input.trimStart(); + const parsed = parseSlashInput(leadingInput); + if (parsed !== null) { + return resolveParsedSlashCommand(parsed, options, leadingInput); + } + + // Scan every mid-prompt `/token`; skip unknowns so an earlier `/tmp` or + // `/yolo` does not hide a later skill/plugin activation. + for (const inline of findInlineSlashTokens(options.input)) { + // Exact map key only (no bare→`skill:` fallback). Mid-prompt bare names + // like `/tmp` would otherwise hijack common path segments whenever a + // skill happens to share that name. Autocomplete inserts the registered + // name (`skill:foo` or builtin bare name) for external skills. + const skillName = options.skillCommandMap.get(inline.name); + if (skillName !== undefined) { + const busyReason = slashCommandBusyReason(options); + if (busyReason !== undefined) { + return { + kind: 'blocked', + commandName: inline.name, + reason: busyReason, + }; + } + return { + kind: 'skill', + commandName: inline.name, + skillName, + args: inline.surroundingText, + }; + } + + if (options.pluginCommandMap.has(inline.name)) { + const busyReason = slashCommandBusyReason(options); + if (busyReason !== undefined) { + return { + kind: 'blocked', + commandName: inline.name, + reason: busyReason, + }; + } + const separator = inline.name.indexOf(':'); + const pluginId = separator === -1 ? inline.name : inline.name.slice(0, separator); + const commandName = separator === -1 ? '' : inline.name.slice(separator + 1); + return { + kind: 'plugin-command', + commandName, + pluginId, + args: inline.surroundingText, + }; + } + } + + return { kind: 'not-command' }; +} +function resolveParsedSlashCommand( + parsed: { name: string; args: string }, + options: ResolveSlashCommandInput, + leadingInput: string, +): SlashCommandIntent { const command = findBuiltInSlashCommand(parsed.name); // `command` is a literal union where only some members carry `experimentalFlag`; widen to read it. if ( @@ -121,7 +183,7 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla return { kind: 'message', - input: options.input, + input: leadingInput, }; } diff --git a/apps/kimi-code/src/tui/commands/skills.ts b/apps/kimi-code/src/tui/commands/skills.ts index 2997a8b151..79cabb0134 100644 --- a/apps/kimi-code/src/tui/commands/skills.ts +++ b/apps/kimi-code/src/tui/commands/skills.ts @@ -42,6 +42,7 @@ export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillS name: commandName, aliases: [], description: skill.description ?? '', + midPrompt: true, }; }); return { commands, commandMap }; diff --git a/apps/kimi-code/src/tui/commands/types.ts b/apps/kimi-code/src/tui/commands/types.ts index 1ec3c68352..6489ebc643 100644 --- a/apps/kimi-code/src/tui/commands/types.ts +++ b/apps/kimi-code/src/tui/commands/types.ts @@ -9,6 +9,12 @@ export interface KimiSlashCommand extends SlashCom readonly description: string; readonly priority?: number; readonly availability?: SlashCommandAvailability | ((args: string) => SlashCommandAvailability); + /** + * When true, this command is offered by `/` autocomplete mid-prompt (after + * existing text), not only when the input starts with `/`. Defaults to false + * for built-ins; skill and plugin commands set it true. + */ + readonly midPrompt?: boolean; /** When set, the command is hidden from the palette and blocked unless this flag is enabled. */ readonly experimentalFlag?: FlagId; /** diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index 722682db60..0c1d3e015c 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -10,12 +10,16 @@ import { type SlashCommand, } from '@moonshot-ai/pi-tui'; +import { extractSlashTokenAtCursor } from '#/tui/commands/parse'; + const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']); const MAX_FALLBACK_SCAN = 2000; const MAX_FALLBACK_SUGGESTIONS = 50; export interface SlashAutocompleteCommand extends SlashCommand { readonly aliases?: readonly string[]; + /** When true, offered by mid-prompt `/` autocomplete (not only at input start). */ + readonly midPrompt?: boolean; } interface FsMentionCandidate { @@ -100,10 +104,6 @@ export class FileMentionProvider implements AutocompleteProvider { } } - if (shouldSuppressLeadingWhitespaceSlashPath(textBeforeCursor, options.force)) { - return null; - } - if ( shouldSuppressSlashArgumentCompletion( textBeforeCursor, @@ -115,16 +115,21 @@ export class FileMentionProvider implements AutocompleteProvider { } // Handle slash-command name completion ourselves so that aliases are - // searchable and visible in the label. - if (!options.force && textBeforeCursor.startsWith('/')) { - const spaceIndex = textBeforeCursor.indexOf(' '); - if (spaceIndex === -1) { - const tokens = textBeforeCursor + // searchable and visible in the label. Supports mid-prompt `/` tokens + // (after existing text) for commands marked `midPrompt`. Bash mode treats + // `/` as a path separator, so never intercept there. + if (!options.force && this.getInputMode() !== 'bash') { + const slashToken = extractSlashTokenAtCursor(textBeforeCursor); + if (slashToken !== null) { + const tokens = slashToken.token .slice(1) - .trim() .split(/\s+/) .filter((t) => t.length > 0); + const candidates = slashToken.isLeading + ? this.slashCommands + : this.slashCommands.filter((cmd) => cmd.midPrompt === true); + type SlashMatch = { cmd: SlashAutocompleteCommand; score: number; @@ -133,7 +138,7 @@ export class FileMentionProvider implements AutocompleteProvider { }; const matches: SlashMatch[] = []; - for (const cmd of this.slashCommands) { + for (const cmd of candidates) { const nameScore = scoreTokens(tokens, cmd.name); if (nameScore !== null) { matches.push({ cmd, score: nameScore, viaAlias: false, label: cmd.name }); @@ -162,18 +167,29 @@ export class FileMentionProvider implements AutocompleteProvider { // Primary-name matches outrank alias matches on score ties. matches.sort((a, b) => a.score - b.score || Number(a.viaAlias) - Number(b.viaAlias)); - if (matches.length === 0) return null; - return { - items: matches.map((m) => ({ - value: m.cmd.name, - label: m.label, - description: formatSlashCommandDescription(m.cmd), - })), - prefix: textBeforeCursor, - }; + if (matches.length > 0) { + return { + items: matches.map((m) => ({ + value: m.cmd.name, + label: m.label, + description: formatSlashCommandDescription(m.cmd), + })), + // Prefix is only the `/token` so applyCompletion replaces just that + // span, including when the slash sits mid-prompt. + prefix: slashToken.token, + }; + } + // No command matched: fall through so path completion can still run + // for mid-prompt tokens that look like paths. + if (slashToken.isLeading) return null; } } + // Leading whitespace + `/` must not fall through to root path completion. + if (shouldSuppressLeadingWhitespaceSlashPath(textBeforeCursor, options.force)) { + return null; + } + // In bash mode `/` is a path separator, not a slash command. Skip slash // command argument handling so an absolute path that happens to start with // a command name (e.g. `/add-dir/...`) completes inside the path instead of @@ -215,6 +231,28 @@ export class FileMentionProvider implements AutocompleteProvider { if (this.getInputMode() === 'bash' && prefix.startsWith('/')) { return applyPathCompletion(lines, cursorLine, cursorCol, item, prefix); } + + // Mid-prompt (and leading) slash-command *name* completion only. Do not + // treat path-argument prefixes like `/` or `/tmp` after `/add-dir ` as + // command names — those must go through pi-tui's path branch. + if (this.getInputMode() !== 'bash') { + const currentLine = lines[cursorLine] ?? ''; + const textBeforeCursor = currentLine.slice(0, cursorCol); + const slashToken = extractSlashTokenAtCursor(textBeforeCursor); + if (slashToken !== null && slashToken.token === prefix) { + const beforePrefix = currentLine.slice(0, slashToken.startIndex); + const afterCursor = currentLine.slice(cursorCol); + const newLine = `${beforePrefix}/${item.value} ${afterCursor}`; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + item.value.length + 2, // `/` + name + space + }; + } + } + return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix); } } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 834bd77771..4cf5c12b51 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -454,6 +454,7 @@ export class KimiTUI { name: cmd.name, aliases: cmd.aliases, description: cmd.description, + midPrompt: cmd.midPrompt, ...(cmd.argumentHint !== undefined ? { argumentHint: cmd.argumentHint } : {}), ...(completer !== undefined ? { getArgumentCompletions: (prefix: string) => completer(prefix) } diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index 614553bb42..18c8653c9a 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -337,4 +337,85 @@ describe('slash command busy helpers', () => { reason: 'streaming', }); }); + + it('resolves a leading slash command with indent', () => { + expect(resolve(' /help')).toMatchObject({ kind: 'builtin', name: 'help', args: '' }); + }); + + it('activates a mid-prompt skill and uses surrounding text as args', () => { + const skillCommandMap = new Map([['skill:review', 'review']]); + expect( + resolve('please check this /skill:review carefully', { skillCommandMap }), + ).toEqual({ + kind: 'skill', + commandName: 'skill:review', + skillName: 'review', + args: 'please check this carefully', + }); + }); + + it('does not bare-fallback external skills mid-prompt (avoids /tmp hijacks)', () => { + const skillCommandMap = new Map([['skill:tmp', 'tmp']]); + expect(resolve('cd /tmp then continue', { skillCommandMap })).toEqual({ + kind: 'not-command', + }); + }); + + it('activates a mid-prompt builtin skill by exact registered name', () => { + const skillCommandMap = new Map([['mcp-config', 'mcp-config']]); + expect(resolve('please /mcp-config here', { skillCommandMap })).toEqual({ + kind: 'skill', + commandName: 'mcp-config', + skillName: 'mcp-config', + args: 'please here', + }); + }); + + it('activates a mid-prompt plugin command with surrounding text as args', () => { + const pluginCommandMap = new Map([['my-plugin:deploy', 'Deploy $ARGUMENTS']]); + expect(resolve('ship it /my-plugin:deploy prod', { pluginCommandMap })).toEqual({ + kind: 'plugin-command', + commandName: 'deploy', + pluginId: 'my-plugin', + args: 'ship it prod', + }); + }); + + it('does not treat a mid-prompt non-skill builtin as a command', () => { + expect(resolve('please /new session')).toEqual({ kind: 'not-command' }); + expect(resolve('enable /yolo please')).toEqual({ kind: 'not-command' }); + }); + + it('skips unknown mid-prompt tokens and still finds a later skill', () => { + const skillCommandMap = new Map([['skill:review', 'review']]); + expect( + resolve('check /tmp then /skill:review carefully', { skillCommandMap }), + ).toEqual({ + kind: 'skill', + commandName: 'skill:review', + skillName: 'review', + args: 'check /tmp then carefully', + }); + }); + + it('skips a mid-prompt yolo token and still finds a later skill', () => { + const skillCommandMap = new Map([['skill:review', 'review']]); + expect(resolve('please /yolo /skill:review', { skillCommandMap })).toEqual({ + kind: 'skill', + commandName: 'skill:review', + skillName: 'review', + args: 'please /yolo', + }); + }); + + it('blocks a mid-prompt skill while streaming', () => { + const skillCommandMap = new Map([['skill:review', 'review']]); + expect( + resolve('please /skill:review', { skillCommandMap, isStreaming: true }), + ).toEqual({ + kind: 'blocked', + commandName: 'skill:review', + reason: 'streaming', + }); + }); }); diff --git a/apps/kimi-code/test/tui/commands/skills.test.ts b/apps/kimi-code/test/tui/commands/skills.test.ts index b9cf46bd60..2e9f3f6138 100644 --- a/apps/kimi-code/test/tui/commands/skills.test.ts +++ b/apps/kimi-code/test/tui/commands/skills.test.ts @@ -47,11 +47,13 @@ describe('skill slash commands', () => { name: 'skill:commit', aliases: [], description: 'commit skill', + midPrompt: true, }); expect(built.commands[1]).toMatchObject({ name: 'skill:nested-review', aliases: [], description: 'Nested review skill', + midPrompt: true, }); expect([...built.commandMap.entries()]).toEqual([ ['skill:commit', 'commit'], diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index b53b49a830..e644174096 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -43,10 +43,18 @@ const NEW_COMMAND = { description: 'Start a fresh session in the current workspace', }; +const YOLO_COMMAND = { + name: 'yolo', + aliases: ['yes'], + description: 'Toggle YOLO mode', + midPrompt: true, +}; + const LARK_CALENDAR_COMMAND = { name: 'skill:lark-calendar', aliases: [], description: 'Manage Lark calendars', + midPrompt: true, }; const HELP_COMMAND = { @@ -236,6 +244,89 @@ describe('FileMentionProvider', () => { }); }); + it('offers only midPrompt commands after existing text', async () => { + const provider = new FileMentionProvider( + [NEW_COMMAND, YOLO_COMMAND, LARK_CALENDAR_COMMAND, HELP_COMMAND], + workDir, + NO_FD, + ); + const line = 'please /'; + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('/'); + const values = result!.items.map((item) => item.value); + expect(values).toContain('yolo'); + expect(values).toContain('skill:lark-calendar'); + expect(values).not.toContain('new'); + expect(values).not.toContain('help'); + }); + + it('still offers all commands at the leading slash', async () => { + const provider = new FileMentionProvider( + [NEW_COMMAND, YOLO_COMMAND, HELP_COMMAND], + workDir, + NO_FD, + ); + const result = await provider.getSuggestions(['/'], 0, 1, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((item) => item.value); + expect(values).toEqual(expect.arrayContaining(['new', 'yolo', 'help'])); + }); + + it('applies mid-prompt slash completion without rewriting leading text', () => { + const provider = new FileMentionProvider([YOLO_COMMAND], workDir, NO_FD); + const applied = provider.applyCompletion(['please /yo'], 0, 'please /yo'.length, { + value: 'yolo', + label: 'yolo', + }, '/yo'); + + expect(applied.lines[0]).toBe('please /yolo '); + expect(applied.cursorCol).toBe('please /yolo '.length); + }); + + it('applies add-dir absolute path completions without a double slash', () => { + const provider = new FileMentionProvider([ADD_DIR_COMMAND], workDir, NO_FD); + const line = '/add-dir /'; + const applied = provider.applyCompletion([line], 0, line.length, { + value: '/tmp/shared/', + label: 'shared/', + description: '/tmp/shared', + }, '/'); + + expect(applied.lines[0]).toBe('/add-dir /tmp/shared/'); + }); + + it('does not intercept bash-mode path completion with midPrompt commands', async () => { + const provider = new FileMentionProvider( + [YOLO_COMMAND, LARK_CALENDAR_COMMAND], + workDir, + NO_FD, + [], + () => 'bash', + ); + writeFileSync(join(workDir, 'Appfile'), 'x'); + mkdirSync(join(workDir, 'Applications')); + const result = await provider.getSuggestions(['cd /'], 0, 'cd /'.length, { + signal: ctrl(), + force: true, + }); + // force:true path completion — must not surface slash commands + if (result !== null) { + expect(result.items.every((item) => item.value !== 'yolo')).toBe(true); + expect(result.items.every((item) => !String(item.value).startsWith('skill:'))).toBe(true); + } + + const soft = await provider.getSuggestions(['cd /'], 0, 'cd /'.length, { + signal: ctrl(), + force: false, + }); + if (soft !== null) { + expect(soft.items.every((item) => item.value !== 'yolo')).toBe(true); + } + }); + it('returns null for a bare slash when no commands are registered', async () => { const provider = new FileMentionProvider([], workDir, NO_FD); diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index dfaa3a59e4..ca3fcd9e44 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -1216,9 +1216,19 @@ export class Editor implements Component, Focusable { // Check if we should trigger or update autocomplete if (!this.autocompleteState) { - // Auto-trigger for "/" at the start of a line (slash commands) - if (char === "/" && this.isAtStartOfMessage()) { - this.tryTriggerAutocomplete(); + // Auto-trigger for "/" at a token boundary (leading or mid-prompt slash commands) + if (char === "/" && this.isSlashMenuAllowed()) { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + const charBeforeSlash = textBeforeCursor[textBeforeCursor.length - 2]; + if ( + textBeforeCursor.length === 1 || + charBeforeSlash === undefined || + charBeforeSlash === " " || + charBeforeSlash === "\t" + ) { + this.tryTriggerAutocomplete(); + } } // Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries else if (this.autocompleteTriggerCharacters.includes(char)) { @@ -2160,7 +2170,11 @@ export class Editor implements Component, Focusable { } private isInSlashCommandContext(textBeforeCursor: string): boolean { - return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/"); + if (!this.isSlashMenuAllowed()) return false; + // Leading slash command (optional indent), including argument typing. + if (textBeforeCursor.trimStart().startsWith("/")) return true; + // Mid-prompt slash token at a word boundary (name portion only). + return /(?:^|[\t ])\/\S*$/.test(textBeforeCursor); } // Autocomplete methods @@ -2211,7 +2225,8 @@ export class Editor implements Component, Focusable { const currentLine = this.state.lines[this.state.cursorLine] || ""; const beforeCursor = currentLine.slice(0, this.state.cursorCol); - if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) { + // Name completion when the current token is `/…` (leading or mid-prompt). + if (this.isSlashMenuAllowed() && /(?:^|[\t ])\/[^\s]*$/.test(beforeCursor)) { this.handleSlashCommandCompletion(); } else { this.forceFileAutocomplete(true); From a3a547683170c47d5e3827087f457ab4e5124308 Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Sat, 1 Aug 2026 02:11:10 +0000 Subject: [PATCH 2/5] fix(tui): address review findings for mid-prompt slash commands - applyCompletion only rewrites a /token prefix as a command name when the item is a registered slash command, so a mid-prompt path completion (e.g. 'please /tm' -> '/tmp/') no longer produces a doubled slash with a stray trailing space. - Update plugin-commands test expectations for the midPrompt flag. - Drop the now-unused isAtStartOfMessage helper from pi-tui's editor. - Treat tab as a separator in the leading-command argument guard, matching the tokenizer. --- apps/kimi-code/src/tui/commands/parse.ts | 2 +- .../components/editor/file-mention-provider.ts | 8 ++++++-- .../test/tui/commands/plugin-commands.test.ts | 4 +++- .../editor/file-mention-provider.test.ts | 15 +++++++++++++++ packages/pi-tui/src/components/editor.ts | 8 -------- 5 files changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/parse.ts b/apps/kimi-code/src/tui/commands/parse.ts index ca9542b60b..f50a6a18b6 100644 --- a/apps/kimi-code/src/tui/commands/parse.ts +++ b/apps/kimi-code/src/tui/commands/parse.ts @@ -44,7 +44,7 @@ export function extractSlashTokenAtCursor(textBeforeCursor: string): SlashTokenA // 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('/') && leading.includes(' ')) return null; + if (leading.startsWith('/') && /\s/.test(leading)) return null; const token = textBeforeCursor.slice(tokenStart); if (token.length === 0) return null; diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index 0c1d3e015c..a920fa729d 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -234,12 +234,16 @@ export class FileMentionProvider implements AutocompleteProvider { // Mid-prompt (and leading) slash-command *name* completion only. Do not // treat path-argument prefixes like `/` or `/tmp` after `/add-dir ` as - // command names — those must go through pi-tui's path branch. + // command names — those must go through pi-tui's path branch. The same + // `/token` prefix is also emitted by mid-prompt *path* completion (e.g. + // `please /tm` completing to `/tmp/`), so require the item to be a + // registered command name before rewriting it as `/name `. if (this.getInputMode() !== 'bash') { const currentLine = lines[cursorLine] ?? ''; const textBeforeCursor = currentLine.slice(0, cursorCol); const slashToken = extractSlashTokenAtCursor(textBeforeCursor); - if (slashToken !== null && slashToken.token === prefix) { + const isCommandItem = this.slashCommands.some((cmd) => cmd.name === item.value); + if (slashToken !== null && slashToken.token === prefix && isCommandItem) { const beforePrefix = currentLine.slice(0, slashToken.startIndex); const afterCursor = currentLine.slice(cursorCol); const newLine = `${beforePrefix}/${item.value} ${afterCursor}`; diff --git a/apps/kimi-code/test/tui/commands/plugin-commands.test.ts b/apps/kimi-code/test/tui/commands/plugin-commands.test.ts index eae75a0997..b8fdcc2309 100644 --- a/apps/kimi-code/test/tui/commands/plugin-commands.test.ts +++ b/apps/kimi-code/test/tui/commands/plugin-commands.test.ts @@ -19,7 +19,9 @@ describe('buildPluginSlashCommands', () => { path: '/p/deploy.md', }, ]); - expect(commands).toEqual([{ name: 'my-plugin:deploy', aliases: [], description: 'Deploy' }]); + expect(commands).toEqual([ + { name: 'my-plugin:deploy', aliases: [], description: 'Deploy', midPrompt: true }, + ]); expect(commandMap.get('my-plugin:deploy')).toBe('Deploy $ARGUMENTS'); }); diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index e644174096..0ef50ed0a8 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -298,6 +298,21 @@ describe('FileMentionProvider', () => { expect(applied.lines[0]).toBe('/add-dir /tmp/shared/'); }); + it('applies mid-prompt path completions without rewriting them as command names', async () => { + const provider = new FileMentionProvider([YOLO_COMMAND], workDir, NO_FD); + const line = 'please /tm'; + // No midPrompt command fuzzy-matches `tm`, so the token falls through to + // path completion, which reuses the same `/tm` prefix. + const suggestions = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + expect(suggestions).not.toBeNull(); + const item = suggestions!.items.find((entry) => entry.value === '/tmp/'); + expect(item).toBeDefined(); + + const applied = provider.applyCompletion([line], 0, line.length, item!, suggestions!.prefix); + expect(applied.lines[0]).toBe('please /tmp/'); + expect(applied.cursorCol).toBe('please /tmp/'.length); + }); + it('does not intercept bash-mode path completion with midPrompt commands', async () => { const provider = new FileMentionProvider( [YOLO_COMMAND, LARK_CALENDAR_COMMAND], diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index ca3fcd9e44..e570817155 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -2161,14 +2161,6 @@ export class Editor implements Component, Focusable { return this.state.cursorLine === 0; } - // Helper method to check if cursor is at start of message (for slash command detection) - private isAtStartOfMessage(): boolean { - if (!this.isSlashMenuAllowed()) return false; - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const beforeCursor = currentLine.slice(0, this.state.cursorCol); - return beforeCursor.trim() === "" || beforeCursor.trim() === "/"; - } - private isInSlashCommandContext(textBeforeCursor: string): boolean { if (!this.isSlashMenuAllowed()) return false; // Leading slash command (optional indent), including argument typing. From 430b8b609857f1d78829ab36ce77560923a82cea Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Sat, 1 Aug 2026 02:11:10 +0000 Subject: [PATCH 3/5] feat(tui): run mid-prompt builtin commands on selection Selecting a builtin offered by the mid-prompt slash menu now executes it in place instead of inserting its name into the draft: the /token is dropped from the text (undoable) and the command runs with the draft preserved. Settings, help, tasks, mcp, plugins and provider join the mid-prompt set alongside the existing toggles and info panels; skill and plugin commands keep insert-on-select and activate on submit. applyCompletion can now return preventSubmit so confirming such a completion does not fall through to submitting the draft (pi-tui submits slash-prefix completions on Enter). Blocked commands only show the busy error without touching the in-place draft, and immediate items are labeled 'runs immediately' in the mid-prompt menu. --- apps/kimi-code/src/tui/commands/dispatch.ts | 25 +++++++ apps/kimi-code/src/tui/commands/registry.ts | 6 ++ .../editor/file-mention-provider.ts | 50 +++++++++++--- apps/kimi-code/src/tui/kimi-tui.ts | 29 ++++++-- .../test/tui/commands/dispatch.test.ts | 58 ++++++++++++++++ .../editor/file-mention-provider.test.ts | 68 +++++++++++++++++++ packages/pi-tui/src/autocomplete.ts | 6 ++ packages/pi-tui/src/components/editor.ts | 6 ++ packages/pi-tui/test/editor.test.ts | 43 ++++++++++++ 9 files changed, 276 insertions(+), 15 deletions(-) create mode 100644 apps/kimi-code/test/tui/commands/dispatch.test.ts diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 242bb2f9c8..bc2f72b672 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -186,6 +186,31 @@ export function dispatchInput(host: SlashCommandHost, text: string): void { void executeSlashIntent(host, text, intent); } +/** + * 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: `/${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, diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index ae9dac929c..28481094d0 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -163,6 +163,7 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Open TUI settings', priority: 100, availability: 'always', + midPrompt: true, }, { name: 'plan', @@ -212,6 +213,7 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Manage AI providers (add / delete / refresh)', priority: 95, availability: 'always', + midPrompt: true, }, { name: 'btw', @@ -226,6 +228,7 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Show available commands and shortcuts', priority: 80, availability: 'always', + midPrompt: true, }, { name: 'new', @@ -245,6 +248,7 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Browse background tasks', priority: 80, availability: 'always', + midPrompt: true, }, { name: 'mcp', @@ -252,6 +256,7 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Show MCP server status', priority: 60, availability: 'always', + midPrompt: true, }, { name: 'plugins', @@ -259,6 +264,7 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Manage plugins', priority: 60, availability: 'always', + midPrompt: true, }, { name: 'add-dir', diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index a920fa729d..a8b40bcd98 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -20,6 +20,12 @@ export interface SlashAutocompleteCommand extends SlashCommand { readonly aliases?: readonly string[]; /** When true, offered by mid-prompt `/` autocomplete (not only at input start). */ readonly midPrompt?: boolean; + /** + * When true, selecting this command from the mid-prompt menu runs it + * immediately (via the `onImmediateCommand` constructor callback) instead of + * inserting its name into the text. Leading-slash selection still inserts. + */ + readonly immediate?: boolean; } interface FsMentionCandidate { @@ -49,6 +55,7 @@ export class FileMentionProvider implements AutocompleteProvider { private readonly fdPath: string | null, additionalDirs: readonly string[] = [], private readonly getInputMode: () => 'prompt' | 'bash' = () => 'prompt', + private readonly onImmediateCommand?: (name: string) => void, ) { this.additionalDirs = additionalDirs.map((dir) => normalizePath(resolve(workDir, dir))); // Build an expanded list that includes alias entries so that @@ -169,11 +176,22 @@ export class FileMentionProvider implements AutocompleteProvider { if (matches.length > 0) { return { - items: matches.map((m) => ({ - value: m.cmd.name, - label: m.label, - description: formatSlashCommandDescription(m.cmd), - })), + items: matches.map((m) => { + const description = formatSlashCommandDescription(m.cmd); + // Mid-prompt immediate commands run on selection instead of + // inserting text — say so in the menu. + const note = + !slashToken.isLeading && m.cmd.immediate === true + ? 'runs immediately' + : undefined; + return { + value: m.cmd.name, + label: m.label, + description: + [description, note].filter((part) => part !== undefined).join(' — ') || + undefined, + }; + }), // Prefix is only the `/token` so applyCompletion replaces just that // span, including when the slash sits mid-prompt. prefix: slashToken.token, @@ -221,7 +239,7 @@ export class FileMentionProvider implements AutocompleteProvider { cursorCol: number, item: AutocompleteItem, prefix: string, - ): { lines: string[]; cursorLine: number; cursorCol: number } { + ): { lines: string[]; cursorLine: number; cursorCol: number; preventSubmit?: boolean } { // In bash mode a leading `/` is a path, but pi-tui's applyCompletion // mistakes it for a slash command (prefix starts with `/`, nothing before // it, no second `/`) and prepends another `/`, producing e.g. @@ -242,8 +260,24 @@ export class FileMentionProvider implements AutocompleteProvider { const currentLine = lines[cursorLine] ?? ''; const textBeforeCursor = currentLine.slice(0, cursorCol); const slashToken = extractSlashTokenAtCursor(textBeforeCursor); - const isCommandItem = this.slashCommands.some((cmd) => cmd.name === item.value); - if (slashToken !== null && slashToken.token === prefix && isCommandItem) { + const command = this.slashCommands.find((cmd) => cmd.name === item.value); + if (slashToken !== null && slashToken.token === prefix && command !== undefined) { + // Mid-prompt selection of an immediate command runs it in place: the + // `/token` is dropped from the draft, and Enter must not fall through + // to submit (pi-tui submits slash-prefix completions on confirm). + if (!slashToken.isLeading && command.immediate === true) { + this.onImmediateCommand?.(command.name); + const beforePrefix = currentLine.slice(0, slashToken.startIndex); + const afterCursor = currentLine.slice(cursorCol); + const newLines = [...lines]; + newLines[cursorLine] = `${beforePrefix}${afterCursor}`; + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length, + preventSubmit: true, + }; + } const beforePrefix = currentLine.slice(0, slashToken.startIndex); const afterCursor = currentLine.slice(cursorCol); const newLine = `${beforePrefix}/${item.value} ${afterCursor}`; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 4cf5c12b51..484efebea1 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -440,38 +440,53 @@ export class KimiTUI { // Autocomplete & Skill Commands // ========================================================================= - private getSlashCommands(): readonly KimiSlashCommand[] { - const builtins = sortSlashCommands(BUILTIN_SLASH_COMMANDS).filter((command) => + private getBuiltinSlashCommands(): readonly KimiSlashCommand[] { + return sortSlashCommands(BUILTIN_SLASH_COMMANDS).filter((command) => isExperimentalFlagEnabled(command.experimentalFlag), ); - return [...builtins, ...this.skillCommands, ...this.pluginCommands]; + } + + private getSlashCommands(): readonly KimiSlashCommand[] { + return [...this.getBuiltinSlashCommands(), ...this.skillCommands, ...this.pluginCommands]; } private setupAutocomplete(): void { - const slashCommands: SlashAutocompleteCommand[] = this.getSlashCommands().map((cmd) => { + const toAutocomplete = (cmd: KimiSlashCommand, immediate?: boolean): SlashAutocompleteCommand => { const completer = cmd.completeArgs; return { name: cmd.name, aliases: cmd.aliases, description: cmd.description, midPrompt: cmd.midPrompt, + immediate, ...(cmd.argumentHint !== undefined ? { argumentHint: cmd.argumentHint } : {}), ...(completer !== undefined ? { getArgumentCompletions: (prefix: string) => completer(prefix) } : {}), }; - }); + }; + // Built-ins offered mid-prompt run immediately on selection (they are + // toggles/pickers/info panels); skill and plugin commands insert their + // name and activate on submit with the surrounding text as args. + const autocompleteCommands: SlashAutocompleteCommand[] = [ + ...this.getBuiltinSlashCommands().map((cmd) => toAutocomplete(cmd, true)), + ...this.skillCommands.map((cmd) => toAutocomplete(cmd)), + ...this.pluginCommands.map((cmd) => toAutocomplete(cmd)), + ]; const provider = new FileMentionProvider( - slashCommands, + autocompleteCommands, this.state.appState.workDir, this.fdPath, this.state.appState.additionalDirs, () => this.state.appState.inputMode, + (name) => { + slashCommands.triggerImmediateSlashCommand(this, name); + }, ); this.state.editor.setAutocompleteProvider(provider); const argumentHints = new Map(); - for (const cmd of slashCommands) { + for (const cmd of autocompleteCommands) { if (cmd.argumentHint === undefined) continue; argumentHints.set(cmd.name, cmd.argumentHint); for (const alias of cmd.aliases ?? []) { diff --git a/apps/kimi-code/test/tui/commands/dispatch.test.ts b/apps/kimi-code/test/tui/commands/dispatch.test.ts new file mode 100644 index 0000000000..9b6a72626f --- /dev/null +++ b/apps/kimi-code/test/tui/commands/dispatch.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { triggerImmediateSlashCommand } from '#/tui/commands/dispatch'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; + +function makeHost(overrides: { streaming?: boolean } = {}) { + const host = { + state: { + appState: { + streamingPhase: overrides.streaming === true ? 'thinking' : 'idle', + isCompacting: false, + }, + }, + skillCommandMap: new Map(), + pluginCommandMap: new Map(), + track: vi.fn(), + showError: vi.fn(), + showHelpPanel: vi.fn(), + } as unknown as SlashCommandHost & { + track: ReturnType; + showError: ReturnType; + showHelpPanel: ReturnType; + }; + return host; +} + +describe('triggerImmediateSlashCommand', () => { + it('executes a builtin command in place', () => { + const host = makeHost(); + triggerImmediateSlashCommand(host, 'help'); + + expect(host.track).toHaveBeenCalledWith('input_command', { command: 'help' }); + expect(host.showHelpPanel).toHaveBeenCalledOnce(); + expect(host.showError).not.toHaveBeenCalled(); + }); + + it('reports a busy error without executing when the command is blocked', () => { + // `/undo` is idle-only, so streaming blocks it. The draft is owned by the + // editor here, so nothing is restored or cleared — only the error shows. + const host = makeHost({ streaming: true }); + triggerImmediateSlashCommand(host, 'undo'); + + expect(host.track).toHaveBeenCalledWith('input_command_invalid', { + reason: 'blocked', + command: 'undo', + }); + expect(host.showError).toHaveBeenCalledOnce(); + }); + + it('ignores names that do not resolve to a builtin', () => { + const host = makeHost(); + triggerImmediateSlashCommand(host, 'not-a-command'); + + expect(host.track).not.toHaveBeenCalled(); + expect(host.showError).not.toHaveBeenCalled(); + expect(host.showHelpPanel).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index 0ef50ed0a8..6b6eb22422 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -57,6 +57,14 @@ const LARK_CALENDAR_COMMAND = { midPrompt: true, }; +const PLAN_COMMAND = { + name: 'plan', + aliases: [], + description: 'Toggle plan mode', + midPrompt: true, + immediate: true, +}; + const HELP_COMMAND = { name: 'help', aliases: ['h'], @@ -286,6 +294,66 @@ describe('FileMentionProvider', () => { expect(applied.cursorCol).toBe('please /yolo '.length); }); + it('runs an immediate command on mid-prompt selection and drops the token', () => { + const triggered: string[] = []; + const provider = new FileMentionProvider([PLAN_COMMAND], workDir, NO_FD, [], () => 'prompt', (name) => { + triggered.push(name); + }); + const line = 'please /pl'; + const applied = provider.applyCompletion([line], 0, line.length, { + value: 'plan', + label: 'plan', + }, '/pl'); + + expect(triggered).toEqual(['plan']); + expect(applied.lines).toEqual(['please ']); + expect(applied.cursorCol).toBe('please '.length); + expect(applied.preventSubmit).toBe(true); + }); + + it('keeps text after the cursor when an immediate command drops the token', () => { + const triggered: string[] = []; + const provider = new FileMentionProvider([PLAN_COMMAND], workDir, NO_FD, [], () => 'prompt', (name) => { + triggered.push(name); + }); + const line = 'please /pl and more'; + const applied = provider.applyCompletion([line], 0, 'please /pl'.length, { + value: 'plan', + label: 'plan', + }, '/pl'); + + expect(triggered).toEqual(['plan']); + expect(applied.lines).toEqual(['please and more']); + expect(applied.cursorCol).toBe('please '.length); + expect(applied.preventSubmit).toBe(true); + }); + + it('still inserts an immediate command at the leading slash', () => { + const triggered: string[] = []; + const provider = new FileMentionProvider([PLAN_COMMAND], workDir, NO_FD, [], () => 'prompt', (name) => { + triggered.push(name); + }); + const applied = provider.applyCompletion(['/pl'], 0, 3, { value: 'plan', label: 'plan' }, '/pl'); + + expect(triggered).toEqual([]); + expect(applied.lines[0]).toBe('/plan '); + expect(applied.preventSubmit).toBeUndefined(); + }); + + it('marks immediate commands in the mid-prompt menu only', async () => { + const provider = new FileMentionProvider([PLAN_COMMAND, LARK_CALENDAR_COMMAND], workDir, NO_FD); + + const mid = await provider.getSuggestions(['please /'], 0, 'please /'.length, { signal: ctrl() }); + const midPlan = mid!.items.find((item) => item.value === 'plan'); + const midSkill = mid!.items.find((item) => item.value === 'skill:lark-calendar'); + expect(midPlan?.description).toContain('runs immediately'); + expect(midSkill?.description ?? '').not.toContain('runs immediately'); + + const leading = await provider.getSuggestions(['/'], 0, 1, { signal: ctrl() }); + const leadingPlan = leading!.items.find((item) => item.value === 'plan'); + expect(leadingPlan?.description ?? '').not.toContain('runs immediately'); + }); + it('applies add-dir absolute path completions without a double slash', () => { const provider = new FileMentionProvider([ADD_DIR_COMMAND], workDir, NO_FD); const line = '/add-dir /'; diff --git a/packages/pi-tui/src/autocomplete.ts b/packages/pi-tui/src/autocomplete.ts index 7ecf6a0a2d..f0786d4b9b 100644 --- a/packages/pi-tui/src/autocomplete.ts +++ b/packages/pi-tui/src/autocomplete.ts @@ -263,6 +263,12 @@ export interface AutocompleteProvider { lines: string[]; cursorLine: number; cursorCol: number; + /** + * When true, confirming (Enter) this completion must not fall through to + * submitting the editor text: the completion ran a side effect (e.g. + * executed a command in place) and the draft stays untouched. + */ + preventSubmit?: boolean; }; // Check if file completion should trigger for explicit Tab completion diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index e570817155..15f5cc2da7 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -796,6 +796,12 @@ export class Editor implements Component, Focusable { if (this.autocompletePrefix.startsWith("/")) { this.cancelAutocomplete(); + // The completion ran a side effect (e.g. an in-place command); + // keep the draft instead of submitting it. + if (result.preventSubmit === true) { + if (this.onChange) this.onChange(this.getText()); + return; + } // Fall through to submit } else { this.cancelAutocomplete(); diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index a47f45dd7d..d13df95452 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -2761,6 +2761,49 @@ describe("Editor component", () => { assert.strictEqual(editor.isShowingAutocomplete(), false); }); + it("does not submit when a slash completion returns preventSubmit", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Provider whose completion runs a side effect instead of editing text + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + const text = lines[0] || ""; + const prefix = text.slice(0, cursorCol); + if (prefix.startsWith("/")) { + return { + items: [{ value: "/plan", label: "plan", description: "Toggle plan mode" }], + prefix, + }; + } + return null; + }, + applyCompletion: (lines, cursorLine, cursorCol) => ({ + lines, + cursorLine, + cursorCol, + preventSubmit: true, + }), + }; + + editor.setAutocompleteProvider(mockProvider); + let submitted = ""; + editor.onSubmit = (text) => { + submitted = text; + }; + + editor.handleInput("/"); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Enter confirms the completion: the menu closes, the draft stays, and + // the input is NOT submitted (slash completions normally fall through + // to submit on confirm). + editor.handleInput("\r"); + assert.strictEqual(editor.isShowingAutocomplete(), false); + assert.strictEqual(submitted, ""); + assert.strictEqual(editor.getText(), "/"); + }); + it("applies exact typed slash-argument value on Enter even when first item is highlighted", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); From 7c6caa38e96df5ce24279098a751950d0c44e933 Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Sat, 1 Aug 2026 02:11:10 +0000 Subject: [PATCH 4/5] chore: add changesets for mid-prompt slash commands --- .changeset/mid-prompt-slash-commands.md | 5 +++++ .changeset/pi-tui-mid-prompt-completion.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/mid-prompt-slash-commands.md create mode 100644 .changeset/pi-tui-mid-prompt-completion.md diff --git a/.changeset/mid-prompt-slash-commands.md b/.changeset/mid-prompt-slash-commands.md new file mode 100644 index 0000000000..10e4e95e58 --- /dev/null +++ b/.changeset/mid-prompt-slash-commands.md @@ -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. diff --git a/.changeset/pi-tui-mid-prompt-completion.md b/.changeset/pi-tui-mid-prompt-completion.md new file mode 100644 index 0000000000..13980cf71d --- /dev/null +++ b/.changeset/pi-tui-mid-prompt-completion.md @@ -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. From ec0e4a6defe06d4df1a13f85ac4a84b319334534 Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Sat, 1 Aug 2026 02:11:10 +0000 Subject: [PATCH 5/5] docs(tui): document mid-prompt slash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Mid-prompt commands section to the slash-commands reference and mention the new completion in the interaction guide, in both locales. Drop the outdated note that a slash after leading whitespace is treated as plain text — indented slash input now opens command completion. --- docs/en/guides/interaction.md | 4 +++- docs/en/reference/slash-commands.md | 14 +++++++++++++- docs/zh/guides/interaction.md | 4 +++- docs/zh/reference/slash-commands.md | 14 +++++++++++++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index ff59fe8b23..2a3dfadc93 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -25,6 +25,8 @@ After pasting, the input box shows a placeholder that you can edit like normal t Anything starting with `/` is treated as a slash command. Typing `/` opens a completion menu that filters in real time as you keep typing; press `Esc` to close the menu. If nothing matches, the input is sent to the agent as a regular message. +Completion also works mid-prompt: typing `/` after existing text (at a word boundary) offers Skill and plugin commands, which activate on submit with the surrounding text as their input, plus quick built-in commands such as `/plan` or `/model`, which run immediately when selected. See [Mid-prompt commands](../reference/slash-commands.md#mid-prompt-commands). + Active [Agent Skills](../customization/skills.md) are automatically registered as slash commands: ordinary external Skills are invoked with `/skill:`, external sub-skills appear as dotted commands such as `/parent.child`, and built-in Skills appear directly as `/` in the slash command panel. If an external skill name does not conflict with a system slash command, you can also drop the `skill:` prefix and type `/` directly. Some commands are only available when the agent is idle — you need to press `Esc` to interrupt streaming output or context compression before using them. Mode-toggle and query commands like `/yolo`, `/plan`, `/help`, and `/btw` are always available. For the full list, see [Slash commands reference](../reference/slash-commands.md). @@ -33,7 +35,7 @@ Some commands are only available when the agent is idle — you need to press `E Type `@` to trigger file-path completion. Selecting a path inserts its relative form into your message; the agent loads the file content directly when it reads the message. File references work in both git and non-git directories, and folder suggestions end with `/` so you can keep completing paths inside them. If the fast search helper is still downloading, Kimi Code falls back to a basic filesystem scan. Hidden paths are available, but `.git` is excluded from suggestions. -> `@` references and slash commands are two separate mechanisms: `@` gives the agent file context, while `/` invokes built-in features or Skills. A `/` typed after leading whitespace is treated as normal text, not as the slash-command menu. +> `@` references and slash commands are two separate mechanisms: `@` gives the agent file context, while `/` invokes built-in features or Skills. ## Approval flow diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 602853cfdc..3df1285695 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -1,6 +1,6 @@ # Slash Commands -Slash commands are built-in control commands provided by Kimi Code CLI in the interactive TUI, covering account configuration, session management, mode switching, information queries, and more. Type `/` in the input box to trigger command completion — the candidate list filters in real time as you continue typing; command aliases are also matched. +Slash commands are built-in control commands provided by Kimi Code CLI in the interactive TUI, covering account configuration, session management, mode switching, information queries, and more. Type `/` in the input box to trigger command completion — the candidate list filters in real time as you continue typing; command aliases are also matched. Completion also works while you are writing a prompt; see [Mid-prompt commands](#mid-prompt-commands). After typing the full command name, press `Enter` to execute. If the `/`-prefixed input does not match any built-in or Skill command, it is sent to the Agent as a regular message. @@ -8,6 +8,16 @@ After typing the full command name, press `Enter` to execute. If the `/`-prefixe Some commands are only available in the idle state. Executing these commands while a session is streaming output or compacting context will be blocked — press `Esc` or `Ctrl-C` to interrupt first. The "Always available" column in the tables below indicates commands that are also available during streaming. ::: +## Mid-prompt commands + +Commands also work while you are writing a prompt: typing `/` after existing text (at a word boundary) opens completion for Skill commands, plugin commands, and a set of quick built-in commands. Other built-ins stay available only at the start of the input. + +Skill and plugin commands selected mid-prompt are inserted into the text and activate when you submit — the surrounding words become the command's input. For example, sending `check this diff /skill:code-style with care` activates the `code-style` Skill with `check this diff with care` as its input. Only exact command names activate, so path-like text such as `/tmp` stays plain text. + +Quick built-in commands instead run immediately when selected — the menu labels them "runs immediately", the typed `/…` fragment is removed, and the rest of the draft stays in place. The set covers mode toggles and panels: `/plan`, `/yolo`, `/auto`, `/permission`, `/model`, `/effort`, `/usage`, `/status`, `/settings`, `/help`, `/tasks`, `/mcp`, `/plugins`, and `/provider`. + +Activating a Skill or plugin command while the session is streaming output or compacting context is blocked, and the draft is restored so nothing is lost. + ## Account & Configuration | Command | Alias | Description | Always available | @@ -153,6 +163,8 @@ For convenience, external Skill commands also support a shorthand form that omit Built-in Skills shipped with Kimi Code CLI appear directly as `/` in the slash command panel. For example, `/mcp-config` helps configure MCP servers and handle MCP OAuth login, and `/custom-theme [extra text]` invokes the custom-theme workflow to create or edit a TUI theme. +Skill commands can also be embedded anywhere in a prompt — they activate on submit with the surrounding text as input. See [Mid-prompt commands](#mid-prompt-commands). + ::: info All Skill commands are only available in the idle state. `flow`-type Skills are also exposed via `/skill:` — there is no separate `/flow:` namespace. ::: diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index 628525e9de..0821934d3e 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -25,6 +25,8 @@ Kimi Code CLI 支持在输入框中直接粘贴图片和视频,让 AI 结合 以 `/` 开头的内容会被识别为斜杠命令。输入 `/` 后弹出补全菜单,随后续字符实时过滤;按 `Esc` 关闭菜单,匹配失败时内容会作为普通消息发送给 Agent。 +补全在编写提示词的过程中同样可用:在已有文本之后(单词边界处)输入 `/`,候选包含 Skill 和 plugin 命令(提交时激活,周围文本作为其输入),以及 `/plan`、`/model` 等快捷内置命令(选中时立即执行)。详见[提示词中的斜杠命令](../reference/slash-commands.md#提示词中的斜杠命令)。 + 已激活的 [Agent Skills](../customization/skills.md) 会自动注册为斜杠命令:普通外部 Skill 以 `/skill:` 调用,外部子 Skill 以 `/parent.child` 这样的点分命令显示,内置 Skill 直接以 `/` 出现在斜杠命令面板中;若外部 Skill 名称与系统斜杠命令不冲突,也可以省略 `skill:` 前缀直接输入 `/`。 部分命令仅在 Agent 空闲时可用,流式输出或上下文压缩期间需先按 `Esc` 中断。`/yolo`、`/plan`、`/help`、`/btw` 等模式切换和查询类命令则始终可用。全部命令说明见[斜杠命令参考](../reference/slash-commands.md)。 @@ -33,7 +35,7 @@ Kimi Code CLI 支持在输入框中直接粘贴图片和视频,让 AI 结合 键入 `@` 触发文件路径补全,选中后在输入中插入相对路径,Agent 读取时会直接加载该文件内容。文件引用在 git 和非 git 目录都可用;文件夹候选会以 `/` 结尾,方便继续补全其下路径。如果快速搜索辅助工具仍在下载,Kimi Code 会先回退到基础的文件系统扫描。隐藏路径也可补全,但 `.git` 会从候选中排除。 -> `@` 引用和斜杠命令是两套不同的机制:`@` 向 Agent 提供文件上下文,`/` 调用内置功能或 Skill。前面有空白字符时输入 `/` 会按普通文本处理,不会打开斜杠命令菜单。 +> `@` 引用和斜杠命令是两套不同的机制:`@` 向 Agent 提供文件上下文,`/` 调用内置功能或 Skill。 ## 审批流程 diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 199ca0a224..01d38c4993 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -1,6 +1,6 @@ # 斜杠命令 -斜杠命令是 Kimi Code CLI 在交互式 TUI 中提供的内置控制命令,涵盖账号配置、会话管理、模式切换、信息查询等操作。在输入框中输入 `/` 即可触发命令补全,候选列表随后续字符实时过滤;命令的别名也会一并参与匹配。 +斜杠命令是 Kimi Code CLI 在交互式 TUI 中提供的内置控制命令,涵盖账号配置、会话管理、模式切换、信息查询等操作。在输入框中输入 `/` 即可触发命令补全,候选列表随后续字符实时过滤;命令的别名也会一并参与匹配。编写提示词的过程中同样可以使用补全,详见[提示词中的斜杠命令](#提示词中的斜杠命令)。 输入完整命令名后按 `Enter` 执行。如果输入的 `/` 开头内容不匹配任何内置或 Skill 命令,则按普通消息发送给 Agent。 @@ -8,6 +8,16 @@ 部分命令仅在空闲(idle)状态下可用。会话正在流式输出或压缩上下文时执行这些命令会被拦截,需先按 `Esc` 或 `Ctrl-C` 中断。下表「随时可用」列标注了流式输出期间也可用的命令。 ::: +## 提示词中的斜杠命令 + +编写提示词的过程中同样可以使用命令:在已有文本之后(单词边界处)输入 `/` 会打开补全,候选包含 Skill 命令、plugin 命令和一组快捷内置命令;其余内置命令仍仅在输入开头可用。 + +在提示词中选中 Skill 或 plugin 命令会将命令名插入文本,并在提交时激活——命令前后的文本会成为命令的输入。例如发送 `check this diff /skill:code-style with care` 会激活 `code-style` Skill,并将 `check this diff with care` 作为它的输入。只有精确的命令名才会激活,`/tmp` 这类路径文本会保持为普通文本。 + +快捷内置命令则在选中时立即执行——菜单中以 "runs immediately" 标注,已输入的 `/…` 片段会被移除,草稿的其余部分保持不动。这组命令涵盖模式开关和面板:`/plan`、`/yolo`、`/auto`、`/permission`、`/model`、`/effort`、`/usage`、`/status`、`/settings`、`/help`、`/tasks`、`/mcp`、`/plugins` 和 `/provider`。 + +会话流式输出或压缩上下文期间激活 Skill 或 plugin 命令会被拦截,草稿会被恢复,不会丢失。 + ## 账号与配置 | 命令 | 别名 | 说明 | 随时可用 | @@ -151,6 +161,8 @@ Kimi Code CLI 随包内置了一组 Skill,直接以 `/` 形式出现在 Kimi Code CLI 随包内置的 Skill 会直接以 `/` 形式出现在斜杠命令面板中。例如,`/mcp-config` 用于配置 MCP server 和处理 MCP OAuth 登录,`/custom-theme [附加文本]` 用于进入自定义主题流程,创建或编辑 TUI 主题。 +Skill 命令也可以嵌入提示词的任意位置——提交时激活,周围文本作为其输入。详见[提示词中的斜杠命令](#提示词中的斜杠命令)。 + ::: info 说明 所有 Skill 命令仅在空闲状态下可用。`flow` 类型的 Skill 同样通过 `/skill:` 暴露,没有独立的 `/flow:` 命名空间。 :::