diff --git a/.changeset/inline-multi-skill-tui.md b/.changeset/inline-multi-skill-tui.md new file mode 100644 index 0000000000..f2151d43c9 --- /dev/null +++ b/.changeset/inline-multi-skill-tui.md @@ -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). diff --git a/.changeset/inline-slash-trigger-pi-tui.md b/.changeset/inline-slash-trigger-pi-tui.md new file mode 100644 index 0000000000..f17dafc9e1 --- /dev/null +++ b/.changeset/inline-slash-trigger-pi-tui.md @@ -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. diff --git a/apps/kimi-code/src/tui/commands/btw.ts b/apps/kimi-code/src/tui/commands/btw.ts index a55ceca0a8..72b3b5806b 100644 --- a/apps/kimi-code/src/tui/commands/btw.ts +++ b/apps/kimi-code/src/tui/commands/btw.ts @@ -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 { @@ -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)}`); } diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 08138365bf..6bf367f64b 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -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; showSessionPicker(): Promise; 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; 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 { const parsedCommand = parseSlashInput(input); const intent = resolveSlashCommandInput({ diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 23d5a673e2..e607b929a3 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -90,6 +90,9 @@ async function undoByCount(host: SlashCommandHost, count: number): Promise(); + 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(); + 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, ): 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 ); } diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index a8b87ae0c5..21a272ef42 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -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 = new Map(); + private skillCommandNames: ReadonlySet = new Set(); setArgumentHints(hints: ReadonlyMap): void { this.argumentHints = hints; } + setSkillCommandNames(names: ReadonlySet): 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, + 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); } diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index 722682db60..daf72ca643 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -10,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, ) { 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) { diff --git a/apps/kimi-code/src/tui/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts index 55ad576afd..3dbb88508c 100644 --- a/apps/kimi-code/src/tui/components/panes/btw-panel.ts +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -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 { diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index a8ee45e61b..186e1b85bd 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -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(); }); diff --git a/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts b/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts index 4a1aa4626c..6926baa755 100644 --- a/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts +++ b/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts @@ -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; sendNormalUserInput(text: string, preExtracted?: ExtractionResult): Promise; + sendInlineSkillUserInput( + text: string, + activations: readonly InlineSkillActivation[], + preExtracted?: ExtractionResult, + ): Promise; } 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 { 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 { + 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. */ diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index b65ecf9c0f..962065ca99 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -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; 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 | 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); diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 9cb1029bdc..9e73c92f20 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -595,6 +595,7 @@ export class SessionEventHandler { turnId: String(event.turnId), renderMode: 'markdown', content: formatHookResultMarkdown(event), + hookResult: true, }); this.host.patchLivePane({ mode: 'idle', diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 1eebd5a720..db5589a31e 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -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 { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 325c03a6be..0b40460f43 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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 { + 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; + 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, + ): Promise { + 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); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 62fdd7b990..3edcda053c 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -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: 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[]; } /** diff --git a/apps/kimi-code/src/tui/utils/inline-skill-tokens.ts b/apps/kimi-code/src/tui/utils/inline-skill-tokens.ts new file mode 100644 index 0000000000..5444304a95 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/inline-skill-tokens.ts @@ -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, + options?: ExtractInlineSkillActivationsOptions, +): InlineSkillActivation[] { + const tokens = findInlineSkillTokens(text, { + isKnownSkill: (commandName) => + skillCommandMap.has(commandName) || skillCommandMap.has(`skill:${commandName}`), + includeLeading: options?.includeLeading, + }); + + const seen = new Set(); + 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; +} diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index c068ac1068..83ec5e375c 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -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 { diff --git a/apps/kimi-code/test/tui/commands/undo.test.ts b/apps/kimi-code/test/tui/commands/undo.test.ts new file mode 100644 index 0000000000..df219bb859 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/undo.test.ts @@ -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 & Pick): 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); + }); +}); diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index b53b49a830..de34a88c0b 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -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[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); + }); + }); }); diff --git a/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts b/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts index d47f29b563..02e885b3d0 100644 --- a/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts +++ b/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts @@ -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'); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/cache-hint-controller.test.ts b/apps/kimi-code/test/tui/controllers/cache-hint-controller.test.ts index 99834d9d9a..7e71860629 100644 --- a/apps/kimi-code/test/tui/controllers/cache-hint-controller.test.ts +++ b/apps/kimi-code/test/tui/controllers/cache-hint-controller.test.ts @@ -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(); diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts index 2be5610319..626728e781 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -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', () => { + function createCtrlSHarness(options: { + editorText: string; + queued: Array>; + engineV2?: boolean; + skillCommandMap?: Map; + }) { + const steerMessage = vi.fn(); + const steerSkillActivation = vi.fn(); + const updateQueueDisplay = vi.fn(); + const setText = vi.fn(); const editor: Record 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, + 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 steerMessage = vi.fn(); - const steerSkillActivation = vi.fn(); - const updateQueueDisplay = vi.fn(); - const session = { id: 'ses-1' }; 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([]); + }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 31d60285d4..eb6328e4f0 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -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; sessionEventHandler: { startSubscription(): void; @@ -253,6 +257,7 @@ function makeSession(overrides: Record = {}) { 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 } + ).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 } + ).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 } + ).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 } + ).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 } + ).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 } + ).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 } + ).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 } + ).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 }).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 }).ensureSession(); + await ( + driver as unknown as { refreshSkillCommands(): Promise } + ).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 } + ).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).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).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 } + ).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((resolve) => { + release = resolve; + }); + (session.promptWithSkills as ReturnType).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 } + ).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 } + ).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 } + ).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 } + ).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); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 1d3132a352..b2184ee9b6 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -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'); }); diff --git a/apps/kimi-code/test/tui/utils/inline-skill-tokens.test.ts b/apps/kimi-code/test/tui/utils/inline-skill-tokens.test.ts new file mode 100644 index 0000000000..d307a56117 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/inline-skill-tokens.test.ts @@ -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([]); + }); +}); diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index ff59fe8b23..9787f58a34 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -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:`, external sub-skills appear as dotted commands such as `/parent.child`, and built-in Skills appear directly as `/` in the slash command panel. If an external skill name does not conflict with a system slash command, you can also drop the `skill:` prefix and type `/` directly. +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: 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 diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index 628525e9de..5d034702eb 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -27,13 +27,15 @@ Kimi Code CLI 支持在输入框中直接粘贴图片和视频,让 AI 结合 已激活的 [Agent Skills](../customization/skills.md) 会自动注册为斜杠命令:普通外部 Skill 以 `/skill:` 调用,外部子 Skill 以 `/parent.child` 这样的点分命令显示,内置 Skill 直接以 `/` 出现在斜杠命令面板中;若外部 Skill 名称与系统斜杠命令不冲突,也可以省略 `skill:` 前缀直接输入 `/`。 +在较长的提示词中,也可以在空白字符后(包括后续行的行首)输入 `/` 打开仅包含 Skill 的补全菜单。这样可以在一条提示词里引用多个 Skill:Kimi Code 会将它们一起激活,与提示词作为同一轮次运行(一次 `/undo` 即可整体撤销),提示词原文保持不变。提示词中的 Skill 引用不携带参数——只按名称激活;参数仍是单独以 `/skill: args` 调用时的概念。内置命令和 plugin 命令仍需放在输入开头。 + 部分命令仅在 Agent 空闲时可用,流式输出或上下文压缩期间需先按 `Esc` 中断。`/yolo`、`/plan`、`/help`、`/btw` 等模式切换和查询类命令则始终可用。全部命令说明见[斜杠命令参考](../reference/slash-commands.md)。 ## 文件引用 键入 `@` 触发文件路径补全,选中后在输入中插入相对路径,Agent 读取时会直接加载该文件内容。文件引用在 git 和非 git 目录都可用;文件夹候选会以 `/` 结尾,方便继续补全其下路径。如果快速搜索辅助工具仍在下载,Kimi Code 会先回退到基础的文件系统扫描。隐藏路径也可补全,但 `.git` 会从候选中排除。 -> `@` 引用和斜杠命令是两套不同的机制:`@` 向 Agent 提供文件上下文,`/` 调用内置功能或 Skill。前面有空白字符时输入 `/` 会按普通文本处理,不会打开斜杠命令菜单。 +> `@` 引用和斜杠命令是两套不同的机制:`@` 向 Agent 提供文件上下文,`/` 调用内置功能或 Skill。空白字符后的 `/` 仅提供 Skill 补全;内置命令和 plugin 命令需要使用开头的 `/`。 ## 审批流程 diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 4a5833437c..3ee3f9a003 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -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:`. 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 diff --git a/packages/pi-tui/src/autocomplete.ts b/packages/pi-tui/src/autocomplete.ts index 7ecf6a0a2d..1b74cfb545 100644 --- a/packages/pi-tui/src/autocomplete.ts +++ b/packages/pi-tui/src/autocomplete.ts @@ -220,6 +220,8 @@ export interface AutocompleteItem { value: string; label: string; description?: string; + /** Provider-specific metadata forwarded unchanged to applyCompletion. */ + data?: Record; } type Awaitable = T | Promise; diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index af50b13768..276cac7e7a 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -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("/"); } diff --git a/packages/pi-tui/src/components/select-list.ts b/packages/pi-tui/src/components/select-list.ts index 26fdb685ad..61db52af99 100644 --- a/packages/pi-tui/src/components/select-list.ts +++ b/packages/pi-tui/src/components/select-list.ts @@ -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; } export interface SelectListTheme { diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 379594db58..7ed25e0241 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -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 }): 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);