feat(kimi-code): recognize multiple inline skill activations in one prompt (#2935)
Some checks are pending
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-vscode-legacy (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

* feat(kimi-code): recognize multiple inline skill activations in one prompt

Inline /skill: tokens are recognized anywhere in the prompt (after
whitespace, including on following lines) with completion, highlighting,
and de-duplication. Submitting goes through session.promptWithSkills, so
the engine bundles every activation into the prompt's own user message —
one turn, one undo anchor. Replay rebuilds the per-skill cards from the
prompt origin's skillActivations and shows only the caller's own parts in
the user bubble; undo removes the prompt together with its marked bundle
cards; hook results ahead of a bundle are projected inside its window.
Enter accepts an inline completion without submitting (pi-tui
inlineSlashTrigger), and cache-hint plus /btw pass activations through.

* fix(kimi-code): harden bundled skill submissions against review findings

- Mark bundle cards by entry id, not by index into a captured array: the
  transcript window trim may replace the entries array mid-call.
- The replay turn limiter no longer cuts between a bundled prompt and the
  hook results recorded immediately before it; the oldest visible bundle
  keeps its hook context.
- A leading-combo bundle (/skill:a args /skill:b) now queues while busy
  like any other inline-skill prompt, instead of being rejected by the
  single-skill slash gate.

* fix(kimi-code): fetch one extra replay turn on resume

The SDK trims the replay to the requested limit before returning it, so a
trim landing between a bundled prompt and its preceding hook results would
make them unrecoverable to the TUI-side limiter. Resume now fetches one
extra turn of margin; preserveBundleHookResults does the final cut without
losing the hook context.

* fix(pi-tui): retrigger inline slash completion as the token grows

When the terminal delivers `/rev` in one stdin chunk, the slash starts an
autocomplete request but the following letters arrived to a null
autocomplete state and — unlike a leading slash command — matched no
retrigger context, so the stale request was discarded and the menu never
appeared. Typing token characters inside an inline slash token now
retriggers completion (with a regression test). Also aligns the startup
resume tests with REPLAY_FETCH_TURN_LIMIT.

* fix(pi-tui): retrigger inline completion on later lines too

isSlashMenuAllowed confines the slash-command menu to the first line, so
reusing it for the inline-slash retrigger context silently disabled
retriggering on every later line — the bare-slash request went stale and
the menu never appeared. The inline context now covers a token-opening
slash on subsequent lines as well (with a regression test).

* fix(kimi-code): activate leading skill tokens in /btw and repeated-token combos

- /btw's initial prompt lives entirely in the slash arguments, so a skill
  token there sits at position 0; scan it with includeLeading so
  `/btw /skill:review …` actually activates the skill.
- Combo-ness is now decided by the raw inline token count rather than the
  deduplicated activation count, so `/skill:review check /skill:review`
  submits as a bundled prompt instead of falling through to the
  single-skill path with the repeated token swallowed into the args.

* fix(kimi-code): rewrite media placeholders in leading combo arguments

A leading combo's first activation carries the raw slash arguments, so a
pasted media placeholder in them reached the engine unresolved — unlike
the standalone sendSkillActivation path, which rewrites placeholders into
escape-proof plain-text file references first. sendInlineSkillUserInput
now rewrites any arg-carrying activation the same way (covering the busy
queue and /btw intercept paths too), while the media themselves continue
to ride the prompt as extracted parts.

* refactor(kimi-code): skill mentions never carry args in bundled prompts

Align bundled submissions with the mention model: two or more skill
tokens anywhere in the input (the leading one included) make one bundled
prompt in which every token activates by name only, and args stay a
standalone /skill:<name> args concept. This removes the leading combo's
command+args parsing, so the first skill's arguments can no longer leak
the next token (displayed as a duplicated prompt under its card), media
placeholders no longer need arg rewriting, and newline-separated bundles
behave exactly like space-separated ones (parseSlashInput's literal-space
separator no longer decides bundle-ness).

* fix(kimi-code): recognized builtin and plugin commands outrank the bundle rule

The no-args bundle rule claimed any input with two or more skill tokens
before checking what led it, so `/btw check /skill:a /skill:b` was
submitted to the main agent as a bundled prompt instead of opening the
side panel. The intent is now resolved first: builtin and plugin commands
always keep their own path regardless of how many skill tokens their
arguments mention, while skill-led and newline-led inputs still bundle as
before.

* fix(pi-tui): retrigger inline completion on colons and register the local divergences

External skill tokens are shaped /skill:<name>, but the inline-slash
retrigger character classes excluded ':' — typing the colon launched no
replacement request, the bare-slash request went stale, and the menu never
appeared for prefixed skill names. Colons now retrigger completion like
other token characters (with a regression test). Also registers the
inlineSlashTrigger and autocomplete-data divergences in the package's
re-vendor protection list.

* fix(kimi-code): preserve FIFO behind unsteerable bundles and reach indented inline completion

- Ctrl-S steering now stops at the first inline-skill bundle: a later
  queued message (or the editor draft) no longer jumps ahead of the
  unsteerable bundle into the running turn, so the conversational order
  survives steering.
- The leading-whitespace slash-path suppression now yields to the inline
  skill context first, so an indented token (`  /skill:rev`) completes
  like its column-0 equivalent instead of being suppressed as a path.
This commit is contained in:
Luyu Cheng 2026-08-17 00:09:18 +08:00 committed by GitHub
parent 61591bce09
commit 44a6c70e66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2411 additions and 100 deletions

View file

@ -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).

View file

@ -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.

View file

@ -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> {
@ -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)}`);
}

View file

@ -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';
@ -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,
@ -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({

View file

@ -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);
@ -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);
@ -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'
);
}
@ -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
);
}

View file

@ -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
@ -162,11 +163,16 @@ 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
@ -174,7 +180,11 @@ export class CustomEditor extends Editor {
// 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
@ -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) {
@ -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;
@ -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);
}

View file

@ -10,6 +10,8 @@ import {
type SlashCommand,
} from '@moonshot-ai/pi-tui';
import { findInlineSkillTokens } from '../../utils/inline-skill-tokens';
const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']);
const MAX_FALLBACK_SCAN = 2000;
const MAX_FALLBACK_SUGGESTIONS = 50;
@ -45,6 +47,7 @@ export class FileMentionProvider implements AutocompleteProvider {
private readonly fdPath: string | null,
additionalDirs: readonly string[] = [],
private readonly getInputMode: () => 'prompt' | 'bash' = () => 'prompt',
private readonly skillCommandNames?: ReadonlySet<string>,
) {
this.additionalDirs = additionalDirs.map((dir) => normalizePath(resolve(workDir, dir)));
// Build an expanded list that includes alias entries so that
@ -100,11 +103,34 @@ export class FileMentionProvider implements AutocompleteProvider {
}
}
if (shouldSuppressLeadingWhitespaceSlashPath(textBeforeCursor, options.force)) {
// An inline skill token the cursor is still on stays eligible for skill
// selection even when the input begins with a slash command and has text
// after the cursor — the argument suppression below guards the command's
// own arguments, not an inline skill the user inserts mid-text. Computed
// before the leading-whitespace suppression: an indented inline token
// (` /skill:rev`) is a skill reference, not a path to suppress.
const inlineSkillPrefix = extractInlineSkillPrefix(textBeforeCursor, cursorLine);
if (
inlineSkillPrefix === null &&
shouldSuppressLeadingWhitespaceSlashPath(textBeforeCursor, options.force)
) {
return null;
}
// A `/` at the start of a later line is an inline skill reference, not a
// start-of-message slash command: offer the skill-only picker there.
if (
cursorLine > 0 &&
textBeforeCursor.trim() === '/' &&
this.getInputMode() !== 'bash' &&
options.force !== true
) {
return this.getInlineSkillSuggestions('/');
}
if (
inlineSkillPrefix === null &&
shouldSuppressSlashArgumentCompletion(
textBeforeCursor,
currentLine.slice(cursorCol),
@ -115,8 +141,9 @@ 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('/')) {
// searchable and visible in the label. Only the first line can host a
// start-of-message slash command; later lines are inline skill territory.
if (!options.force && cursorLine === 0 && textBeforeCursor.startsWith('/')) {
const spaceIndex = textBeforeCursor.indexOf(' ');
if (spaceIndex === -1) {
const tokens = textBeforeCursor
@ -185,6 +212,20 @@ export class FileMentionProvider implements AutocompleteProvider {
}
}
// Inline skill selection: `/` after whitespace mid-input in prompt mode.
// Runs after slash-command argument handling so known commands such as
// `/add-dir /` keep their own argument completions.
if (
inlineSkillPrefix !== null &&
this.getInputMode() !== 'bash' &&
options.force !== true
) {
// A mid-input `/` in prompt mode is only meaningful as skill selection;
// when no skills are registered, suppress path completion instead of
// offering root directories.
return this.getInlineSkillSuggestions(inlineSkillPrefix);
}
try {
const inner = await this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
if (inner === null || this.getInputMode() !== 'bash') {
@ -199,6 +240,37 @@ export class FileMentionProvider implements AutocompleteProvider {
}
}
private getInlineSkillSuggestions(prefix: string): AutocompleteSuggestions | null {
if (this.skillCommandNames === undefined || this.skillCommandNames.size === 0) return null;
const names = this.skillCommandNames;
const tokens = prefix
.slice(1)
.trim()
.split(/\s+/)
.filter((t) => t.length > 0);
const matches: Array<{ cmd: SlashAutocompleteCommand; score: number }> = [];
for (const cmd of this.slashCommands) {
if (!names.has(cmd.name)) continue;
const score = scoreTokens(tokens, cmd.name);
if (score !== null) {
matches.push({ cmd, score });
}
}
matches.sort((a, b) => a.score - b.score);
if (matches.length === 0) return null;
return {
items: matches.map((m) => ({
value: m.cmd.name,
label: m.cmd.name,
description: formatSlashCommandDescription(m.cmd),
data: { inlineSkill: true },
})),
prefix,
};
}
applyCompletion(
lines: string[],
cursorLine: number,
@ -206,6 +278,30 @@ export class FileMentionProvider implements AutocompleteProvider {
item: AutocompleteItem,
prefix: string,
): { lines: string[]; cursorLine: number; cursorCol: number } {
// Inline skill selection mid-input: pi-tui's default applyCompletion
// treats mid-line slash prefixes as file paths and drops the `/`. Preserve
// the slash and add a trailing space so the completed token stays a valid
// skill reference (e.g. `hello /rev` -> `hello /skill:review `).
if (
item.data?.['inlineSkill'] === true &&
this.getInputMode() !== 'bash' &&
prefix.startsWith('/')
) {
const currentLine = lines[cursorLine] ?? '';
const textBeforeCursor = currentLine.slice(0, cursorCol);
if (extractInlineSkillPrefix(textBeforeCursor, cursorLine) === prefix) {
const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
const afterCursor = currentLine.slice(cursorCol);
const newLines = [...lines];
newLines[cursorLine] = `${beforePrefix}/${item.value} ${afterCursor}`;
return {
lines: newLines,
cursorLine,
// +2 for the preserved "/" and the appended " ".
cursorCol: beforePrefix.length + item.value.length + 2,
};
}
}
// 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.
@ -219,6 +315,32 @@ export class FileMentionProvider implements AutocompleteProvider {
}
}
/**
* Extract the inline skill prefix (e.g. `/rev`) from `text` when the cursor is
* positioned after a `/` that is preceded by whitespace and not part of the
* leading slash-command area. Returns `null` when the context is not an inline
* skill trigger.
*
* On lines after the first, a `/` at the start of the line always begins an
* inline skill prefix including the partially typed `/rev` so the picker
* stays in skill-only mode while the token is completed.
*/
export function extractInlineSkillPrefix(text: string, cursorLine: number = 0): string | null {
if (cursorLine > 0) {
const trimmedStart = text.trimStart();
const match = /^\/[^\s/]*$/.exec(trimmedStart);
if (match !== null) return match[0];
}
// findInlineSkillTokens skips the leading slash-command area, so a line such
// as `/skill:review args /` still yields the trailing `/` token.
const tokens = findInlineSkillTokens(text, {
isKnownSkill: () => true,
allowEmpty: true,
});
const token = tokens.findLast((t) => t.end === text.length);
return token === undefined ? null : text.slice(token.start);
}
export function extractAtPrefix(text: string): string | null {
let tokenStart = 0;
for (let i = text.length - 1; i >= 0; i -= 1) {

View file

@ -9,6 +9,7 @@ import chalk from 'chalk';
import { THINKING_PREVIEW_LINES } from '../../constant/rendering';
import { currentTheme } from '../../theme';
import type { InlineSkillActivation } from '../../types';
import { createMarkdownOptions } from '../../utils/markdown-options';
type BtwPanelPhase = 'running' | 'done' | 'failed';
@ -31,7 +32,10 @@ interface BtwBodyRender {
export interface BtwPanelOptions {
readonly markdownTheme: MarkdownTheme;
readonly canUseScrollKeys: () => boolean;
readonly onPrompt: (prompt: string) => void;
readonly onPrompt: (
prompt: string,
inlineSkillActivations?: readonly InlineSkillActivation[],
) => void;
readonly terminalRows: () => number;
}
@ -45,7 +49,7 @@ export class BtwPanelComponent implements Component {
constructor(private readonly options: BtwPanelOptions) {}
submit(prompt: string): void {
submit(prompt: string, inlineSkillActivations?: readonly InlineSkillActivation[]): void {
const normalized = prompt.trim();
if (normalized.length === 0 || this.isRunning()) return;
this.followTail = true;
@ -57,7 +61,7 @@ export class BtwPanelComponent implements Component {
thinking: '',
phase: 'running',
});
this.options.onPrompt(normalized);
this.options.onPrompt(normalized, inlineSkillActivations);
}
addTransientNotice(message: string): void {

View file

@ -11,6 +11,7 @@ import { BtwPanelComponent } from '../components/panes/btw-panel';
import { formatErrorMessage } from '../utils/event-payload';
import { formatHookResultPlain } from '../utils/hook-result-format';
import { createMarkdownTheme } from '../theme/pi-tui-theme';
import type { InlineSkillActivation } from '../types';
import type { TUIState } from '../tui-state';
const BTW_BUSY_NOTICE = 'Wait for /btw to finish before sending another question.';
@ -34,20 +35,24 @@ export class BtwPanelController {
constructor(private readonly host: BtwPanelHost) {}
open(agentId: string, initialPrompt: string): void {
open(
agentId: string,
initialPrompt: string,
inlineSkillActivations?: readonly InlineSkillActivation[],
): void {
let panel: BtwPanelComponent;
panel = new BtwPanelComponent({
markdownTheme: createMarkdownTheme(),
canUseScrollKeys: () => this.host.state.editor.getText().length === 0,
terminalRows: () => this.host.state.terminal.rows,
onPrompt: (prompt) => {
this.promptAgent(agentId, prompt, panel);
onPrompt: (prompt, inlineSkillActivations) => {
this.promptAgent(agentId, prompt, panel, inlineSkillActivations);
},
});
this.active = { agentId, panel };
this.panelsByAgentId.set(agentId, panel);
this.mount(panel);
panel.submit(initialPrompt);
panel.submit(initialPrompt, inlineSkillActivations);
}
clear(): void {
@ -79,14 +84,14 @@ export class BtwPanelController {
return true;
}
sendUserInput(text: string): boolean {
sendUserInput(text: string, inlineSkillActivations?: readonly InlineSkillActivation[]): boolean {
const active = this.active;
if (active === undefined) return false;
if (active.panel.isRunning()) {
this.showBusyNotice(active, text);
return true;
}
active.panel.submit(text);
active.panel.submit(text, inlineSkillActivations);
this.host.state.ui.setFocus(this.host.state.editor);
this.host.state.ui.requestRender();
return true;
@ -165,14 +170,30 @@ export class BtwPanelController {
this.host.state.ui.requestRender();
}
private promptAgent(agentId: string, prompt: string, panel: BtwPanelComponent): void {
private promptAgent(
agentId: string,
prompt: string,
panel: BtwPanelComponent,
inlineSkillActivations?: readonly InlineSkillActivation[],
): void {
const session = this.host.session;
if (session === undefined) {
panel.markFailed(NO_ACTIVE_SESSION_MESSAGE);
this.host.state.ui.requestRender();
return;
}
void this.withInteractiveAgent(agentId, () => session.prompt(prompt)).catch((error: unknown) => {
const send =
inlineSkillActivations !== undefined && inlineSkillActivations.length > 0
? () =>
session.promptWithSkills(
prompt,
inlineSkillActivations.map((activation) => ({
name: activation.skillName,
args: activation.args,
})),
)
: () => session.prompt(prompt);
void this.withInteractiveAgent(agentId, send).catch((error: unknown) => {
panel.markFailed(`Failed to send /btw prompt: ${formatErrorMessage(error)}`);
this.host.state.ui.requestRender();
});

View file

@ -17,7 +17,7 @@ import {
} from '../components/dialogs/cache-hint-dialog';
import { saveTuiConfig } from '../config';
import { MAIN_AGENT_ID } from '../constant/kimi-tui';
import type { AppState } from '../types';
import type { AppState, InlineSkillActivation } from '../types';
import type { TUIState } from '../tui-state';
import { evaluateCacheHint } from '../utils/cache-hint';
import { formatErrorMessage } from '../utils/event-payload';
@ -28,6 +28,7 @@ import type { ExtractionResult } from '../utils/image-placeholder';
interface StashedSubmit {
readonly text: string;
readonly extraction?: ExtractionResult;
readonly inlineSkillActivations?: readonly InlineSkillActivation[];
}
export interface CacheHintHost {
@ -43,6 +44,11 @@ export interface CacheHintHost {
showError(message: string): void;
createNewSession(): Promise<void>;
sendNormalUserInput(text: string, preExtracted?: ExtractionResult): Promise<void>;
sendInlineSkillUserInput(
text: string,
activations: readonly InlineSkillActivation[],
preExtracted?: ExtractionResult,
): Promise<void>;
}
type HintDecision = { readonly idleSeconds: number; readonly totalTokens: number };
@ -236,7 +242,11 @@ export class CacheHintController {
* is swallowed while the config is fetched (spec: the trigger must reach
* the interface); the message is then either shown the dialog or released.
*/
maybeInterceptOnSubmit(text: string, extraction?: ExtractionResult): boolean {
maybeInterceptOnSubmit(
text: string,
extraction?: ExtractionResult,
inlineSkillActivations?: readonly InlineSkillActivation[],
): boolean {
const { host } = this;
if (!host.engineV2 || host.session === undefined) return false;
// A stashed message being released re-enters the send path here — never
@ -253,7 +263,7 @@ export class CacheHintController {
// Coarse floor: configured cache durations are 10min+, so anything
// fresher than a minute can never hint.
if (Date.now() - this.lastActivityAt < 60_000) return false;
const stash: StashedSubmit = { text, extraction };
const stash: StashedSubmit = { text, extraction, inlineSkillActivations };
const cached = peekCacheHintConfig();
if (cached !== undefined) {
const decision = evaluateCacheHint({
@ -342,12 +352,24 @@ export class CacheHintController {
private async releaseStashed(stash: StashedSubmit): Promise<void> {
this.releasingStashed = true;
try {
await this.host.sendNormalUserInput(stash.text, stash.extraction);
await this.releaseToSendPath(stash);
} finally {
this.releasingStashed = false;
}
}
private async releaseToSendPath(stash: StashedSubmit): Promise<void> {
if (stash.inlineSkillActivations !== undefined && stash.inlineSkillActivations.length > 0) {
await this.host.sendInlineSkillUserInput(
stash.text,
stash.inlineSkillActivations,
stash.extraction,
);
return;
}
await this.host.sendNormalUserInput(stash.text, stash.extraction);
}
/** Restore a stashed input to the editor, appending to anything already
* restored this cycle so earlier text is not overwritten. */
private restoreStashedInput(text: string | undefined): void {
@ -470,7 +492,7 @@ export class CacheHintController {
break;
}
this.lastDialogRestored = false;
if (stashed !== undefined) await host.sendNormalUserInput(stashed.text, stashed.extraction);
if (stashed !== undefined) await this.releaseToSendPath(stashed);
}
/** Bounded wait for the engine to flip `isCompacting` after a compact RPC. */

View file

@ -16,6 +16,7 @@ import {
import { formatErrorMessage } from '../utils/event-payload';
import type { ImageAttachmentStore } from '../utils/image-attachment-store';
import { extractMediaAttachments } from '../utils/image-placeholder';
import { extractInlineSkillActivations } from '../utils/inline-skill-tokens';
import type { PendingExit, QueuedMessage, SteerInputItem } from '../types';
import type { TUIState } from '../tui-state';
import type { BtwPanelController } from './btw-panel';
@ -34,6 +35,7 @@ export interface EditorKeyboardHost {
handleUserInput(text: string): void;
readonly btwPanelController: BtwPanelController;
readonly skillCommandMap: Map<string, string>;
steerMessage(session: Session, input: readonly SteerInputItem[]): void;
steerSkillActivation(session: Session, skillName: string, skillArgs: string): void;
validateMediaCapabilities(extraction: {
@ -292,11 +294,24 @@ export class EditorKeyboardController {
const editorIsBash = editor.inputMode === 'bash';
// Bash commands (`! …`) are not steerable: they stay queued so they run
// after the current task. Everything else steers in queue order —
// plain text as a steered message, slash-skill items as activations
// fired into the running turn (never as literal text).
// after the current task. Grouped inline-skill submissions are not
// steerable either — steer carries no skill activations, so they stay
// queued and submit intact when the session drains; the same applies to
// an editor draft carrying inline skill tokens. Steering stops at the
// first such bundle: items behind it stay queued too, or a later
// message would jump ahead of its bundle and reverse the conversational
// order. Everything else steers in queue order — plain text as a
// steered message, slash-skill items as activations fired into the
// running turn (never as literal text).
const queued = host.state.queuedMessages;
const steerable = queued.filter((m) => m.mode !== 'bash');
const firstBundle = queued.findIndex((m) => m.inlineSkillActivations !== undefined);
const windowBeforeFirstBundle = firstBundle === -1 ? queued : queued.slice(0, firstBundle);
const steerable = windowBeforeFirstBundle.filter((m) => m.mode !== 'bash');
const editorHasInlineSkills =
!editorIsBash &&
text.length > 0 &&
host.engineV2 &&
extractInlineSkillActivations(text, host.skillCommandMap).length > 0;
type SteerRun =
| { readonly kind: 'text'; readonly items: SteerInputItem[] }
@ -323,7 +338,7 @@ export class EditorKeyboardController {
}
}
let editorExtraction: ReturnType<typeof extractMediaAttachments> | undefined;
if (!editorIsBash && text.length > 0) {
if (!editorIsBash && text.length > 0 && !editorHasInlineSkills && firstBundle === -1) {
try {
editorExtraction = extractMediaAttachments(text, this.imageStore);
} catch (error) {
@ -353,8 +368,10 @@ export class EditorKeyboardController {
) {
return;
}
host.state.queuedMessages = queued.filter((m) => m.mode === 'bash');
if (!editorIsBash) editor.setText('');
host.state.queuedMessages = queued.filter(
(m, index) => m.mode === 'bash' || (firstBundle !== -1 && index >= firstBundle),
);
if (!editorIsBash && !editorHasInlineSkills && firstBundle === -1) editor.setText('');
const session = host.session;
if (host.state.appState.model.trim().length === 0 || session === undefined) {
host.showError(LLM_NOT_SET_MESSAGE);

View file

@ -595,6 +595,7 @@ export class SessionEventHandler {
turnId: String(event.turnId),
renderMode: 'markdown',
content: formatHookResultMarkdown(event),
hookResult: true,
});
this.host.patchLivePane({
mode: 'idle',

View file

@ -28,6 +28,7 @@ import { markTranscriptComponent } from '../utils/transcript-component-metadata'
import {
appStateFromResumeAgent,
backgroundOrigin,
bundledSkillsFromOrigin,
collectReplayMessageContent,
contentPartsToText,
countActiveBackgroundTasks,
@ -39,6 +40,7 @@ import {
replayBackgroundProjection,
replayEntry,
skillActivationFromOrigin,
stripBundledSkillParts,
pluginCommandFromOrigin,
toolCallFromReplayMessage,
toolResultOutput,
@ -81,6 +83,33 @@ function unescapeBashXml(text: string): string {
.replaceAll('&amp;', '&');
}
/**
* Replay records within the turn limit, but never cut between a bundled
* prompt and the hook results recorded immediately before it: when the
* limiter's first retained record is a bundled prompt, the consecutive
* preceding hook results are pulled back into the window so the oldest
* visible bundle keeps its hook context.
*/
function preserveBundleHookResults(
replay: readonly AgentReplayRecord[],
maxTurns: number,
): readonly AgentReplayRecord[] {
const limited = limitReplayRecordsByTurn(replay, maxTurns);
const first = limited[0];
if (first?.type !== 'message' || bundledSkillsFromOrigin(first.message.origin).length === 0) {
return limited;
}
const firstIndex = replay.indexOf(first);
if (firstIndex < 0) return limited;
let start = firstIndex;
for (;;) {
const candidate = replay[start - 1];
if (candidate?.type !== 'message' || candidate.message.origin?.kind !== 'hook_result') break;
start -= 1;
}
return start === firstIndex ? limited : [...replay.slice(start, firstIndex), ...limited];
}
export class SessionReplayRenderer {
constructor(private readonly host: SessionReplayHost) {}
@ -192,13 +221,48 @@ export class SessionReplayRenderer {
private renderRecords(agent: ResumedAgentState): void {
const context = createReplayRenderContext();
for (const record of limitReplayRecordsByTurn(agent.replay, REPLAY_TURN_LIMIT)) {
this.renderRecord(context, record);
const records = [...preserveBundleHookResults(agent.replay, REPLAY_TURN_LIMIT)];
for (let i = 0; i < records.length; i++) {
i = this.renderRecordWithBundleLookahead(context, records, i);
}
this.flushAssistant(context);
this.cleanupRuntime(context);
}
private renderRecordWithBundleLookahead(
context: ReplayRenderContext,
records: readonly AgentReplayRecord[],
index: number,
): number {
const record = records[index]!;
// Hook results recorded ahead of a bundled prompt are projected inside
// the bundle's window — after its skill cards, before the prompt —
// matching the live event order instead of attaching them to the
// previous turn.
if (record.type === 'message' && record.message.origin?.kind === 'hook_result') {
let end = index;
for (;;) {
const candidate = records[end + 1];
if (candidate?.type !== 'message' || candidate.message.origin?.kind !== 'hook_result') {
break;
}
end += 1;
}
const next = records[end + 1];
if (next?.type === 'message' && bundledSkillsFromOrigin(next.message.origin).length > 0) {
const hookResults: ContextMessage[] = [];
for (let j = index; j <= end; j++) {
const hookRecord = records[j]!;
if (hookRecord.type === 'message') hookResults.push(hookRecord.message);
}
this.renderBundledPrompt(context, next.message, hookResults);
return end + 1;
}
}
this.renderRecord(context, record);
return index;
}
private renderRecord(context: ReplayRenderContext, record: AgentReplayRecord): void {
switch (record.type) {
case 'message':
@ -339,12 +403,40 @@ export class SessionReplayRenderer {
return;
}
if (bundledSkillsFromOrigin(message.origin).length > 0) {
this.renderBundledPrompt(context, message);
return;
}
this.advanceTurn(context);
this.host.appendTranscriptEntry(
replayEntry(context, 'user', contentPartsToText(message.content), 'plain'),
);
}
private renderBundledPrompt(
context: ReplayRenderContext,
message: ContextMessage,
hookResults: readonly ContextMessage[] = [],
): void {
// The bundle is one message: advance once, rebuild the per-skill cards
// from the prompt origin, then show the caller's own parts (the engine
// prepends one rendered text part per bundled skill to the content).
this.advanceTurn(context);
this.renderBundledSkillCards(context, message);
for (const hookResult of hookResults) {
this.renderHookResult(context, hookResult);
}
this.host.appendTranscriptEntry(
replayEntry(context, 'user', contentPartsToText(stripBundledSkillParts(message)), 'plain'),
);
}
private renderBundledSkillCards(context: ReplayRenderContext, message: ContextMessage): void {
for (const skill of bundledSkillsFromOrigin(message.origin)) {
this.renderSkillActivation(context, skill);
}
}
private renderToolCalls(context: ReplayRenderContext, toolCalls: readonly ToolCall[]): void {
if (toolCalls.length === 0) return;
const { streamingUI } = this.host;
@ -432,6 +524,7 @@ export class SessionReplayRenderer {
skillName: skill.skillName,
skillArgs: skill.skillArgs,
skillTrigger: skill.trigger,
bundledWithPrompt: skill.bundled === true ? true : undefined,
});
}
@ -526,8 +619,8 @@ export class SessionReplayRenderer {
private renderHookResult(context: ReplayRenderContext, message: ContextMessage): void {
if (message.origin?.kind !== 'hook_result') return;
this.flushAssistant(context);
this.host.appendTranscriptEntry(
replayEntry(
this.host.appendTranscriptEntry({
...replayEntry(
context,
'assistant',
formatHookResultMessageForTranscript(
@ -537,7 +630,8 @@ export class SessionReplayRenderer {
),
'markdown',
),
);
hookResult: true,
});
}
private renderCronJob(context: ReplayRenderContext, message: ContextMessage): void {

View file

@ -135,6 +135,7 @@ import { createTUIState, type TUIState } from './tui-state';
import {
INITIAL_LIVE_PANE,
type AppState,
type InlineSkillActivation,
type KimiTUIOptions,
type LivePaneState,
type LoginProgressSpinnerHandle,
@ -154,7 +155,7 @@ import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image
import type { ExtractionResult } from './utils/image-placeholder';
import { installInputLatencyProbe } from './utils/input-latency';
import { startupTrace } from '#/utils/startup-trace';
import { REPLAY_TURN_LIMIT } from './utils/message-replay';
import { REPLAY_FETCH_TURN_LIMIT } from './utils/message-replay';
import { hasPatchChanges } from './utils/object-patch';
import { beginScreenTakeover, endScreenTakeover, type ScreenTakeover } from './utils/screen-takeover';
import { sessionRowsForPicker } from './utils/session-picker-rows';
@ -492,12 +493,14 @@ export class KimiTUI {
: {}),
};
});
const skillCommandNames = new Set(this.skillCommandMap.keys());
const provider = new FileMentionProvider(
slashCommands,
this.state.appState.workDir,
this.fdPath,
this.state.appState.additionalDirs,
() => this.state.appState.inputMode,
skillCommandNames,
);
this.state.editor.setAutocompleteProvider(provider);
@ -510,6 +513,7 @@ export class KimiTUI {
}
}
this.state.editor.setArgumentHints(argumentHints);
this.state.editor.setSkillCommandNames(skillCommandNames);
}
refreshSlashCommandAutocomplete(): void {
@ -895,7 +899,7 @@ export class KimiTUI {
session = await this.harness.resumeSession({
id: startup.sessionFlag,
additionalDirs: createSessionOptions.additionalDirs,
replayTurnLimit: REPLAY_TURN_LIMIT,
replayTurnLimit: REPLAY_FETCH_TURN_LIMIT,
});
shouldReplayHistory = true;
} else {
@ -907,7 +911,7 @@ export class KimiTUI {
session = await this.harness.resumeSession({
id: target.id,
additionalDirs: createSessionOptions.additionalDirs,
replayTurnLimit: REPLAY_TURN_LIMIT,
replayTurnLimit: REPLAY_FETCH_TURN_LIMIT,
});
shouldReplayHistory = true;
} else {
@ -1367,6 +1371,98 @@ export class KimiTUI {
this.state.ui.requestRender();
}
async sendInlineSkillUserInput(
text: string,
activations: readonly InlineSkillActivation[],
preExtracted?: ExtractionResult,
): Promise<void> {
if (this.btwPanelController.sendUserInput(text, activations)) return;
if (this.state.appState.model.trim().length === 0) {
this.showError(LLM_NOT_SET_MESSAGE);
return;
}
let extraction: ReturnType<typeof extractMediaAttachments>;
try {
extraction = preExtracted ?? extractMediaAttachments(text, this.imageStore);
} catch (error) {
this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`);
return;
}
if (!this.validateMediaCapabilities(extraction)) return;
if (this.cacheHint.maybeInterceptOnSubmit(text, extraction, activations)) return;
let session = this.session;
if (session === undefined) {
// Dispatch only routes here on the v2 engine, so the session is created
// lazily on first use exactly like a normal prompt.
session = await this.ensureSession();
if (session === undefined) return;
}
if (
this.deferUserMessages ||
this.state.appState.goal?.status === 'active' ||
this.state.appState.streamingPhase !== 'idle' ||
this.state.appState.isCompacting
) {
this.enqueueMessage(
text,
extraction.hasMedia
? {
hasMedia: true,
parts: extraction.parts,
imageAttachmentIds: extraction.imageAttachmentIds,
inlineSkillActivations: activations,
}
: { inlineSkillActivations: activations },
);
this.updateQueueDisplay();
this.state.ui.requestRender();
return;
}
this.beginSessionRequest();
void this.runInlineSkillActivations(session, text, activations, extraction).catch(
(error: unknown) => {
this.failSessionRequest(`Skill activation failed: ${formatErrorMessage(error)}`);
},
);
}
private async runInlineSkillActivations(
session: Session,
text: string,
activations: readonly InlineSkillActivation[],
extraction: ReturnType<typeof extractMediaAttachments>,
): Promise<void> {
const knownEntryIds = new Set(this.state.transcriptEntries.map((entry) => entry.id));
await session.promptWithSkills(
extraction.hasMedia ? extraction.parts : text,
activations.map((activation) => ({ name: activation.skillName, args: activation.args })),
);
// The engine bundles the activations into the prompt's own message, and
// the `skill.activated` events land synchronously during the call — so
// the cards appended for this submission are the skill_activation entries
// with fresh ids (the window trim may replace the entries array mid-call,
// so membership is decided by id, not by index into a captured array).
// Appending the user entry afterwards keeps the live transcript in the
// same order as a resumed replay (skill cards first, prompt last).
// Marking only happens once the submission was accepted: a rejected
// bundle leaves no cards and must not leave a local undo anchor the
// engine never recorded.
for (const entry of this.state.transcriptEntries) {
if (entry.kind === 'skill_activation' && !knownEntryIds.has(entry.id)) {
entry.bundledWithPrompt = true;
}
}
this.appendTranscriptEntry({
id: nextTranscriptId(),
kind: 'user',
turnId: undefined,
renderMode: 'plain',
content: text,
imageAttachmentIds:
extraction.imageAttachmentIds.length > 0 ? extraction.imageAttachmentIds : undefined,
});
}
validateMediaCapabilities(extraction: {
hasMedia: boolean;
imageAttachmentIds: readonly number[];
@ -1437,7 +1533,9 @@ export class KimiTUI {
private enqueueMessage(
text: string,
options?: SendMessageOptions,
options?: SendMessageOptions & {
readonly inlineSkillActivations?: readonly InlineSkillActivation[];
},
mode?: 'prompt' | 'bash',
): void {
this.state.queuedMessages.push({
@ -1449,6 +1547,7 @@ export class KimiTUI {
? options.imageAttachmentIds
: undefined,
mode,
inlineSkillActivations: options?.inlineSkillActivations,
});
this.track('input_queue');
}
@ -1488,6 +1587,25 @@ export class KimiTUI {
this.sendSkillActivation(session, item.skillName, item.skillArgs ?? '');
return;
}
if (item.inlineSkillActivations !== undefined && item.inlineSkillActivations.length > 0) {
// Media was extracted and validated at enqueue time; reuse the queued
// parts rather than re-extracting from a possibly-cleared image store.
this.beginSessionRequest();
void this.runInlineSkillActivations(
session,
item.text,
item.inlineSkillActivations,
{
parts: item.parts !== undefined ? [...item.parts] : [],
hasMedia: item.parts !== undefined && item.parts.length > 0,
imageAttachmentIds: item.imageAttachmentIds !== undefined ? [...item.imageAttachmentIds] : [],
videoAttachmentIds: [],
},
).catch((error: unknown) => {
this.failSessionRequest(`Skill activation failed: ${formatErrorMessage(error)}`);
});
return;
}
this.harness.withInteractiveAgent(item.agentId ?? MAIN_AGENT_ID, () => {
this.sendMessageInternal(session, item.text, {
parts: item.parts,
@ -2222,7 +2340,7 @@ export class KimiTUI {
try {
session = await this.harness.resumeSession({
id: targetSessionId,
replayTurnLimit: REPLAY_TURN_LIMIT,
replayTurnLimit: REPLAY_FETCH_TURN_LIMIT,
});
} catch (error) {
const msg = formatErrorMessage(error);

View file

@ -236,6 +236,10 @@ export interface TranscriptEntry {
skillName?: string;
skillArgs?: string;
skillTrigger?: SkillActivationTrigger;
/** Card belongs to the following prompt's bundled submission: undo removes them together. */
bundledWithPrompt?: boolean;
/** Entry renders a UserPromptSubmit hook result (sits inside its prompt's group window). */
hookResult?: boolean;
pluginCommandData?: PluginCommandTranscriptData;
}
@ -252,6 +256,15 @@ export interface LivePaneState {
pendingQuestion: PendingQuestion | null;
}
export interface InlineSkillActivation {
readonly skillName: string;
/**
* Skill arguments. Only set for a leading `/skill:<name> args` command that
* is combined with further inline skills; inline tokens carry no args.
*/
readonly args?: string;
}
export interface QueuedMessage {
readonly text: string;
readonly agentId?: string;
@ -266,6 +279,8 @@ export interface QueuedMessage {
readonly skillName?: string;
/** Set when mode === 'skill': the raw (media-rewritten) args to activate with. */
readonly skillArgs?: string;
/** Skills to activate together with this queued message's prompt. */
readonly inlineSkillActivations?: readonly InlineSkillActivation[];
}
/**

View file

@ -0,0 +1,97 @@
/**
* Scanner for inline skill `/tokens` inside a prompt.
*
* Dispatch, editor highlighting, and autocomplete share this so all three
* agree on what counts as an inline skill reference: a `/name` token whose `/`
* is preceded by whitespace (space, tab, or newline), with no internal `/`.
* The leading slash-command area at the very start of the input is handled by
* the regular slash-command path and is skipped here by default.
*/
import type { InlineSkillActivation } from '../types';
export interface InlineSkillToken {
readonly commandName: string;
readonly start: number;
readonly end: number;
}
export interface FindInlineSkillTokensOptions {
/** Decide whether a syntactically valid token names a known skill. */
readonly isKnownSkill: (commandName: string) => boolean;
/** Include tokens with an empty command name (a bare trailing `/`). */
readonly allowEmpty?: boolean;
/** Also treat a `/` at the very start of the input as a token. */
readonly includeLeading?: boolean;
}
const WHITESPACE = /\s/;
export function findInlineSkillTokens(
text: string,
options: FindInlineSkillTokensOptions,
): InlineSkillToken[] {
const tokens: InlineSkillToken[] = [];
let searchStart = 0;
if (text.startsWith('/') && options.includeLeading !== true) {
const firstWhitespace = text.search(WHITESPACE);
searchStart = firstWhitespace === -1 ? text.length : firstWhitespace + 1;
}
for (let i = searchStart; i < text.length; i++) {
if (text[i] !== '/') continue;
const isLeadingSlash = i === 0 && options.includeLeading === true;
const charBefore = i > 0 ? text[i - 1] : undefined;
if (!isLeadingSlash && (charBefore === undefined || !WHITESPACE.test(charBefore))) continue;
let end = i + 1;
while (end < text.length && !WHITESPACE.test(text[end] ?? '')) {
end++;
}
const commandName = text.slice(i + 1, end);
if (commandName.includes('/')) continue;
if (commandName.length === 0 && options.allowEmpty !== true) continue;
if (!options.isKnownSkill(commandName)) continue;
tokens.push({ commandName, start: i, end });
}
return tokens;
}
export interface ExtractInlineSkillActivationsOptions {
/** Also treat a `/` at the very start of the input as a skill token. */
readonly includeLeading?: boolean;
}
/**
* Resolve the skill tokens of `text` through `skillCommandMap` (command name
* skill name, with the same `skill:` prefix fallback as the leading-command
* path) and return the deduplicated activations in first-occurrence order.
* Unknown tokens, paths, URLs, and fractions are ignored.
*/
export function extractInlineSkillActivations(
text: string,
skillCommandMap: ReadonlyMap<string, string>,
options?: ExtractInlineSkillActivationsOptions,
): InlineSkillActivation[] {
const tokens = findInlineSkillTokens(text, {
isKnownSkill: (commandName) =>
skillCommandMap.has(commandName) || skillCommandMap.has(`skill:${commandName}`),
includeLeading: options?.includeLeading,
});
const seen = new Set<string>();
const activations: InlineSkillActivation[] = [];
for (const token of tokens) {
const skillName =
skillCommandMap.get(token.commandName) ?? skillCommandMap.get(`skill:${token.commandName}`);
if (skillName === undefined || seen.has(skillName)) continue;
seen.add(skillName);
activations.push({ skillName });
}
return activations;
}

View file

@ -24,6 +24,16 @@ import { nextTranscriptId } from './transcript-id';
export const REPLAY_TURN_LIMIT = 10;
/**
* Resume fetches one extra turn of records: the SDK trims the replay to the
* requested limit before returning it, and a trim that lands between a
* bundled prompt and the hook results recorded immediately before it would
* make them unrecoverable. The extra margin lets the TUI-side limiter
* (session-replay's preserveBundleHookResults) do the final cut without
* losing them.
*/
export const REPLAY_FETCH_TURN_LIMIT = REPLAY_TURN_LIMIT + 1;
export interface ReplayRenderContext {
turnIndex: number;
stepIndex: number;
@ -44,6 +54,8 @@ export interface SkillActivationProjection {
readonly skillName: string;
readonly skillArgs?: string;
readonly trigger: SkillActivationTrigger;
/** The activation rode a bundled prompt message, not a standalone one. */
readonly bundled?: boolean;
}
export interface PluginCommandProjection {
@ -255,6 +267,48 @@ export function skillActivationFromOrigin(
};
}
/**
* The v2 engine bundles a prompt's inline skill activations into the prompt
* message itself: the rendered skill blocks precede the caller's parts in
* the content, and this origin field carries every activation's metadata so
* replay can rebuild the per-skill cards from the single message. The SDK's
* origin union is typed from the v1 engine, which never sets the field, so
* read it structurally here instead of widening the deprecated v1 package's
* types.
*/
export function bundledSkillsFromOrigin(
origin: PromptOrigin | undefined,
): readonly SkillActivationProjection[] {
if (origin?.kind !== 'user') return [];
const activations = (
origin as {
readonly skillActivations?: readonly {
readonly activationId: string;
readonly skillName: string;
readonly skillArgs?: string;
}[];
}
).skillActivations;
if (activations === undefined) return [];
return activations.map((activation) => ({
activationId: activation.activationId,
skillName: activation.skillName,
skillArgs: activation.skillArgs,
trigger: 'user-slash' as const,
bundled: true,
}));
}
/**
* Content parts the caller actually typed: the engine prepends one rendered
* text part per bundled skill, so the caller's own parts start right after
* them.
*/
export function stripBundledSkillParts(message: ContextMessage): readonly ContentPart[] {
const bundledCount = bundledSkillsFromOrigin(message.origin).length;
return bundledCount === 0 ? message.content : message.content.slice(bundledCount);
}
export function pluginCommandFromOrigin(
origin: PromptOrigin | undefined,
): PluginCommandProjection | undefined {

View file

@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest';
import { handleUndoCommand } from '#/tui/commands/undo';
import type { SlashCommandHost } from '#/tui/commands/dispatch';
import type { TranscriptEntry } from '#/tui/types';
function entry(partial: Partial<TranscriptEntry> & Pick<TranscriptEntry, 'kind' | 'content'>): TranscriptEntry {
return {
id: `t-${Math.random().toString(36).slice(2, 10)}`,
turnId: undefined,
renderMode: 'plain',
...partial,
};
}
function hostWith(entries: TranscriptEntry[]): SlashCommandHost {
return {
session: { undoHistory: vi.fn(async () => {}) },
state: {
transcriptEntries: entries,
transcriptContainer: { children: [], addChild: vi.fn() },
ui: { requestRender: vi.fn() },
appState: { streamingPhase: 'idle' },
},
showError: vi.fn(),
} as unknown as SlashCommandHost;
}
describe('/undo with bundled prompts', () => {
it('removes the bundle cards with their prompt, keeping a standalone skill card before them', async () => {
const entries: TranscriptEntry[] = [
entry({ kind: 'user', content: 'earlier question' }),
entry({
kind: 'skill_activation',
content: 'Activated skill: review',
skillTrigger: 'user-slash',
}),
entry({ kind: 'user', content: 'prompt one' }),
entry({ kind: 'assistant', content: 'answer one' }),
entry({
kind: 'skill_activation',
content: 'Activated skill: security',
skillTrigger: 'user-slash',
bundledWithPrompt: true,
}),
entry({ kind: 'user', content: 'prompt two' }),
entry({ kind: 'assistant', content: 'answer two' }),
];
const host = hostWith(entries);
await handleUndoCommand(host, '1');
expect(host.session?.undoHistory).toHaveBeenCalledWith(1);
expect(entries.map((item) => item.content)).toEqual([
'earlier question',
'Activated skill: review',
'prompt one',
'answer one',
]);
});
it('removes bundle cards around an interleaved hook result and keeps the hook result', async () => {
const entries: TranscriptEntry[] = [
entry({
kind: 'skill_activation',
content: 'Activated skill: review',
skillTrigger: 'user-slash',
bundledWithPrompt: true,
}),
entry({ kind: 'assistant', content: 'hook note', hookResult: true }),
entry({ kind: 'user', content: 'bundled prompt' }),
entry({ kind: 'assistant', content: 'bundled answer' }),
];
const host = hostWith(entries);
await handleUndoCommand(host, '1');
expect(host.session?.undoHistory).toHaveBeenCalledWith(1);
expect(entries.map((item) => item.content)).toEqual(['hook note']);
});
it('does not count bundle cards as undo anchors of their own', async () => {
const entries: TranscriptEntry[] = [
entry({ kind: 'user', content: 'prompt one' }),
entry({ kind: 'assistant', content: 'answer one' }),
entry({
kind: 'skill_activation',
content: 'Activated skill: review',
skillTrigger: 'user-slash',
bundledWithPrompt: true,
}),
entry({ kind: 'user', content: 'prompt two' }),
entry({ kind: 'assistant', content: 'answer two' }),
];
const host = hostWith(entries);
await handleUndoCommand(host, '2');
expect(host.session?.undoHistory).toHaveBeenCalledWith(2);
expect(entries).toHaveLength(0);
});
});

View file

@ -640,4 +640,143 @@ describe('FileMentionProvider', () => {
expect(result?.items.map((item) => item.label)).toContain('shared/');
});
});
describe('inline skill completion', () => {
const REVIEW_COMMAND = {
name: 'skill:review',
aliases: [],
description: 'Review changes',
};
const SECURITY_COMMAND = {
name: 'skill:security',
aliases: [],
description: 'Check security',
};
const SKILL_NAMES = new Set(['skill:review', 'skill:security']);
function skillProvider(
commands: ConstructorParameters<typeof FileMentionProvider>[0] = [
REVIEW_COMMAND,
SECURITY_COMMAND,
HELP_COMMAND,
],
) {
return new FileMentionProvider(
commands,
workDir,
NO_FD,
[],
() => 'prompt',
SKILL_NAMES,
);
}
it('offers skill-only suggestions for a `/` after whitespace mid-input', async () => {
const provider = skillProvider();
const line = 'hello /';
const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() });
expect(result).not.toBeNull();
expect(result!.prefix).toBe('/');
expect(result!.items.map((item) => item.value).sort()).toEqual([
'skill:review',
'skill:security',
]);
});
it('filters inline suggestions by the typed prefix', async () => {
const provider = skillProvider();
const line = 'hello /rev';
const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() });
expect(result).not.toBeNull();
expect(result!.prefix).toBe('/rev');
expect(result!.items.map((item) => item.value)).toEqual(['skill:review']);
});
it('offers the skill picker for a `/` at the start of a later line', async () => {
const provider = skillProvider();
const result = await provider.getSuggestions(['first line', '/'], 1, 1, { signal: ctrl() });
expect(result).not.toBeNull();
expect(result!.items.map((item) => item.value).sort()).toEqual([
'skill:review',
'skill:security',
]);
});
it('stays in skill-only mode while typing a token on a later line', async () => {
const provider = skillProvider();
const result = await provider.getSuggestions(['first line', '/rev'], 1, 4, {
signal: ctrl(),
});
expect(result).not.toBeNull();
expect(result!.prefix).toBe('/rev');
expect(result!.items.map((item) => item.value)).toEqual(['skill:review']);
});
it('offers inline skills on an indented later line', async () => {
const provider = skillProvider();
const result = await provider.getSuggestions(['first line', ' /skill:rev'], 1, 12, {
signal: ctrl(),
});
expect(result).not.toBeNull();
expect(result!.prefix).toBe('/skill:rev');
expect(result!.items.map((item) => item.value)).toEqual(['skill:review']);
});
it('offers inline skills for an indented token on the first line', async () => {
const provider = skillProvider();
const result = await provider.getSuggestions([' /skill:rev'], 0, 12, { signal: ctrl() });
expect(result).not.toBeNull();
expect(result!.prefix).toBe('/skill:rev');
expect(result!.items.map((item) => item.value)).toEqual(['skill:review']);
});
it('does not leak built-in commands onto later lines', async () => {
const provider = skillProvider();
const result = await provider.getSuggestions(['first line', '/hel'], 1, 4, {
signal: ctrl(),
});
expect(result?.items.map((item) => item.value) ?? []).not.toContain('help');
});
it('returns null for a prose slash when no skills are registered', async () => {
const provider = new FileMentionProvider([HELP_COMMAND], workDir, NO_FD, [], () => 'prompt');
const line = 'hello /';
const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() });
expect(result).toBeNull();
});
it('keeps slash-command argument completions ahead of inline skills', async () => {
const provider = skillProvider([ADD_DIR_COMMAND, REVIEW_COMMAND]);
const line = '/add-dir /';
const result = await provider.getSuggestions([line], 0, line.length, {
signal: ctrl(),
force: false,
});
expect(result).not.toBeNull();
expect(result!.items.map((item) => item.value)).toEqual(['/tmp/shared/']);
});
it('applyCompletion preserves the slash and appends a trailing space', () => {
const provider = skillProvider();
const line = 'hello /rev';
const result = provider.applyCompletion(
[line],
0,
line.length,
{ value: 'skill:review', label: 'skill:review', data: { inlineSkill: true } },
'/rev',
);
expect(result.lines[0]).toBe('hello /skill:review ');
expect(result.cursorCol).toBe('hello /skill:review '.length);
});
});
});

View file

@ -1,7 +1,7 @@
import chalk from 'chalk';
import { describe, it, expect, beforeAll } from 'vitest';
import { highlightFirstSlashToken } from '#/tui/components/editor/custom-editor';
import { highlightFirstSlashToken, highlightInlineSkillTokens } from '#/tui/components/editor/custom-editor';
beforeAll(() => {
// Vitest runs without a TTY so chalk auto-detects colour support as
@ -86,3 +86,47 @@ describe('highlightFirstSlashToken', () => {
expect(out!).toContain(' /b');
});
});
describe('highlightInlineSkillTokens', () => {
const SKILLS = new Set(['skill:review', 'skill:security', 'commit']);
it('colours known skill tokens anywhere in the line', () => {
const out = highlightInlineSkillTokens('please /skill:review this', SKILLS, null, 'primary');
expect(out).toBeDefined();
expect(strip(out!)).toBe('please /skill:review this');
expectHighlighted(out!, '/skill:review');
});
it('colours multiple skill tokens in one line', () => {
const out = highlightInlineSkillTokens(
'/skill:review then /skill:security',
SKILLS,
null,
'primary',
);
expect(out).toBeDefined();
expectHighlighted(out!, '/skill:review');
expectHighlighted(out!, '/skill:security');
});
it('skips the excluded leading command range', () => {
const visible = '/skill:review args';
const out = highlightInlineSkillTokens(
visible,
SKILLS,
{ start: 0, end: 13 },
'primary',
);
expect(out).toBeUndefined();
});
it('ignores unknown tokens and plain slashes', () => {
expect(highlightInlineSkillTokens('and /not-a-skill or /tmp', SKILLS, null, 'primary')).toBeUndefined();
});
it('supports the skill: prefix fallback for bare names', () => {
const out = highlightInlineSkillTokens('please /review this', SKILLS, null, 'primary');
expect(out).toBeDefined();
expectHighlighted(out!, '/review');
});
});

View file

@ -57,6 +57,7 @@ function makeHost(
if (overrides.createNewSessionFails !== true) state.appState.sessionId = 's2';
}),
sendNormalUserInput: vi.fn(async () => undefined),
sendInlineSkillUserInput: vi.fn(async () => undefined),
};
return { host, state };
}
@ -148,6 +149,26 @@ describe('CacheHintController scenario 2 (idle submit)', () => {
vi.restoreAllMocks();
});
it('releases a stashed inline-skill submit through the inline-skill path', async () => {
const { host } = makeHost();
const controller = new CacheHintController(host);
controller.recordActivity();
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000);
const activations = [{ skillName: 'review' }];
expect(controller.maybeInterceptOnSubmit('check /skill:review', undefined, activations)).toBe(
true,
);
await flush();
vi.restoreAllMocks();
expect(host.sendInlineSkillUserInput).toHaveBeenCalledWith(
'check /skill:review',
activations,
undefined,
);
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
});
it('fetches on a cold-cache submit and shows the dialog when a rule matches', async () => {
getMock.mockResolvedValue(CONFIG);
const { host } = makeHost();

View file

@ -464,64 +464,188 @@ describe('EditorKeyboardController Shift-Tab plan toggle', () => {
});
});
/**
* Ctrl-S steering of the TUI queue: plain-text items steer as messages,
* slash-skill items fire as real activations into the running turn (never as
* literal text), bash items stay queued all in queue order.
* literal text), grouped inline-skill submissions stay queued for the drain
* path, bash items stay queued all in queue order.
*/
describe('EditorKeyboardController Ctrl-S steering', () => {
it('steers text as a message, skill items as activations, and keeps bash queued', () => {
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {
setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown,
setInputMode: vi.fn() as unknown as (...args: never[]) => unknown,
getText: vi.fn(() => '') as unknown as (...args: never[]) => unknown,
setText: vi.fn() as unknown as (...args: never[]) => unknown,
inputMode: 'prompt' as unknown as (...args: never[]) => unknown,
};
function createCtrlSHarness(options: {
editorText: string;
queued: Array<Record<string, unknown>>;
engineV2?: boolean;
skillCommandMap?: Map<string, string>;
}) {
const steerMessage = vi.fn();
const steerSkillActivation = vi.fn();
const updateQueueDisplay = vi.fn();
const session = { id: 'ses-1' };
const setText = vi.fn();
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {
setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown,
setInputMode: vi.fn() as unknown as (...args: never[]) => unknown,
getText: vi.fn(() => options.editorText) as unknown as (...args: never[]) => unknown,
setText: setText as unknown as (...args: never[]) => unknown,
inputMode: 'prompt' as unknown as (...args: never[]) => unknown,
};
const host = {
state: {
editor,
queuedMessages: [
{ text: 'queued text', agentId: 'main' },
{ text: '/tower status', agentId: 'main', mode: 'skill', skillName: 'tower', skillArgs: 'status' },
{ text: '!ls', agentId: 'main', mode: 'bash' },
],
appState: {
streamingPhase: 'waiting',
isCompacting: false,
model: 'mock-model',
},
activeDialog: null,
queuedMessages: options.queued,
appState: { streamingPhase: 'waiting', isCompacting: false, model: 'k2' },
footer: { setTransientHint: vi.fn() },
ui: { requestRender: vi.fn() },
},
session,
session: { id: 's1' },
engineV2: options.engineV2 ?? false,
skillCommandMap: options.skillCommandMap ?? new Map(),
steerMessage,
steerSkillActivation,
updateQueueDisplay,
validateMediaCapabilities: vi.fn(() => true),
showError: vi.fn(),
track: vi.fn(),
btwPanelController: {
cancelRunning: vi.fn(() => false),
closeOrCancel: vi.fn(() => false),
},
} as unknown as EditorKeyboardHost;
const controller = new EditorKeyboardController(
host,
undefined as unknown as ImageAttachmentStore,
);
controller.install();
const onCtrlS = editor['onCtrlS'];
if (onCtrlS === undefined) throw new Error('onCtrlS handler not installed');
return {
host,
editor,
setText,
steerMessage,
steerSkillActivation,
updateQueueDisplay,
onCtrlS: onCtrlS as () => void,
};
}
const handler = editor['onCtrlS'];
expect(handler).toBeDefined();
(handler as () => void)();
it('steers text as a message, skill items as activations, and keeps bash queued', () => {
const { host, steerMessage, steerSkillActivation, updateQueueDisplay, onCtrlS } =
createCtrlSHarness({
editorText: '',
queued: [
{ text: 'queued text', agentId: 'main' },
{
text: '/tower status',
agentId: 'main',
mode: 'skill',
skillName: 'tower',
skillArgs: 'status',
},
{ text: '!ls', agentId: 'main', mode: 'bash' },
],
});
expect(steerMessage).toHaveBeenCalledWith(session, [
onCtrlS();
expect(steerMessage).toHaveBeenCalledWith(host.session, [
{ text: 'queued text', parts: undefined, imageAttachmentIds: undefined },
]);
expect(steerSkillActivation).toHaveBeenCalledWith(session, 'tower', 'status');
expect(steerSkillActivation).toHaveBeenCalledWith(host.session, 'tower', 'status');
expect(host.state.queuedMessages).toEqual([{ text: '!ls', agentId: 'main', mode: 'bash' }]);
expect(updateQueueDisplay).toHaveBeenCalled();
});
it('steers plain queued messages but keeps grouped inline-skill submissions queued', () => {
const { host, steerMessage, updateQueueDisplay, onCtrlS } = createCtrlSHarness({
editorText: '',
queued: [
{ text: 'plain note', agentId: 'main' },
{
text: 'check /skill:review',
agentId: 'main',
inlineSkillActivations: [{ skillName: 'review' }],
},
],
});
onCtrlS();
expect(steerMessage).toHaveBeenCalledWith(host.session, [
{ text: 'plain note', parts: undefined, imageAttachmentIds: undefined },
]);
expect(host.state.queuedMessages).toEqual([
{
text: 'check /skill:review',
agentId: 'main',
inlineSkillActivations: [{ skillName: 'review' }],
},
]);
expect(updateQueueDisplay).toHaveBeenCalled();
});
it('stops steering at the first bundle so later messages keep FIFO order', () => {
const { host, steerMessage, onCtrlS } = createCtrlSHarness({
editorText: '',
queued: [
{ text: 'earlier note', agentId: 'main' },
{
text: 'check /skill:review',
agentId: 'main',
inlineSkillActivations: [{ skillName: 'review' }],
},
{ text: 'later note', agentId: 'main' },
],
});
onCtrlS();
expect(steerMessage).toHaveBeenCalledWith(host.session, [
{ text: 'earlier note', parts: undefined, imageAttachmentIds: undefined },
]);
expect(host.state.queuedMessages).toEqual([
{
text: 'check /skill:review',
agentId: 'main',
inlineSkillActivations: [{ skillName: 'review' }],
},
{ text: 'later note', agentId: 'main' },
]);
});
it('steers nothing when a bundle leads the queue', () => {
const { host, steerMessage, onCtrlS } = createCtrlSHarness({
editorText: '',
queued: [
{
text: 'check /skill:review',
agentId: 'main',
inlineSkillActivations: [{ skillName: 'review' }],
},
{ text: 'later note', agentId: 'main' },
],
});
onCtrlS();
expect(steerMessage).not.toHaveBeenCalled();
expect(host.state.queuedMessages).toHaveLength(2);
});
it('leaves an editor draft with inline skill tokens in the editor for the grouped path', () => {
const { host, setText, steerMessage, onCtrlS } = createCtrlSHarness({
editorText: 'check /skill:review',
queued: [{ text: 'plain note', agentId: 'main' }],
engineV2: true,
skillCommandMap: new Map([['skill:review', 'review']]),
});
onCtrlS();
expect(steerMessage).toHaveBeenCalledWith(host.session, [
{ text: 'plain note', parts: undefined, imageAttachmentIds: undefined },
]);
expect(setText).not.toHaveBeenCalled();
expect(host.state.queuedMessages).toEqual([]);
});
});

View file

@ -13,6 +13,7 @@ import type {
ApprovalResponse,
Event,
GoalSnapshot,
Session,
} from '@moonshot-ai/kimi-code-sdk';
import { afterEach, describe, expect, it, vi } from 'vitest';
@ -28,6 +29,7 @@ import { AssistantMessageComponent } from '#/tui/components/messages/assistant-m
import { StepSummaryComponent } from '#/tui/components/messages/step-summary';
import { ToolCallComponent } from '#/tui/components/messages/tool-call';
import {
groupTurns,
TRANSCRIPT_KEEP_RECENT_ASSISTANT,
TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED,
TRANSCRIPT_KEEP_RECENT_STEPS,
@ -45,6 +47,7 @@ import {
PluginsPanelComponent,
} from '#/tui/components/dialogs/plugins-selector';
import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui';
import type { SessionReplayRenderer } from '#/tui/controllers/session-replay';
import type { StreamingUIController } from '#/tui/controllers/streaming-ui';
import { handleFeedbackCommand } from '#/tui/commands/info';
import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text';
@ -116,6 +119,7 @@ function stripSgr(text: string): string {
interface MessageDriver {
state: TUIState;
streamingUI: StreamingUIController;
sessionReplay: SessionReplayRenderer;
pluginCommandMap: Map<string, string>;
sessionEventHandler: {
startSubscription(): void;
@ -253,6 +257,7 @@ function makeSession(overrides: Record<string, unknown> = {}) {
reloadPlugins: vi.fn(async () => ({ added: [], removed: [], errors: [] })),
reloadSession: vi.fn(async () => ({})),
activateSkill: vi.fn(async () => {}),
promptWithSkills: vi.fn(async () => {}),
getPluginInfo: vi.fn(async (id: string) => ({
id,
displayName: id,
@ -594,6 +599,624 @@ describe('KimiTUI message flow', () => {
expect(driver.getCurrentSessionId()).toBe('ses-lazy');
});
it('submits inline skill tokens with the prompt as one grouped submission (v2 engine)', async () => {
const session = makeSession({ id: 'ses-lazy' });
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(
session,
{
listWorkspaceSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
{ name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' },
]),
listPluginCommands: vi.fn(async () => []),
},
startupInput,
);
await (
driver as unknown as { refreshSkillCommands(): Promise<void> }
).refreshSkillCommands();
driver.handleUserInput('please /skill:review and /skill:security this change');
await vi.waitFor(() => {
expect(session.promptWithSkills).toHaveBeenCalledWith(
'please /skill:review and /skill:security this change',
[{ name: 'review' }, { name: 'security' }],
);
});
expect(session.prompt).not.toHaveBeenCalled();
expect(session.activateSkill).not.toHaveBeenCalled();
});
it('combines a leading skill command with later inline skills into one submission (v2 engine)', async () => {
const session = makeSession({ id: 'ses-lazy' });
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(
session,
{
listWorkspaceSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
{ name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' },
]),
listPluginCommands: vi.fn(async () => []),
},
startupInput,
);
await (
driver as unknown as { refreshSkillCommands(): Promise<void> }
).refreshSkillCommands();
driver.handleUserInput('/skill:review check this /skill:security');
await vi.waitFor(() => {
expect(session.promptWithSkills).toHaveBeenCalledWith(
'/skill:review check this /skill:security',
[{ name: 'review' }, { name: 'security' }],
);
});
expect(session.activateSkill).not.toHaveBeenCalled();
});
it('bundles a repeated leading skill as one bundled submission (v2 engine)', async () => {
const session = makeSession({ id: 'ses-lazy' });
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(
session,
{
listWorkspaceSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
]),
listPluginCommands: vi.fn(async () => []),
},
startupInput,
);
await (
driver as unknown as { refreshSkillCommands(): Promise<void> }
).refreshSkillCommands();
driver.handleUserInput('/skill:review check /skill:review');
await vi.waitFor(() => {
expect(session.promptWithSkills).toHaveBeenCalledWith('/skill:review check /skill:review', [
{ name: 'review' },
]);
});
expect(session.activateSkill).not.toHaveBeenCalled();
});
it('passes no args in a bundle while media rides the prompt parts (v2 engine)', async () => {
const session = makeSession({ id: 'ses-lazy' });
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(
session,
{
listWorkspaceSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
{ name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' },
]),
listPluginCommands: vi.fn(async () => []),
},
startupInput,
);
await (
driver as unknown as { refreshSkillCommands(): Promise<void> }
).refreshSkillCommands();
const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore;
const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1);
driver.handleUserInput(`/skill:review inspect ${attachment.placeholder} /skill:security`);
await vi.waitFor(() => {
expect(session.promptWithSkills).toHaveBeenCalledWith(
[
{ type: 'text', text: '/skill:review inspect ' },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } },
{ type: 'text', text: ' /skill:security' },
],
[{ name: 'review' }, { name: 'security' }],
);
});
});
it('bundles newline-separated skills with the leading one included (v2 engine)', async () => {
const session = makeSession({ id: 'ses-lazy' });
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(
session,
{
listWorkspaceSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
{ name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' },
]),
listPluginCommands: vi.fn(async () => []),
},
startupInput,
);
await (
driver as unknown as { refreshSkillCommands(): Promise<void> }
).refreshSkillCommands();
driver.handleUserInput('/skill:review\ncheck this /skill:security');
await vi.waitFor(() => {
expect(session.promptWithSkills).toHaveBeenCalledWith(
'/skill:review\ncheck this /skill:security',
[{ name: 'review' }, { name: 'security' }],
);
});
expect(session.prompt).not.toHaveBeenCalled();
});
it('scans inline skills in messages that start with an unknown slash token (v2 engine)', async () => {
const session = makeSession({ id: 'ses-lazy' });
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(
session,
{
listWorkspaceSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
]),
listPluginCommands: vi.fn(async () => []),
},
startupInput,
);
await (
driver as unknown as { refreshSkillCommands(): Promise<void> }
).refreshSkillCommands();
driver.handleUserInput('/dance please use /skill:review');
await vi.waitFor(() => {
expect(session.promptWithSkills).toHaveBeenCalledWith(
'/dance please use /skill:review',
[{ name: 'review' }],
);
});
expect(session.prompt).not.toHaveBeenCalled();
});
it('keeps inline skill tokens as plain text on the legacy engine', async () => {
const session = makeSession({ id: 'ses-1' });
const { driver } = await makeDriver(session, {
listSkills: undefined,
listPluginCommands: vi.fn(async () => []),
});
(
driver as unknown as { skillCommandMap: Map<string, string> }
).skillCommandMap.set('skill:review', 'review');
driver.handleUserInput('please /skill:review this');
await vi.waitFor(() => {
expect(session.prompt).toHaveBeenCalledWith('please /skill:review this');
});
expect(session.promptWithSkills).not.toHaveBeenCalled();
});
it('queues an inline-skill prompt while a goal is active (v2 engine)', async () => {
const session = makeSession({
id: 'ses-lazy',
listSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
]),
});
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(
session,
{
listWorkspaceSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
]),
listPluginCommands: vi.fn(async () => []),
},
startupInput,
);
await (
driver as unknown as { refreshSkillCommands(): Promise<void> }
).refreshSkillCommands();
// Materialize the lazy session first: an active goal only exists inside a
// live session, and lazy creation would refresh (and clear) the goal
// snapshot set up below.
await (driver as unknown as { ensureSession(): Promise<unknown> }).ensureSession();
driver.state.appState.goal = makeActiveGoalSnapshot();
driver.handleUserInput('check /skill:review');
expect(session.promptWithSkills).not.toHaveBeenCalled();
expect(driver.state.queuedMessages).toEqual([
expect.objectContaining({
text: 'check /skill:review',
inlineSkillActivations: [{ skillName: 'review' }],
}),
]);
});
it('queues a leading-combo bundle while busy instead of rejecting it (v2 engine)', async () => {
const session = makeSession({ id: 'ses-lazy' });
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(
session,
{
listWorkspaceSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
{ name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' },
]),
listPluginCommands: vi.fn(async () => []),
},
startupInput,
);
await (driver as unknown as { ensureSession(): Promise<unknown> }).ensureSession();
await (
driver as unknown as { refreshSkillCommands(): Promise<void> }
).refreshSkillCommands();
driver.state.appState.goal = makeActiveGoalSnapshot();
driver.handleUserInput('/skill:review check this /skill:security');
expect(session.promptWithSkills).not.toHaveBeenCalled();
expect(driver.state.queuedMessages).toEqual([
expect.objectContaining({
text: '/skill:review check this /skill:security',
inlineSkillActivations: [{ skillName: 'review' }, { skillName: 'security' }],
}),
]);
});
it('does not append a user entry when the grouped submission is rejected (v2 engine)', async () => {
const session = makeSession({
id: 'ses-lazy',
listSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
]),
promptWithSkills: vi.fn(async () => {
throw new Error('Skill "review" was not found');
}),
});
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(
session,
{
listWorkspaceSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
]),
listPluginCommands: vi.fn(async () => []),
},
startupInput,
);
await (
driver as unknown as { refreshSkillCommands(): Promise<void> }
).refreshSkillCommands();
driver.handleUserInput('please /skill:review');
await vi.waitFor(() => {
expect(session.promptWithSkills).toHaveBeenCalled();
});
await vi.waitFor(() => {
expect(driver.state.appState.streamingPhase).toBe('idle');
});
// A rejected group leaves no local undo anchor the engine never recorded.
expect(driver.state.transcriptEntries.filter((entry) => entry.kind === 'user')).toHaveLength(0);
});
it('renders a bundled replay submission as a single turn', async () => {
const session = makeSession({ id: 'ses-lazy' });
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(session, {}, startupInput);
(session.getResumeState as ReturnType<typeof vi.fn>).mockReturnValue({
sessionMetadata: {},
agents: {
main: {
config: { modelCapabilities: { max_context_tokens: 100 }, modelAlias: 'k2' },
plan: null,
permission: { mode: 'manual' },
swarmMode: false,
context: { history: [], tokenCount: 0 },
background: [],
toolStore: {},
replay: [
{
type: 'message',
time: 1,
message: {
role: 'user',
content: [{ type: 'text', text: 'earlier question' }],
toolCalls: [],
origin: { kind: 'user' },
},
},
{
type: 'message',
time: 2,
message: {
role: 'assistant',
content: [{ type: 'text', text: 'earlier answer' }],
toolCalls: [],
},
},
{
type: 'message',
time: 3,
message: {
role: 'user',
content: [{ type: 'text', text: 'hook note' }],
toolCalls: [],
origin: { kind: 'hook_result', event: 'UserPromptSubmit' },
},
},
{
type: 'message',
time: 4,
message: {
role: 'user',
content: [
{ type: 'text', text: 'skill card A body' },
{ type: 'text', text: 'skill card B body' },
{ type: 'text', text: 'please /skill:review and /skill:security' },
],
toolCalls: [],
origin: {
kind: 'user',
skillActivations: [
{ activationId: 'act-1', skillName: 'review' },
{ activationId: 'act-2', skillName: 'security' },
],
},
},
},
{
type: 'message',
time: 5,
message: {
role: 'assistant',
content: [{ type: 'text', text: 'bundled answer' }],
toolCalls: [],
},
},
{
type: 'message',
time: 6,
message: {
role: 'user',
content: [
{ type: 'text', text: 'skill card C body' },
{ type: 'text', text: 'please /commit' },
],
toolCalls: [],
origin: {
kind: 'user',
skillActivations: [{ activationId: 'act-3', skillName: 'commit' }],
},
},
},
],
},
},
});
const replayed = await driver.sessionReplay.hydrateFromReplay(session as unknown as Session);
expect(replayed).toBe(true);
const turns = groupTurns(driver.state.transcriptEntries);
expect(turns).toHaveLength(3);
// The hook result is projected inside the bundle's window (after the
// skill cards, before the prompt), matching the live event order.
expect(turns[1]!.entries.map((entry) => entry.kind)).toEqual([
'skill_activation',
'skill_activation',
'assistant',
'user',
'assistant',
]);
expect(turns[1]!.entries[2]!.hookResult).toBe(true);
// The user entry shows only the caller's own text — the rendered skill
// blocks the engine prepended to the content are stripped.
expect(turns[1]!.entries[3]!.content).toBe('please /skill:review and /skill:security');
expect(
turns[1]!.entries.slice(0, 2).map((entry) => entry.bundledWithPrompt),
).toEqual([true, true]);
expect(turns[2]!.entries.map((entry) => entry.kind)).toEqual(['skill_activation', 'user']);
expect(turns[2]!.entries[1]!.content).toBe('please /commit');
});
it('keeps hook results recorded before the oldest retained bundle within the replay limit', async () => {
const session = makeSession({ id: 'ses-lazy' });
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(session, {}, startupInput);
const plainTurn = (index: number) => [
{
type: 'message',
time: index * 2,
message: {
role: 'user',
content: [{ type: 'text', text: `question ${index}` }],
toolCalls: [],
origin: { kind: 'user' },
},
},
{
type: 'message',
time: index * 2 + 1,
message: {
role: 'assistant',
content: [{ type: 'text', text: `answer ${index}` }],
toolCalls: [],
},
},
];
(session.getResumeState as ReturnType<typeof vi.fn>).mockReturnValue({
sessionMetadata: {},
agents: {
main: {
config: { modelCapabilities: { max_context_tokens: 100 }, modelAlias: 'k2' },
plan: null,
permission: { mode: 'manual' },
swarmMode: false,
context: { history: [], tokenCount: 0 },
background: [],
toolStore: {},
replay: [
...plainTurn(0),
{
type: 'message',
time: 1,
message: {
role: 'user',
content: [{ type: 'text', text: 'hook note' }],
toolCalls: [],
origin: { kind: 'hook_result', event: 'UserPromptSubmit' },
},
},
{
type: 'message',
time: 2,
message: {
role: 'user',
content: [
{ type: 'text', text: 'review body' },
{ type: 'text', text: 'bundled question' },
],
toolCalls: [],
origin: {
kind: 'user',
skillActivations: [{ activationId: 'act-1', skillName: 'review' }],
},
},
},
{
type: 'message',
time: 3,
message: {
role: 'assistant',
content: [{ type: 'text', text: 'bundled answer' }],
toolCalls: [],
},
},
...Array.from({ length: 9 }, (_, i) => plainTurn(i + 10)).flat(),
],
},
},
});
const replayed = await driver.sessionReplay.hydrateFromReplay(session as unknown as Session);
expect(replayed).toBe(true);
const entries = driver.state.transcriptEntries;
const hookIndex = entries.findIndex((entry) => entry.hookResult === true);
expect(hookIndex).toBeGreaterThan(-1);
expect(entries[hookIndex]!.content).toContain('hook note');
const contents = entries.map((entry) => entry.content);
expect(contents.indexOf('Activated skill: review')).toBeLessThan(hookIndex);
expect(contents.indexOf('bundled question')).toBeGreaterThan(hookIndex);
expect(contents).not.toContain('question 0');
});
it('appends the user entry after the skill cards for a bundled submission (v2 engine)', async () => {
const session = makeSession({
id: 'ses-lazy',
listSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
]),
});
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(
session,
{
listWorkspaceSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
]),
listPluginCommands: vi.fn(async () => []),
},
startupInput,
);
await (
driver as unknown as { refreshSkillCommands(): Promise<void> }
).refreshSkillCommands();
// Hold the RPC open so the skill.activated event can land mid-flight,
// exactly how the in-process wiring delivers it during the call.
let release!: () => void;
const heldPrompt = new Promise<void>((resolve) => {
release = resolve;
});
(session.promptWithSkills as ReturnType<typeof vi.fn>).mockReturnValue(heldPrompt);
driver.handleUserInput('please /skill:review');
await vi.waitFor(() => {
expect(session.promptWithSkills).toHaveBeenCalled();
});
driver.sessionEventHandler.handleEvent(
{
type: 'skill.activated',
sessionId: 'ses-lazy',
agentId: 'main',
activationId: 'act-1',
skillName: 'review',
trigger: 'user-slash',
} as Event,
() => {},
);
release();
await vi.waitFor(() => {
expect(driver.state.transcriptEntries.map((entry) => entry.kind)).toEqual([
'skill_activation',
'user',
]);
});
expect(driver.state.transcriptEntries[0]!.bundledWithPrompt).toBe(true);
});
it('serializes concurrent lazy session creation (v2 engine)', async () => {
const session = makeSession({ id: 'ses-lazy' });
const startupInput: KimiTUIStartupInput = {
@ -3593,6 +4216,162 @@ command = "vim"
expect(stripSgr(renderBtwPanel(driver))).toContain('Q: What are you working on right now?');
});
it('sends /btw panel input with inline skills via promptWithSkills (v2 engine)', async () => {
const session = makeSession({
id: 'ses-lazy',
listSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
]),
});
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(
session,
{
listWorkspaceSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
]),
listPluginCommands: vi.fn(async () => []),
},
startupInput,
);
await (
driver as unknown as { refreshSkillCommands(): Promise<void> }
).refreshSkillCommands();
driver.handleUserInput('/btw');
await vi.waitFor(() => {
expect(session.startBtw).toHaveBeenCalledWith();
});
expect(stripSgr(renderBtwPanel(driver))).toContain('Ready for a side question...');
driver.handleUserInput('check /skill:review');
await vi.waitFor(() => {
expect(session.promptWithSkills).toHaveBeenCalledWith('check /skill:review', [
{ name: 'review' },
]);
});
expect(session.prompt).not.toHaveBeenCalled();
});
it('activates inline skills in the initial /btw prompt (v2 engine)', async () => {
const session = makeSession({
id: 'ses-lazy',
listSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
]),
});
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(
session,
{
listWorkspaceSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
]),
listPluginCommands: vi.fn(async () => []),
},
startupInput,
);
await (
driver as unknown as { refreshSkillCommands(): Promise<void> }
).refreshSkillCommands();
driver.handleUserInput('/btw check this /skill:review');
await vi.waitFor(() => {
expect(session.promptWithSkills).toHaveBeenCalledWith('check this /skill:review', [
{ name: 'review' },
]);
});
expect(session.prompt).not.toHaveBeenCalled();
});
it('activates a leading skill token in the initial /btw prompt (v2 engine)', async () => {
const session = makeSession({
id: 'ses-lazy',
listSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
]),
});
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(
session,
{
listWorkspaceSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
]),
listPluginCommands: vi.fn(async () => []),
},
startupInput,
);
await (
driver as unknown as { refreshSkillCommands(): Promise<void> }
).refreshSkillCommands();
driver.handleUserInput('/btw /skill:review check this');
await vi.waitFor(() => {
expect(session.promptWithSkills).toHaveBeenCalledWith('/skill:review check this', [
{ name: 'review' },
]);
});
expect(session.prompt).not.toHaveBeenCalled();
});
it('keeps /btw as the leading command when its prompt mentions multiple skills (v2 engine)', async () => {
const session = makeSession({
id: 'ses-lazy',
listSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
{ name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' },
]),
});
const startupInput: KimiTUIStartupInput = {
...makeStartupInput(),
engineV2: true,
cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' },
};
const { driver } = await makeDriver(
session,
{
listWorkspaceSkills: vi.fn(async () => [
{ name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' },
{ name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' },
]),
listPluginCommands: vi.fn(async () => []),
},
startupInput,
);
await (
driver as unknown as { refreshSkillCommands(): Promise<void> }
).refreshSkillCommands();
driver.handleUserInput('/btw check /skill:review /skill:security');
await vi.waitFor(() => {
expect(session.startBtw).toHaveBeenCalledWith();
});
await vi.waitFor(() => {
expect(session.promptWithSkills).toHaveBeenCalledWith('check /skill:review /skill:security', [
{ name: 'review' },
{ name: 'security' },
]);
});
expect(session.prompt).not.toHaveBeenCalled();
});
it('cancels an unused /btw side agent when closing an empty panel', async () => {
const session = makeSession();
const { driver } = await makeDriver(session);

View file

@ -13,7 +13,7 @@ import { promptPlatformSelection, promptLogoutProviderSelection } from '#/tui/co
import { BannerComponent } from '#/tui/components/chrome/banner';
import { WelcomeComponent } from '#/tui/components/chrome/welcome';
import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui';
import { REPLAY_TURN_LIMIT } from '#/tui/utils/message-replay';
import { REPLAY_FETCH_TURN_LIMIT } from '#/tui/utils/message-replay';
import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text';
import { quoteShellArg } from '#/utils/shell-quote';
import {
@ -560,7 +560,7 @@ describe('KimiTUI startup', () => {
expect(harness.resumeSession).toHaveBeenCalledWith({
id: 'ses-latest',
replayTurnLimit: REPLAY_TURN_LIMIT,
replayTurnLimit: REPLAY_FETCH_TURN_LIMIT,
});
expect(harness.createSession).not.toHaveBeenCalled();
expect(driver.state.startupState).toBe('ready');
@ -2024,7 +2024,7 @@ describe('KimiTUI startup', () => {
expect(harness.resumeSession).toHaveBeenCalledWith({
id: 'ses-latest',
replayTurnLimit: REPLAY_TURN_LIMIT,
replayTurnLimit: REPLAY_FETCH_TURN_LIMIT,
});
expect(harness.createSession).not.toHaveBeenCalled();
expect(driver.state.startupState).toBe('ready');
@ -2044,7 +2044,7 @@ describe('KimiTUI startup', () => {
expect(harness.resumeSession).toHaveBeenCalledWith({
id: 'ses-target',
replayTurnLimit: REPLAY_TURN_LIMIT,
replayTurnLimit: REPLAY_FETCH_TURN_LIMIT,
});
expect(driver.state.startupState).toBe('ready');
expect(driver.state.appState.sessionId).toBe('');
@ -2375,7 +2375,7 @@ describe('KimiTUI startup', () => {
});
expect(harness.resumeSession).toHaveBeenCalledWith({
id: 'ses-target',
replayTurnLimit: REPLAY_TURN_LIMIT,
replayTurnLimit: REPLAY_FETCH_TURN_LIMIT,
});
expect(driver.state.appState.sessionId).toBe('ses-target');
});

View file

@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import {
extractInlineSkillActivations,
findInlineSkillTokens,
} from '#/tui/utils/inline-skill-tokens';
const SKILL_COMMAND_MAP = new Map([
['skill:review', 'review'],
['skill:security', 'security'],
['commit', 'commit'],
]);
function findAll(text: string, includeLeading = false) {
return findInlineSkillTokens(text, {
isKnownSkill: (name) => SKILL_COMMAND_MAP.has(name) || SKILL_COMMAND_MAP.has(`skill:${name}`),
includeLeading,
});
}
describe('findInlineSkillTokens', () => {
it('finds tokens preceded by whitespace in first-occurrence order', () => {
expect(findAll('please /skill:review and /skill:security this')).toEqual([
{ commandName: 'skill:review', start: 7, end: 20 },
{ commandName: 'skill:security', start: 25, end: 40 },
]);
});
it('skips the leading slash-command area by default', () => {
expect(findAll('/skill:review')).toEqual([]);
expect(findAll('/skill:review')).toHaveLength(0);
expect(findAll('/skill:review', true)).toEqual([
{ commandName: 'skill:review', start: 0, end: 13 },
]);
});
it('finds tokens after the leading command and its arguments', () => {
expect(findAll('/skill:review some args /skill:security')).toEqual([
{ commandName: 'skill:security', start: 24, end: 39 },
]);
});
it('treats a newline as whitespace, so multi-line prompts work', () => {
expect(findAll('first line\n/skill:review more')).toEqual([
{ commandName: 'skill:review', start: 11, end: 24 },
]);
});
it('ignores slashes inside words, paths, and URLs', () => {
expect(findAll('and/or')).toEqual([]);
expect(findAll('see /tmp/file and https://example.com/a')).toEqual([]);
expect(findAll('1/2')).toEqual([]);
});
it('ignores unknown command names', () => {
expect(findAll('hello /not-a-skill world')).toEqual([]);
});
});
describe('extractInlineSkillActivations', () => {
it('resolves command names to skill names, deduped in first-occurrence order', () => {
expect(
extractInlineSkillActivations(
'/skill:review then /skill:review again /skill:security',
SKILL_COMMAND_MAP,
{ includeLeading: true },
),
).toEqual([{ skillName: 'review' }, { skillName: 'security' }]);
});
it('supports the skill: prefix fallback for bare names', () => {
expect(extractInlineSkillActivations('hello /review', SKILL_COMMAND_MAP)).toEqual([
{ skillName: 'review' },
]);
});
it('keeps builtin skill command names as-is', () => {
expect(extractInlineSkillActivations('please /commit this', SKILL_COMMAND_MAP)).toEqual([
{ skillName: 'commit' },
]);
});
it('returns an empty list when nothing matches', () => {
expect(extractInlineSkillActivations('no tokens here', SKILL_COMMAND_MAP)).toEqual([]);
});
});

View file

@ -27,13 +27,15 @@ Anything starting with `/` is treated as a slash command. Typing `/` opens a com
Active [Agent Skills](../customization/skills.md) are automatically registered as slash commands: ordinary external Skills are invoked with `/skill:<name>`, external sub-skills appear as dotted commands such as `/parent.child`, and built-in Skills appear directly as `/<name>` 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 `/<name>` directly.
Inside a longer prompt, typing `/` after whitespace — including at the start of a later line — opens a skill-only completion menu. You can reference several Skills in one prompt this way: Kimi Code activates them together and runs them with the prompt as a single turn (one `/undo` reverts the whole submission), and the prompt text is sent unchanged. A Skill mention in a prompt never carries arguments — activation is by name only; arguments remain a standalone `/skill:<name> args` concept. Built-in and plugin commands still only work at the very start of the input.
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).
## File references
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. After whitespace, `/` offers Skill completions only; use a leading `/` for built-in and plugin commands.
## Approval flow

View file

@ -27,13 +27,15 @@ Kimi Code CLI 支持在输入框中直接粘贴图片和视频,让 AI 结合
已激活的 [Agent Skills](../customization/skills.md) 会自动注册为斜杠命令:普通外部 Skill 以 `/skill:<name>` 调用,外部子 Skill 以 `/parent.child` 这样的点分命令显示,内置 Skill 直接以 `/<name>` 出现在斜杠命令面板中;若外部 Skill 名称与系统斜杠命令不冲突,也可以省略 `skill:` 前缀直接输入 `/<name>`
在较长的提示词中,也可以在空白字符后(包括后续行的行首)输入 `/` 打开仅包含 Skill 的补全菜单。这样可以在一条提示词里引用多个 SkillKimi Code 会将它们一起激活,与提示词作为同一轮次运行(一次 `/undo` 即可整体撤销),提示词原文保持不变。提示词中的 Skill 引用不携带参数——只按名称激活;参数仍是单独以 `/skill:<name> args` 调用时的概念。内置命令和 plugin 命令仍需放在输入开头。
部分命令仅在 Agent 空闲时可用,流式输出或上下文压缩期间需先按 `Esc` 中断。`/yolo``/plan``/help``/btw` 等模式切换和查询类命令则始终可用。全部命令说明见[斜杠命令参考](../reference/slash-commands.md)。
## 文件引用
键入 `@` 触发文件路径补全选中后在输入中插入相对路径Agent 读取时会直接加载该文件内容。文件引用在 git 和非 git 目录都可用;文件夹候选会以 `/` 结尾方便继续补全其下路径。如果快速搜索辅助工具仍在下载Kimi Code 会先回退到基础的文件系统扫描。隐藏路径也可补全,但 `.git` 会从候选中排除。
> `@` 引用和斜杠命令是两套不同的机制:`@` 向 Agent 提供文件上下文,`/` 调用内置功能或 Skill。前面有空白字符时输入 `/` 会按普通文本处理,不会打开斜杠命令菜单
> `@` 引用和斜杠命令是两套不同的机制:`@` 向 Agent 提供文件上下文,`/` 调用内置功能或 Skill。空白字符后的 `/` 仅提供 Skill 补全;内置命令和 plugin 命令需要使用开头的 `/`
## 审批流程

View file

@ -12,6 +12,8 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the
4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` / `editor.ts` — negative-width `repeat` guards**: the `repeat` counts for blank lines, horizontal rules, and the editor's top/bottom borders are clamped to ≥ 0 (two editor border sites; markdown's emptyLine and hr — the hr site is currently unreachable from the render entry and is purely defensive). Guarding tests: the "negative width safety" cases — Text's lives in `test/tui-render.test.ts` (Text has no dedicated test file), Markdown's and TruncatedText's live in their own test files; the editor's is "does not throw at zero or negative widths" inside the "Editor narrow width rendering" group in `test/editor.test.ts`.
5. **`src/tui.ts` — per-frame processed-line reuse**: `doRender` keeps the previous frame's raw lines (`previousRawLines`), their processed output (`previousLines`), and per-line kitty image ids (`previousLineImageIds`). A line whose raw string is reference-identical to the previous frame's reuses its processed output (truncation + `normalizeTerminalOutput` + trailing `SEGMENT_RESET`) verbatim, so a steady-state frame costs O(total lines) pointer comparisons plus O(changed lines) real work instead of re-normalizing every line. Image-id consumers (`expandChangedRangeForKittyImages`, `deleteChangedKittyImages`, the frame-end id union) read the cached per-line ids rather than re-scanning line text; upstream has no such cache and re-processes every line every frame (upstream also has `applyLineResets`, which this divergence inlines into the processed-line build). Reuse is only valid when the terminal width is unchanged; width changes re-render everything at the new width so references never match. Guarding tests: "TUI steady-frame processed-line reuse" in `test/tui-render.test.ts`.
6. **`src/components/markdown.ts``CjkBoundaryUrlTokenizer` autolink CJK boundary**: marked's GFM autolink accepts any non-space characters after the domain and its backpedal strips only ASCII trailing punctuation, so CJK/full-width punctuation right after a bare URL is absorbed into the link text and href (`.../pull/232本地` renders as one anchor with a CJK target). The `CjkBoundaryUrlTokenizer` subclass (the tokenizer actually registered on the parser) cuts the match at the first CJK punctuation character before the ASCII backpedal; full-width parentheses follow GFM's ASCII-paren rule — balanced pairs stay in the URL (`.../wiki/中华人民共和国1949年`, punctuation inside them included), only unbalanced ones terminate the match. `StrictStrikethroughTokenizer` itself stays byte-identical to upstream. Guarding tests: the bare-URL CJK cases in the "Links" group in `test/markdown.test.ts`.
7. **`src/components/editor.ts` — opt-in inline slash autocomplete (`inlineSlashTrigger`)**: when enabled, `/` after whitespace mid-input or at the start of a subsequent line auto-triggers autocomplete (`isAtInlineSlashTrigger`), and typing further token characters (letters, digits, `.`, `-`, `_`, `:`) inside that inline token re-triggers the request (`isInInlineSlashContext`) so the in-flight request from the bare `/` cannot go stale before the menu appears; `:` is required because external skill tokens are shaped `/skill:<name>`. Off by default — prose slashes (paths, fractions) keep upstream behavior. Guarding tests: the "Inline slash trigger" group in `test/editor.test.ts`.
8. **`src/autocomplete.ts` / `src/components/select-list.ts` / `src/components/editor.ts``data` on autocomplete items + Enter non-submit for marked completions**: autocomplete items may carry an opaque `data` record; when the selected item's `data.inlineSkill` is set, confirming with Enter applies the completion without submitting the editor (ordinary completions keep upstream Enter-submits behavior). Guarding tests: "does not submit when confirming an inline-marked completion with Enter" and "still submits when confirming an unmarked slash completion with Enter" in `test/editor.test.ts`.
## Acceptance after syncing from upstream

View file

@ -220,6 +220,8 @@ export interface AutocompleteItem {
value: string;
label: string;
description?: string;
/** Provider-specific metadata forwarded unchanged to applyCompletion. */
data?: Record<string, unknown>;
}
type Awaitable<T> = T | Promise<T>;

View file

@ -246,6 +246,13 @@ export interface EditorOptions {
paddingX?: number;
autocompleteMaxVisible?: number;
disablePasteBurst?: boolean;
/**
* When true, typing `/` after whitespace mid-input also auto-triggers
* autocomplete, so providers can offer inline completions (e.g. inline
* skill selection). The default providers treat such `/` as a path prefix,
* so the default is false to avoid surprising other consumers.
*/
inlineSlashTrigger?: boolean;
}
const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
@ -329,6 +336,7 @@ export class Editor implements Component, Focusable {
// Non-bracketed paste-burst fallback
private pasteBurst = new PasteBurst();
private disablePasteBurst: boolean = false;
private inlineSlashTrigger: boolean = false;
// Prompt history for up/down navigation
private history: string[] = [];
@ -386,6 +394,7 @@ export class Editor implements Component, Focusable {
const maxVisible = options.autocompleteMaxVisible ?? 5;
this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
this.disablePasteBurst = options.disablePasteBurst ?? false;
this.inlineSlashTrigger = options.inlineSlashTrigger ?? false;
}
/** Set of currently valid paste IDs, for marker-aware segmentation. */
@ -806,7 +815,11 @@ export class Editor implements Component, Focusable {
this.state.cursorLine = result.cursorLine;
this.setCursorCol(result.cursorCol);
if (this.autocompletePrefix.startsWith("/")) {
// Slash-command completions submit on confirm (Enter runs the
// command); inline completions marked by the provider (e.g. an
// inline skill token mid-prompt) are content edits, so confirm
// behaves like Tab and never submits.
if (this.autocompletePrefix.startsWith("/") && selected.data?.["inlineSkill"] !== true) {
this.cancelAutocomplete();
// Fall through to submit
} else {
@ -1244,8 +1257,11 @@ 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()) {
// Auto-trigger for "/" at the start of a line (slash commands). When
// the editor opts in, also trigger "/" after whitespace mid-input so
// the provider can offer inline completions; ordinary prose slashes
// (paths, fractions) are left untouched.
if (char === "/" && (this.isAtStartOfMessage() || (this.inlineSlashTrigger && this.isAtInlineSlashTrigger()))) {
this.tryTriggerAutocomplete();
}
// Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries
@ -1258,7 +1274,7 @@ export class Editor implements Component, Focusable {
}
}
// Also auto-trigger when typing letters in a slash command or symbol completion context
else if (/[a-zA-Z0-9.\-_]/.test(char)) {
else if (/[a-zA-Z0-9.:\-_]/.test(char)) {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
// Check if we're in a slash command (with or without space for arguments)
@ -1269,6 +1285,12 @@ export class Editor implements Component, Focusable {
else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
this.tryTriggerAutocomplete();
}
// Check if we're typing an inline slash token (opt-in trigger):
// without this the slash's in-flight request goes stale as the
// token grows and no fresh request replaces it.
else if (this.isInInlineSlashContext(textBeforeCursor)) {
this.tryTriggerAutocomplete();
}
}
} else {
this.updateAutocomplete();
@ -2217,6 +2239,38 @@ export class Editor implements Component, Focusable {
return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
}
// Helper method to check if "/" was typed after whitespace mid-input, which
// providers can use for inline completions while leaving ordinary prose
// slashes (e.g. paths, fractions) untouched. Also covers "/" at the start
// of a non-first line, so inline completion works across multi-line input.
private isAtInlineSlashTrigger(): boolean {
if (this.isAtStartOfMessage()) return false;
const currentLine = this.state.lines[this.state.cursorLine] || "";
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
if (!beforeCursor.endsWith("/")) return false;
// "/" at the start of a subsequent line.
if (this.state.cursorLine > 0 && beforeCursor.trim() === "/") {
return true;
}
const charBeforeSlash = beforeCursor[beforeCursor.length - 2];
return charBeforeSlash === " " || charBeforeSlash === "\t";
}
// Whether the token being typed is an inline slash token (opt-in trigger):
// a "/" after whitespace mid-input, or a "/" opening a subsequent line —
// isSlashMenuAllowed confines the slash-command menu to the first line,
// while the inline trigger deliberately covers later lines too. The
// leading slash-command context is owned by isInSlashCommandContext.
private isInInlineSlashContext(textBeforeCursor: string): boolean {
if (!this.inlineSlashTrigger) return false;
if (this.isInSlashCommandContext(textBeforeCursor)) return false;
if (/[ \t]\/[a-zA-Z0-9.:\-_]*$/.test(textBeforeCursor)) return true;
return this.state.cursorLine > 0 && /^\/[a-zA-Z0-9.:\-_]*$/.test(textBeforeCursor);
}
private isInSlashCommandContext(textBeforeCursor: string): boolean {
return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");
}

View file

@ -13,6 +13,8 @@ export interface SelectItem {
value: string;
label: string;
description?: string;
/** Provider-specific metadata (e.g. autocomplete item markers), passed through unchanged. */
data?: Record<string, unknown>;
}
export interface SelectListTheme {

View file

@ -3127,6 +3127,189 @@ describe("Editor component", () => {
});
});
describe("Inline slash trigger", () => {
const inlineProvider: AutocompleteProvider = {
getSuggestions: async (lines, cursorLine, cursorCol) => {
const beforeCursor = (lines[cursorLine] || "").slice(0, cursorCol);
if (!beforeCursor.endsWith("/")) return null;
return {
items: [{ value: "skill:review", label: "skill:review" }],
prefix: "/",
};
},
applyCompletion,
};
it("does not trigger for `/` after whitespace mid-input by default", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.setAutocompleteProvider(inlineProvider);
for (const ch of "hello ") editor.handleInput(ch);
editor.handleInput("/");
await flushAutocomplete();
assert.strictEqual(editor.isShowingAutocomplete(), false);
});
it("triggers for `/` after whitespace mid-input when inlineSlashTrigger is on", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme, { inlineSlashTrigger: true });
editor.setAutocompleteProvider(inlineProvider);
for (const ch of "hello ") editor.handleInput(ch);
editor.handleInput("/");
await flushAutocomplete();
assert.strictEqual(editor.isShowingAutocomplete(), true);
});
it("retriggers inline completion as token characters arrive with the slash's request in flight", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme, { inlineSlashTrigger: true });
editor.setAutocompleteProvider({
getSuggestions: async (lines, cursorLine, cursorCol) => {
const beforeCursor = (lines[cursorLine] || "").slice(0, cursorCol);
const match = /\/skill:\w*$/.exec(beforeCursor);
if (match === null) return null;
return {
items: [{ value: "skill:review", label: "skill:review" }],
prefix: match[0],
};
},
applyCompletion,
});
// The slash and its first letters land back-to-back, the way one
// stdin chunk delivers them: the slash's request is still in flight
// while the letters arrive with no autocomplete state yet.
for (const ch of "hello /skill:r") editor.handleInput(ch);
await flushAutocomplete();
assert.strictEqual(editor.isShowingAutocomplete(), true);
});
it("retriggers inline completion on later lines as the token grows", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme, { inlineSlashTrigger: true });
editor.setAutocompleteProvider({
getSuggestions: async (lines, cursorLine, cursorCol) => {
const beforeCursor = (lines[cursorLine] || "").slice(0, cursorCol);
const match = /\/skill:\w*$/.exec(beforeCursor);
if (match === null) return null;
return {
items: [{ value: "skill:review", label: "skill:review" }],
prefix: match[0],
};
},
applyCompletion,
});
for (const ch of "hello\n/skill:r") editor.handleInput(ch);
await flushAutocomplete();
assert.strictEqual(editor.isShowingAutocomplete(), true);
});
it("retriggers inline completion when a colon follows the token name", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme, { inlineSlashTrigger: true });
let calls = 0;
editor.setAutocompleteProvider({
getSuggestions: async (lines, cursorLine, cursorCol) => {
calls += 1;
const beforeCursor = (lines[cursorLine] || "").slice(0, cursorCol);
const match = /\/skill:\w*$/.exec(beforeCursor);
if (match === null) return null;
return {
items: [{ value: "skill:review", label: "skill:review" }],
prefix: match[0],
};
},
applyCompletion,
});
for (const ch of "hello /skill") editor.handleInput(ch);
await flushAutocomplete();
const beforeColon = calls;
editor.handleInput(":");
await flushAutocomplete();
assert.ok(calls > beforeColon, "expected the colon to retrigger completion");
editor.handleInput("r");
await flushAutocomplete();
assert.strictEqual(editor.isShowingAutocomplete(), true);
});
it("triggers for `/` at the start of a later line when inlineSlashTrigger is on", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme, { inlineSlashTrigger: true });
editor.setAutocompleteProvider(inlineProvider);
for (const ch of "first") editor.handleInput(ch);
editor.handleInput("\n");
editor.handleInput("/");
await flushAutocomplete();
assert.strictEqual(editor.isShowingAutocomplete(), true);
});
it("does not trigger for `/` inside a word even when inlineSlashTrigger is on", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme, { inlineSlashTrigger: true });
editor.setAutocompleteProvider(inlineProvider);
for (const ch of "and/") editor.handleInput(ch);
await flushAutocomplete();
assert.strictEqual(editor.isShowingAutocomplete(), false);
});
function inlineProviderWith(item: { value: string; label: string; data?: Record<string, unknown> }): AutocompleteProvider {
return {
getSuggestions: async (lines, cursorLine, cursorCol) => {
const beforeCursor = (lines[cursorLine] || "").slice(0, cursorCol);
if (!beforeCursor.endsWith("/")) return null;
return { items: [item], prefix: "/" };
},
applyCompletion,
};
}
it("does not submit when confirming an inline-marked completion with Enter", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme, { inlineSlashTrigger: true });
let submitted: string | undefined;
editor.onSubmit = (text) => {
submitted = text;
};
editor.setAutocompleteProvider(
inlineProviderWith({ value: "skill:review", label: "skill:review", data: { inlineSkill: true } }),
);
for (const ch of "hello ") editor.handleInput(ch);
editor.handleInput("/");
await flushAutocomplete();
assert.strictEqual(editor.isShowingAutocomplete(), true);
editor.handleInput("\r");
await flushAutocomplete();
assert.strictEqual(submitted, undefined);
assert.strictEqual(editor.getText(), "hello skill:review");
});
it("still submits when confirming an unmarked slash completion with Enter", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme, { inlineSlashTrigger: true });
let submitted: string | undefined;
editor.onSubmit = (text) => {
submitted = text;
};
editor.setAutocompleteProvider(inlineProviderWith({ value: "help", label: "help" }));
for (const ch of "hello ") editor.handleInput(ch);
editor.handleInput("/");
await flushAutocomplete();
assert.strictEqual(editor.isShowingAutocomplete(), true);
editor.handleInput("\r");
await flushAutocomplete();
assert.strictEqual(submitted, "hello help");
});
});
describe("Character jump (Ctrl+])", () => {
it("jumps forward to first occurrence of character on same line", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);