mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-18 05:05:28 +00:00
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
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:
parent
61591bce09
commit
44a6c70e66
32 changed files with 2411 additions and 100 deletions
5
.changeset/inline-multi-skill-tui.md
Normal file
5
.changeset/inline-multi-skill-tui.md
Normal 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).
|
||||
5
.changeset/inline-slash-trigger-pi-tui.md
Normal file
5
.changeset/inline-slash-trigger-pi-tui.md
Normal 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.
|
||||
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -595,6 +595,7 @@ export class SessionEventHandler {
|
|||
turnId: String(event.turnId),
|
||||
renderMode: 'markdown',
|
||||
content: formatHookResultMarkdown(event),
|
||||
hookResult: true,
|
||||
});
|
||||
this.host.patchLivePane({
|
||||
mode: 'idle',
|
||||
|
|
|
|||
|
|
@ -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('&', '&');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
97
apps/kimi-code/src/tui/utils/inline-skill-tokens.ts
Normal file
97
apps/kimi-code/src/tui/utils/inline-skill-tokens.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
102
apps/kimi-code/test/tui/commands/undo.test.ts
Normal file
102
apps/kimi-code/test/tui/commands/undo.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
86
apps/kimi-code/test/tui/utils/inline-skill-tokens.test.ts
Normal file
86
apps/kimi-code/test/tui/utils/inline-skill-tokens.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 的补全菜单。这样可以在一条提示词里引用多个 Skill:Kimi 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 命令需要使用开头的 `/`。
|
||||
|
||||
## 审批流程
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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("/");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue