From 094718bc252069491e6be76004bd18bcf696bd2c Mon Sep 17 00:00:00 2001 From: Drilmo Date: Fri, 6 Feb 2026 02:35:45 +0100 Subject: [PATCH] feat(slash-commands): add type/source indicators, skill invocation, and argument hints Enhance the slash command menu with visual type badges, source labels, skill discovery/invocation via "/", type-colored input highlights, and argument-hint ghost text from SKILL.md frontmatter. --- .changeset/slash-command-enhancements.md | 12 +++ src/core/slash-commands/kilo.ts | 18 ++++ src/core/task/Task.ts | 4 + src/core/webview/webviewMessageHandler.ts | 1 + src/services/skills/SkillsManager.ts | 5 + src/shared/skills.ts | 1 + .../src/components/chat/ChatTextArea.tsx | 33 +++++-- .../src/components/chat/SlashCommandMenu.tsx | 56 ++++++++++- .../src/context/ExtensionStateContext.tsx | 16 +++ webview-ui/src/kilocode.css | 28 ++++++ webview-ui/src/utils/slash-commands.ts | 99 ++++++++++++++++--- 11 files changed, 249 insertions(+), 24 deletions(-) create mode 100644 .changeset/slash-command-enhancements.md diff --git a/.changeset/slash-command-enhancements.md b/.changeset/slash-command-enhancements.md new file mode 100644 index 00000000000..d4f0d6e1b33 --- /dev/null +++ b/.changeset/slash-command-enhancements.md @@ -0,0 +1,12 @@ +--- +"kilo-code": minor +--- + +feat: Enhance slash command menu with type indicators, source badges, skill invocation, and argument hints + +- Add type badges (command, mode, workflow, skill) with distinct colors to the "/" slash command dropdown +- Add source labels (project, global, organization) for non-built-in items +- Add skills to the "/" slash command menu, allowing discovery and invocation of installed skills +- Color-code slash command highlights in the text input to match their type +- Support Claude Code's `argument-hint` SKILL.md frontmatter field (from the Agent Skills specification), displayed as ghost text in the input after selecting a skill command to guide usage (e.g. `[-a] [-x] `) +- Parse and propagate `argument-hint` from skill frontmatter through the full data pipeline (SkillsManager → ExtensionStateContext → SlashCommand) diff --git a/src/core/slash-commands/kilo.ts b/src/core/slash-commands/kilo.ts index 384a73c833e..3e1da56c4ca 100644 --- a/src/core/slash-commands/kilo.ts +++ b/src/core/slash-commands/kilo.ts @@ -1,6 +1,7 @@ // kilocode_change whole file import { ClineRulesToggles } from "../../shared/cline-rules" +import { SkillMetadata } from "../../shared/skills" import fs from "fs/promises" import path from "path" import { @@ -27,6 +28,7 @@ export async function parseKiloSlashCommands( text: string, localWorkflowToggles: ClineRulesToggles, globalWorkflowToggles: ClineRulesToggles, + skills: SkillMetadata[] = [], ): Promise<{ processedText: string; needsRulesFileCheck: boolean }> { const condenseAliases = condenseToolResponse @@ -75,6 +77,22 @@ export async function parseKiloSlashCommands( console.error(`Error reading workflow file ${matchingWorkflow.fullPath}: ${error}`) } } + + // Check for matching skill + const matchingSkill = skills.find((skill) => skill.name === commandName) + if (matchingSkill) { + try { + const skillContent = (await fs.readFile(matchingSkill.path, "utf8")).trim() + + const processedText = + `\n${skillContent}\n\n` + + textWithoutSlashCommand + + return { processedText, needsRulesFileCheck: false } + } catch (error) { + console.error(`Error reading skill file ${matchingSkill.path}: ${error}`) + } + } } // if no supported commands are found, return the original text diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 38510d20d24..ba2ac156f88 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4047,10 +4047,14 @@ export class Task extends EventEmitter implements TaskLike { ) // when parsing slash commands, we still want to allow the user to provide their desired context + const currentMode = await this.getTaskMode() + const skillsForMode = + this.providerRef.deref()?.getSkillsManager()?.getSkillsForMode(currentMode) ?? [] const { processedText, needsRulesFileCheck: needsCheck } = await parseKiloSlashCommands( parsedText.text, localWorkflowToggles, globalWorkflowToggles, + skillsForMode, ) if (needsCheck) { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 1e8531c41b7..53750b8cced 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -527,6 +527,7 @@ export const webviewMessageHandler = async ( provider.postStateToWebview() provider.postRulesDataToWebview() // kilocode_change: send workflows and rules immediately + provider.postSkillsDataToWebview() // kilocode_change: send skills data for slash command dropdown provider.workspaceTracker?.initializeFilePaths() // Don't await. getTheme().then((theme) => provider.postMessageToWebview({ type: "theme", text: JSON.stringify(theme) })) diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts index bee1499bfdf..82b0aee26fd 100644 --- a/src/services/skills/SkillsManager.ts +++ b/src/services/skills/SkillsManager.ts @@ -165,6 +165,10 @@ export class SkillsManager { return } + // Extract optional argument-hint from frontmatter + const argumentHint = + typeof frontmatter["argument-hint"] === "string" ? frontmatter["argument-hint"].trim() : undefined + // Create unique key combining name, source, and mode for override resolution const skillKey = this.getSkillKey(effectiveSkillName, source, mode) @@ -174,6 +178,7 @@ export class SkillsManager { path: skillMdPath, source, mode, // undefined for generic skills, string for mode-specific + ...(argumentHint && { argumentHint }), }) } catch (error) { console.error(`Failed to load skill at ${skillDir}:`, error) diff --git a/src/shared/skills.ts b/src/shared/skills.ts index 7ed85816aa8..d01a024664c 100644 --- a/src/shared/skills.ts +++ b/src/shared/skills.ts @@ -8,6 +8,7 @@ export interface SkillMetadata { path: string // Absolute path to SKILL.md source: "global" | "project" // Where the skill was discovered mode?: string // If set, skill is only available in this mode + argumentHint?: string // Optional: usage hint shown after command selection (from frontmatter argument-hint) } /** diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 5ec8d4c596e..497f50d3596 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -49,7 +49,7 @@ import { shouldShowSlashCommandsMenu, getMatchingSlashCommands, insertSlashCommand, - validateSlashCommand, + findSlashCommand, } from "@/utils/slash-commands" // kilocode_change end @@ -159,6 +159,7 @@ export const ChatTextArea = forwardRef( togglePinnedApiConfig, localWorkflows, // kilocode_change globalWorkflows, // kilocode_change + skills, // kilocode_change taskHistoryVersion, // kilocode_change clineMessages, ghostServiceSettings, // kilocode_change @@ -691,6 +692,7 @@ export const ChatTextArea = forwardRef( customModes, localWorkflows, globalWorkflows, + skills, ) // kilocode_change if (commands.length === 0) { @@ -710,6 +712,7 @@ export const ChatTextArea = forwardRef( customModes, localWorkflows, globalWorkflows, + skills, ) if (commands.length > 0) { handleSlashCommandsSelect(commands[selectedSlashCommandsIndex]) @@ -886,6 +889,7 @@ export const ChatTextArea = forwardRef( showSlashCommandsMenu, // kilocode_change start localWorkflows, globalWorkflows, + skills, customModes, handleSlashCommandsSelect, selectedSlashCommandsIndex, @@ -949,6 +953,7 @@ export const ChatTextArea = forwardRef( customModes, localWorkflows, globalWorkflows, + skills, ) // kilocode_change end @@ -1024,10 +1029,11 @@ export const ChatTextArea = forwardRef( } }, [ - // kilocode_change start: workflow toggles dependencies + // kilocode_change start: workflow toggles and skills dependencies customModes, localWorkflows, globalWorkflows, + skills, // kilocode_change end setInputValue, setSearchRequestId, @@ -1187,12 +1193,27 @@ export const ChatTextArea = forwardRef( // extract and validate the exact command text const commandText = processedText.substring(slashIndex + 1, endIndex) - const isValidCommand = validateSlashCommand(commandText, customModes) + const matchedCommand = findSlashCommand( + commandText, + customModes, + localWorkflows, + globalWorkflows, + skills, + ) - if (isValidCommand) { + if (matchedCommand) { const fullCommand = processedText.substring(slashIndex, endIndex) // includes slash + const typeClass = matchedCommand.type ? ` slash-command-type-${matchedCommand.type}` : "" + + let highlighted = `${fullCommand}` + + // Show argument hint as ghost text when command has one and user hasn't typed args yet + // Skip if FIM autocomplete ghost text is active to avoid overlapping hints + const textAfterCommand = processedText.substring(endIndex).trim() + if (matchedCommand.argumentHint && !textAfterCommand && !autocompleteText) { + highlighted += ` ${escapeHtml(matchedCommand.argumentHint)}` + } - const highlighted = `${fullCommand}` processedText = processedText.substring(0, slashIndex) + highlighted + processedText.substring(endIndex) } @@ -1225,7 +1246,7 @@ export const ChatTextArea = forwardRef( highlightLayerRef.current.innerHTML = processedText highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft - }, [customModes, autocompleteText, inputValue, isRecording, previewRanges]) // kilocode_change - merged dependencies + }, [customModes, localWorkflows, globalWorkflows, skills, autocompleteText, inputValue, isRecording, previewRanges]) // kilocode_change - merged dependencies useLayoutEffect(() => { updateHighlights() diff --git a/webview-ui/src/components/chat/SlashCommandMenu.tsx b/webview-ui/src/components/chat/SlashCommandMenu.tsx index 378dd8c2fff..1651411b453 100644 --- a/webview-ui/src/components/chat/SlashCommandMenu.tsx +++ b/webview-ui/src/components/chat/SlashCommandMenu.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useRef, useEffect } from "react" -import { SlashCommand, getMatchingSlashCommands } from "@/utils/slash-commands" +import { SlashCommand, SlashCommandSource, SlashCommandType, getMatchingSlashCommands } from "@/utils/slash-commands" import { useExtensionState } from "@/context/ExtensionStateContext" // kilocode_change interface SlashCommandMenuProps { @@ -11,6 +11,33 @@ interface SlashCommandMenuProps { customModes?: any[] } +const typeBadgeColors: Record = { + command: { bg: "rgba(58, 150, 221, 0.15)", text: "rgba(58, 150, 221, 0.9)" }, + mode: { bg: "rgba(160, 100, 230, 0.15)", text: "rgba(160, 100, 230, 0.9)" }, + workflow: { bg: "rgba(80, 180, 100, 0.15)", text: "rgba(80, 180, 100, 0.9)" }, + skill: { bg: "rgba(220, 160, 50, 0.15)", text: "rgba(220, 160, 50, 0.9)" }, +} + +const defaultBadgeColors = { bg: "rgba(128, 128, 128, 0.15)", text: "var(--vscode-descriptionForeground)" } + +function getTypeBadgeColors(type?: SlashCommandType): { bg: string; text: string } { + return (type && typeBadgeColors[type]) || defaultBadgeColors +} + +function getSourceLabel(source?: SlashCommandSource): string | null { + switch (source) { + case "project": + return "project" + case "global": + return "global" + case "organization": + return "org" + case "built-in": + default: + return null + } +} + const SlashCommandMenu: React.FC = ({ onSelect, selectedIndex, @@ -19,7 +46,7 @@ const SlashCommandMenu: React.FC = ({ query, customModes, }) => { - const { localWorkflows, globalWorkflows } = useExtensionState() // kilocode_change + const { localWorkflows, globalWorkflows, skills } = useExtensionState() // kilocode_change const menuRef = useRef(null) const handleClick = useCallback( @@ -47,7 +74,7 @@ const SlashCommandMenu: React.FC = ({ }, [selectedIndex]) // Filter commands based on query - const filteredCommands = getMatchingSlashCommands(query, customModes, localWorkflows, globalWorkflows) // kilocode_change + const filteredCommands = getMatchingSlashCommands(query, customModes, localWorkflows, globalWorkflows, skills) // kilocode_change return (
= ({ } hover:bg-[var(--vscode-list-hoverBackground)]`} onClick={() => handleClick(command)} onMouseEnter={() => setSelectedIndex(index)}> -
- /{command.name} +
+
+ /{command.name} +
+
+ {command.type && ( + + {command.type} + + )} + {getSourceLabel(command.source) && ( + + {getSourceLabel(command.source)} + + )} +
{command.description} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index fa79612efb0..34469cdd5e2 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -84,6 +84,7 @@ export interface ExtensionStateContextType extends ExtensionState { localWorkflows: ClineRulesToggles // kilocode_change start commands: Command[] + skills: Array<{ name: string; description: string; path: string; source: "global" | "project"; mode?: string }> organizationAllowList: OrganizationAllowList organizationSettingsVersion: number cloudIsAuthenticated: boolean @@ -372,6 +373,16 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const [filePaths, setFilePaths] = useState([]) const [openedTabs, setOpenedTabs] = useState>([]) const [commands, setCommands] = useState([]) + const [skills, setSkills] = useState< + Array<{ + name: string + description: string + path: string + source: "global" | "project" + mode?: string + argumentHint?: string + }> + >([]) const [mcpServers, setMcpServers] = useState([]) const [mcpMarketplaceCatalog, setMcpMarketplaceCatalog] = useState({ items: [] }) // kilocode_change const [currentCheckpoint, setCurrentCheckpoint] = useState() @@ -477,6 +488,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setCommands(message.commands ?? []) break } + case "skillsData": { + setSkills(message.skills ?? []) + break + } case "messageUpdated": { const clineMessage = message.clineMessage! setState((prevState) => { @@ -576,6 +591,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode localWorkflows, // kilocode_change end commands, + skills, soundVolume: state.soundVolume, ttsSpeed: state.ttsSpeed, fuzzyMatchThreshold: state.fuzzyMatchThreshold, diff --git a/webview-ui/src/kilocode.css b/webview-ui/src/kilocode.css index 7d4a5c43d4e..ddd3fd900b1 100644 --- a/webview-ui/src/kilocode.css +++ b/webview-ui/src/kilocode.css @@ -8,6 +8,34 @@ color: transparent; } +/* Type-specific slash command highlight colors */ +.slash-command-match-textarea-highlight.slash-command-type-command { + background-color: rgba(58, 150, 221, 0.2); + box-shadow: 0 0 0 0.5px rgba(58, 150, 221, 0.3); +} + +.slash-command-match-textarea-highlight.slash-command-type-mode { + background-color: rgba(160, 100, 230, 0.2); + box-shadow: 0 0 0 0.5px rgba(160, 100, 230, 0.3); +} + +.slash-command-match-textarea-highlight.slash-command-type-workflow { + background-color: rgba(80, 180, 100, 0.2); + box-shadow: 0 0 0 0.5px rgba(80, 180, 100, 0.3); +} + +.slash-command-match-textarea-highlight.slash-command-type-skill { + background-color: rgba(220, 160, 50, 0.2); + box-shadow: 0 0 0 0.5px rgba(220, 160, 50, 0.3); +} + +/* Argument hint ghost text after slash commands */ +.slash-command-argument-hint { + color: var(--vscode-editorGhostText-foreground, var(--vscode-descriptionForeground)); + opacity: 0.5; + pointer-events: none; +} + /* Custom slow pulse animation for task progress squares */ @keyframes slow-pulse { 0%, diff --git a/webview-ui/src/utils/slash-commands.ts b/webview-ui/src/utils/slash-commands.ts index 87296185d1f..864882f663f 100644 --- a/webview-ui/src/utils/slash-commands.ts +++ b/webview-ui/src/utils/slash-commands.ts @@ -6,10 +6,23 @@ import { getBasename } from "./kilocode/path-webview" import { Fzf } from "@/lib/word-boundary-fzf" // kilocode_change import { ClineRulesToggles } from "@roo/cline-rules" +export type SlashCommandType = "command" | "mode" | "workflow" | "skill" +export type SlashCommandSource = "built-in" | "project" | "global" | "organization" + export interface SlashCommand { name: string description?: string section?: "default" | "custom" + type?: SlashCommandType + source?: SlashCommandSource + argumentHint?: string +} + +export interface SkillInfo { + name: string + description: string + source: "global" | "project" + argumentHint?: string } // Create a function to get all supported slash commands @@ -17,24 +30,39 @@ export function getSupportedSlashCommands( customModes?: any[], localWorkflowToggles: ClineRulesToggles = {}, globalWorkflowToggles: ClineRulesToggles = {}, + skills: SkillInfo[] = [], ): SlashCommand[] { // Start with non-mode commands const baseCommands: SlashCommand[] = [ { name: "newtask", description: "Create a new task with context from the current task", + type: "command", + source: "built-in", }, { name: "newrule", description: "Create a new Kilo rule with context from your conversation", + type: "command", + source: "built-in", }, - { name: "reportbug", description: "Create a KiloCode GitHub issue" }, + { name: "reportbug", description: "Create a KiloCode GitHub issue", type: "command", source: "built-in" }, // kilocode_change start - { name: "init", description: "Initialize Kilo Code for this workspace" }, - { name: "smol", description: "Condenses your current context window" }, - { name: "condense", description: "Condenses your current context window" }, - { name: "compact", description: "Condenses your current context window" }, - { name: "session", description: "Session management " }, + { name: "init", description: "Initialize Kilo Code for this workspace", type: "command", source: "built-in" }, + { name: "smol", description: "Condenses your current context window", type: "command", source: "built-in" }, + { + name: "condense", + description: "Condenses your current context window", + type: "command", + source: "built-in", + }, + { name: "compact", description: "Condenses your current context window", type: "command", source: "built-in" }, + { + name: "session", + description: "Session management ", + type: "command", + source: "built-in", + }, // kilocode_change end ] @@ -42,11 +70,24 @@ export function getSupportedSlashCommands( const modeCommands = getAllModes(customModes).map((mode) => ({ name: mode.slug, description: `Switch to ${mode.name.replace(/^[💻🏗️❓🪲🪃]+ /, "")} mode`, + type: "mode" as const, + source: (mode.source ?? "built-in") as SlashCommandSource, })) // add workflow commands const workflowCommands = getWorkflowCommands(localWorkflowToggles, globalWorkflowToggles) - return [...baseCommands, ...modeCommands, ...workflowCommands] + + // add skill commands + const skillCommands: SlashCommand[] = skills.map((skill) => ({ + name: skill.name, + description: skill.description, + section: "custom" as const, + type: "skill" as const, + source: skill.source as SlashCommandSource, + ...(skill.argumentHint && { argumentHint: skill.argumentHint }), + })) + + return [...baseCommands, ...modeCommands, ...workflowCommands, ...skillCommands] } // Export a default instance for backward compatibility @@ -66,6 +107,7 @@ export function shouldShowSlashCommandsMenu( customModes?: any[], localWorkflowToggles: ClineRulesToggles = {}, globalWorkflowToggles: ClineRulesToggles = {}, + skills: SkillInfo[] = [], ): boolean { // kilocode_change end const beforeCursor = text.slice(0, cursorPosition) @@ -93,17 +135,25 @@ export function shouldShowSlashCommandsMenu( // kilocode_change start: If there are no matching commands for the current query, don't show the menu. // This prevents an empty menu from capturing Enter/Tab and blocking message submission. - const matches = getMatchingSlashCommands(textAfterSlash, customModes, localWorkflowToggles, globalWorkflowToggles) + const matches = getMatchingSlashCommands( + textAfterSlash, + customModes, + localWorkflowToggles, + globalWorkflowToggles, + skills, + ) return matches.length > 0 // kilocode_change end } -function enabledWorkflowToggles(workflowToggles: ClineRulesToggles): SlashCommand[] { +function enabledWorkflowToggles(workflowToggles: ClineRulesToggles, source: "project" | "global"): SlashCommand[] { return Object.entries(workflowToggles) .filter(([_, enabled]) => enabled) .map(([filePath, _]) => ({ name: getBasename(filePath), - section: "custom", + section: "custom" as const, + type: "workflow" as const, + source, })) } @@ -111,7 +161,10 @@ export function getWorkflowCommands( localWorkflowToggles: ClineRulesToggles = {}, globalWorkflowToggles: ClineRulesToggles = {}, ): SlashCommand[] { - return [...enabledWorkflowToggles(localWorkflowToggles), ...enabledWorkflowToggles(globalWorkflowToggles)] + return [ + ...enabledWorkflowToggles(localWorkflowToggles, "project"), + ...enabledWorkflowToggles(globalWorkflowToggles, "global"), + ] } /** @@ -122,8 +175,9 @@ export function getMatchingSlashCommands( customModes?: any[], localWorkflowToggles: ClineRulesToggles = {}, globalWorkflowToggles: ClineRulesToggles = {}, + skills: SkillInfo[] = [], ): SlashCommand[] { - const commands = getSupportedSlashCommands(customModes, localWorkflowToggles, globalWorkflowToggles) + const commands = getSupportedSlashCommands(customModes, localWorkflowToggles, globalWorkflowToggles, skills) if (!query) { return [...commands] @@ -162,12 +216,13 @@ export function validateSlashCommand( customModes?: any[], localWorkflowToggles: ClineRulesToggles = {}, globalWorkflowToggles: ClineRulesToggles = {}, + skills: SkillInfo[] = [], ): "full" | "partial" | null { if (!command) { return null } - const commands = getSupportedSlashCommands(customModes, localWorkflowToggles, globalWorkflowToggles) + const commands = getSupportedSlashCommands(customModes, localWorkflowToggles, globalWorkflowToggles, skills) // Check for exact match (command name equals query, case-insensitive via FZF) const lowerCommand = command.toLowerCase() @@ -188,3 +243,21 @@ export function validateSlashCommand( return null // no match } + +// kilocode_change start: Find matching slash command to get its type for highlighting +export function findSlashCommand( + command: string, + customModes?: any[], + localWorkflowToggles: ClineRulesToggles = {}, + globalWorkflowToggles: ClineRulesToggles = {}, + skills: SkillInfo[] = [], +): SlashCommand | null { + if (!command) { + return null + } + + const commands = getSupportedSlashCommands(customModes, localWorkflowToggles, globalWorkflowToggles, skills) + const lowerCommand = command.toLowerCase() + return commands.find((cmd) => cmd.name.toLowerCase() === lowerCommand) ?? null +} +// kilocode_change end