Skip to content
Merged
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/inline-multi-skill-tui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Activate multiple skills in a single prompt. Type `/` after whitespace to insert a skill token; all referenced skills run with the prompt as one turn (and undo as one unit).
5 changes: 5 additions & 0 deletions .changeset/inline-slash-trigger-pi-tui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/pi-tui": patch
---

Add an opt-in inline slash autocomplete trigger that fires after whitespace mid-input and at the start of subsequent editor lines.
10 changes: 9 additions & 1 deletion apps/kimi-code/src/tui/commands/btw.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui';
import { formatErrorMessage } from '../utils/event-payload';
import { extractInlineSkillActivations } from '../utils/inline-skill-tokens';
import type { SlashCommandHost } from './dispatch';

export async function handleBtwCommand(host: SlashCommandHost, args: string): Promise<void> {
Expand All @@ -13,7 +14,14 @@ export async function handleBtwCommand(host: SlashCommandHost, args: string): Pr

try {
const agentId = await session.startBtw();
host.btwPanelController.open(agentId, prompt);
const activations = host.engineV2
? extractInlineSkillActivations(prompt, host.skillCommandMap, { includeLeading: true })
: [];
host.btwPanelController.open(
agentId,
prompt,
activations.length > 0 ? activations : undefined,
);
} catch (error) {
host.showError(`Failed to start /btw: ${formatErrorMessage(error)}`);
}
Expand Down
78 changes: 78 additions & 0 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,16 @@ import type { ResolvedTheme } from '../theme/colors';
import type { TUIState } from '../tui-state';
import type {
AppState,
InlineSkillActivation,
LoginProgressSpinnerHandle,
QueuedMessage,
TranscriptEntry,
} from '../types';
import { formatErrorMessage } from '../utils/event-payload';
import {
extractInlineSkillActivations,
findInlineSkillTokens,
} from '../utils/inline-skill-tokens';
import { handleLoginCommand, handleLogoutCommand } from './auth';
import { handleBtwCommand } from './btw';
import { handleCopyCommand } from './copy';
Expand Down Expand Up @@ -191,6 +196,12 @@ export interface SlashCommandHost {
createNewSession(): Promise<void>;
showSessionPicker(): Promise<void>;
sendNormalUserInput(text: string): void;
/**
* Submit a prompt that explicitly activates one or more skills inline
* (v2 engine only): all activations ride the same submission as the prompt
* and launch as a single turn.
*/
sendInlineSkillUserInput(text: string, activations: readonly InlineSkillActivation[]): Promise<void>;
sendSkillActivation(session: Session, skillName: string, skillArgs: string): void;
activatePluginCommand(
session: Session,
Expand All @@ -214,12 +225,79 @@ export interface SlashCommandHost {

export function dispatchInput(host: SlashCommandHost, text: string): void {
if (parseSlashInput(text) !== null) {
// A leading skill command combined with further inline skill tokens
// (`/skill:a args /skill:b`) is one grouped submission on the v2 engine.
if (host.engineV2 && dispatchInlineSkillCombo(host, text)) {
return;
}
void executeSlashCommand(host, text);
return;
}
// Inline skill tokens anywhere in a plain prompt (v2 engine only); on the
// legacy engine they keep their plain-text meaning.
if (host.engineV2) {
const activations = extractInlineSkillActivations(text, host.skillCommandMap);
if (activations.length > 0) {
void host.sendInlineSkillUserInput(text, activations);
return;
}
}
host.sendNormalUserInput(text);
}

/**
* Handle a leading-slash input that may be a bundled submission. Returns true
* when the input was claimed, false when it should fall through to the
* regular single-skill slash path.
*
* Bundle rule: two or more known skill tokens with the first one leading the
* input make the whole input one bundled prompt in which every token
* activates with NO args — the mention is the whole interface, and args stay
* a standalone-activation concept (`/skill:a some args` with no other tokens
* keeps its single-skill path). Tokenization is whitespace-generic, so
* space- and newline-separated bundles behave identically. A recognized
* builtin or plugin command always keeps its own path, no matter how many
* skill tokens its arguments mention.
*/
function dispatchInlineSkillCombo(host: SlashCommandHost, text: string): boolean {
// The intent is parsed without the busy flags on purpose: submissions
// through sendInlineSkillUserInput queue while busy — only genuine
// single-skill commands reject.
const intent = resolveSlashCommandInput({
input: text,
skillCommandMap: host.skillCommandMap,
pluginCommandMap: host.pluginCommandMap,
isStreaming: false,
isCompacting: false,
});
if (intent.kind !== 'skill' && intent.kind !== 'message') return false;

const tokens = findInlineSkillTokens(text, {
isKnownSkill: (commandName) =>
host.skillCommandMap.has(commandName) || host.skillCommandMap.has(`skill:${commandName}`),
includeLeading: true,
});
// The 'message' kind joins the bundle rule because parseSlashInput only
// splits on a literal space: a newline after a leading skill resolves to
// 'message' instead of 'skill', and must not silently drop the leading
// activation.
if (tokens.length >= 2 && tokens[0]!.start === 0) {
const activations = extractInlineSkillActivations(text, host.skillCommandMap, {
includeLeading: true,
});
void host.sendInlineSkillUserInput(text, activations);
return true;
}

// An unrecognized leading slash token makes the whole input a plain
// message; scan it for inline skills like any other plain prompt.
if (intent.kind !== 'message') return false;
const activations = extractInlineSkillActivations(text, host.skillCommandMap);
if (activations.length === 0) return false;
void host.sendInlineSkillUserInput(text, activations);
return true;
}

async function executeSlashCommand(host: SlashCommandHost, input: string): Promise<void> {
const parsedCommand = parseSlashInput(input);
const intent = resolveSlashCommandInput({
Expand Down
61 changes: 51 additions & 10 deletions apps/kimi-code/src/tui/commands/undo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ async function undoByCount(host: SlashCommandHost, count: number): Promise<boole
showUndoLimitStatus(host, 'Nothing to undo.');
return false;
}
// When the anchor is a bundled prompt, its skill activation cards sit
// before it (contiguous, marked at submission/replay time) and are removed
// together with it.

try {
await session.undoHistory(count);
Expand All @@ -108,15 +111,43 @@ async function undoByCount(host: SlashCommandHost, count: number): Promise<boole
const children = host.state.transcriptContainer.children;
const lastUserComponentIndex = findUndoAnchorComponentIndex(children, count);
if (lastUserComponentIndex !== undefined) {
// Structural removal only: the container's ref-checked render cache
// detects the child-list change; no tree-wide invalidate needed.
removeUndoContextComponents(children, lastUserComponentIndex);
// A hook result may interleave between the bundle's cards and its prompt
// and survives undo in the engine, so it is skipped (kept) while the
// cards around it are removed. Only the contiguous marked run belongs to
// this submission: a standalone `/skill` card is unmarked and never
// swept. Structural removal only: the container's ref-checked render
// cache detects the child-list change; no tree-wide invalidate needed.
const groupChildIndices = new Set<number>();
for (let i = lastUserComponentIndex - 1; i >= 0; i--) {
const entry = getTranscriptComponentEntry(children[i]!);
if (entry?.bundledWithPrompt === true) {
groupChildIndices.add(i);
continue;
}
if (entry?.hookResult === true) continue;
break;
}
removeUndoContextComponents(children, lastUserComponentIndex, groupChildIndices);
}

const preservedEntries = entries.slice(lastUserIndex).filter(
(entry) => !isUndoContextEntry(entry),
const groupEntryIndices = new Set<number>();
for (let i = lastUserIndex - 1; i >= 0; i--) {
const prev = entries[i];
if (prev?.bundledWithPrompt === true) {
groupEntryIndices.add(i);
continue;
}
if (prev?.hookResult === true) continue;
break;
}
const preservedEntries = entries.filter(
(entry, index) =>
!(
(index >= lastUserIndex || groupEntryIndices.has(index)) &&
isUndoContextEntry(entry)
),
);
entries.splice(lastUserIndex, entries.length - lastUserIndex, ...preservedEntries);
entries.splice(0, entries.length, ...preservedEntries);

if (entries.length === 0) {
renderWelcome(host);
Expand Down Expand Up @@ -393,7 +424,9 @@ function undoLimitFromError(
function isUndoAnchorEntry(entry: TranscriptEntry): boolean {
return (
entry.kind === 'user' ||
(entry.kind === 'skill_activation' && entry.skillTrigger === 'user-slash') ||
(entry.kind === 'skill_activation' &&
entry.skillTrigger === 'user-slash' &&
entry.bundledWithPrompt !== true) ||
entry.kind === 'plugin_command'
);
}
Expand Down Expand Up @@ -449,19 +482,27 @@ function findUndoAnchorComponentIndex(
function removeUndoContextComponents(
children: Component[],
startIndex: number,
additionalIndices: ReadonlySet<number>,
): void {
for (let i = children.length - 1; i >= startIndex; i--) {
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
if (child !== undefined && isUndoContextComponent(child)) {
if (
child !== undefined &&
(i >= startIndex || additionalIndices.has(i)) &&
isUndoContextComponent(child)
) {
children.splice(i, 1);
}
}
}

function isUndoAnchorComponent(child: Component): boolean {
const entry = getTranscriptComponentEntry(child);
return (
child instanceof UserMessageComponent ||
(child instanceof SkillActivationComponent && child.trigger === 'user-slash') ||
(child instanceof SkillActivationComponent &&
child.trigger === 'user-slash' &&
entry?.bundledWithPrompt !== true) ||
child instanceof PluginCommandComponent
);
}
Expand Down
93 changes: 80 additions & 13 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { createEditorTheme } from '#/tui/theme/pi-tui-theme';
import { printableChar } from '#/tui/utils/printable-key';

import { extractAtPrefix } from './file-mention-provider';
import { findInlineSkillTokens } from '../../utils/inline-skill-tokens';
import { WrappingSelectList } from './wrapping-select-list';

// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences
Expand Down Expand Up @@ -162,19 +163,28 @@ export class CustomEditor extends Editor {
private consumingPaste = false;
private consumeBuffer = '';
private argumentHints: ReadonlyMap<string, string> = new Map();
private skillCommandNames: ReadonlySet<string> = new Set();

setArgumentHints(hints: ReadonlyMap<string, string>): void {
this.argumentHints = hints;
}

setSkillCommandNames(names: ReadonlySet<string>): void {
this.skillCommandNames = names;
}

constructor(tui: TUI, options: CustomEditorOptions = {}) {
// paddingX: 4 reserves column 0 for the left vertical border (│),
// column 1 as a single space between border and prompt, column 2 for
// the `>` prompt token, and column 3 as the space between prompt and
// content. The right side mirrors with 3 padding columns and the right
// border at the last column.
const theme = createEditorTheme();
super(tui, theme, { paddingX: 4, disablePasteBurst: options.disablePasteBurst });
super(tui, theme, {
paddingX: 4,
disablePasteBurst: options.disablePasteBurst,
inlineSlashTrigger: true,
});

// pi-tui keeps `createAutocompleteList` private; shadow it with an
// instance property so slash command menus render descriptions wrapped
Expand Down Expand Up @@ -264,16 +274,42 @@ export class CustomEditor extends Editor {
const firstContentIdx = 1;
const isBash = this.inputMode === 'bash';
const text = this.getText().trimStart();
if (text.startsWith('/') && !isBash) {
// Paint only the FIRST editor content line; multi-line slash commands
// are not a thing in practice.
if (!isBash) {
// Paint the leading slash command on the first content line only, then
// inline skill tokens on every content line (multi-line prompts can
// reference skills anywhere).
const original = lines[firstContentIdx];
if (original !== undefined) {
const highlighted = highlightFirstSlashToken(original, 'primary');
if (highlighted !== undefined) {
let highlighted = original;
let leadingRange: { start: number; end: number } | null = null;
if (text.startsWith('/')) {
leadingRange = leadingSlashTokenRange(stripSgr(original));
const leading = highlightFirstSlashToken(original, 'primary');
if (leading !== undefined) {
highlighted = leading;
}
}
const inline = highlightInlineSkillTokens(
highlighted,
this.skillCommandNames,
leadingRange,
'primary',
);
if (inline !== undefined) {
highlighted = inline;
}
if (highlighted !== original) {
lines[firstContentIdx] = highlighted;
}
}
for (let i = firstContentIdx + 1; i < lines.length - 1; i++) {
const original = lines[i];
if (original === undefined) continue;
const inline = highlightInlineSkillTokens(original, this.skillCommandNames, null, 'primary');
if (inline !== undefined) {
lines[i] = inline;
}
}
}
const hint = this.computeArgumentHint();
if (hint !== undefined) {
Expand Down Expand Up @@ -571,12 +607,22 @@ export class CustomEditor extends Editor {
*/
export function highlightFirstSlashToken(line: string, token: 'primary'): string | undefined {
const visible = stripSgr(line);
const range = leadingSlashTokenRange(visible);
if (range === null) return undefined;
const ranges = [range];
if (visible.slice(range.start, range.end) === '/goal') {
ranges.push(...goalCommandPathRanges(visible, range.end));
}
return highlightVisibleRanges(line, ranges, token);
}

function leadingSlashTokenRange(visible: string): { start: number; end: number } | null {
const slashIdx = visible.indexOf('/');
if (slashIdx < 0) return undefined;
if (slashIdx < 0) return null;
// Guard: only paint when `/` is the first non-whitespace character
// on the line (avoids colouring a mid-sentence slash).
for (let i = 0; i < slashIdx; i++) {
if (visible[i] !== ' ' && visible[i] !== '\t') return undefined;
if (visible[i] !== ' ' && visible[i] !== '\t') return null;
}
// Token ends at the next whitespace (or the visible end).
let endVisible = slashIdx + 1;
Expand All @@ -586,11 +632,32 @@ export function highlightFirstSlashToken(line: string, token: 'primary'): string
endVisible++;
}
const visibleToken = visible.slice(slashIdx, endVisible);
if (visibleToken.slice(1).includes('/')) return undefined;
const ranges = [{ start: slashIdx, end: endVisible }];
if (visibleToken === '/goal') {
ranges.push(...goalCommandPathRanges(visible, endVisible));
}
if (visibleToken.slice(1).includes('/')) return null;
return { start: slashIdx, end: endVisible };
}

/**
* Highlight inline skill tokens in `line`. A token is painted only when it
* names a known skill; `exclude` (the already-painted leading slash command
* range) is skipped so the leading command is not painted twice.
*/
export function highlightInlineSkillTokens(
line: string,
skillCommandNames: ReadonlySet<string>,
exclude: { start: number; end: number } | null,
token: 'primary',
): string | undefined {
if (skillCommandNames.size === 0) return undefined;
const visible = stripSgr(line);
const ranges = findInlineSkillTokens(visible, {
isKnownSkill: (commandName) =>
skillCommandNames.has(commandName) || skillCommandNames.has(`skill:${commandName}`),
includeLeading: true,
}).filter(
(inlineToken) =>
exclude === null || inlineToken.start >= exclude.end || inlineToken.end <= exclude.start,
);
if (ranges.length === 0) return undefined;
return highlightVisibleRanges(line, ranges, token);
}

Expand Down
Loading
Loading