Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/slash-command-enhancements.md
Original file line number Diff line number Diff line change
@@ -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] <task description>`)
- Parse and propagate `argument-hint` from skill frontmatter through the full data pipeline (SkillsManager → ExtensionStateContext → SlashCommand)
18 changes: 18 additions & 0 deletions src/core/slash-commands/kilo.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -27,6 +28,7 @@ export async function parseKiloSlashCommands(
text: string,
localWorkflowToggles: ClineRulesToggles,
globalWorkflowToggles: ClineRulesToggles,
skills: SkillMetadata[] = [],
): Promise<{ processedText: string; needsRulesFileCheck: boolean }> {
const condenseAliases = condenseToolResponse

Expand Down Expand Up @@ -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 =
`<explicit_instructions type="${matchingSkill.name}">\n${skillContent}\n</explicit_instructions>\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
Expand Down
4 changes: 4 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4047,10 +4047,14 @@ export class Task extends EventEmitter<TaskEvents> 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) {
Expand Down
1 change: 1 addition & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }))
Expand Down
5 changes: 5 additions & 0 deletions src/services/skills/SkillsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/shared/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/**
Expand Down
33 changes: 27 additions & 6 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import {
shouldShowSlashCommandsMenu,
getMatchingSlashCommands,
insertSlashCommand,
validateSlashCommand,
findSlashCommand,
} from "@/utils/slash-commands"
// kilocode_change end

Expand Down Expand Up @@ -159,6 +159,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
togglePinnedApiConfig,
localWorkflows, // kilocode_change
globalWorkflows, // kilocode_change
skills, // kilocode_change
taskHistoryVersion, // kilocode_change
clineMessages,
ghostServiceSettings, // kilocode_change
Expand Down Expand Up @@ -691,6 +692,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
customModes,
localWorkflows,
globalWorkflows,
skills,
) // kilocode_change

if (commands.length === 0) {
Expand All @@ -710,6 +712,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
customModes,
localWorkflows,
globalWorkflows,
skills,
)
if (commands.length > 0) {
handleSlashCommandsSelect(commands[selectedSlashCommandsIndex])
Expand Down Expand Up @@ -886,6 +889,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
showSlashCommandsMenu, // kilocode_change start
localWorkflows,
globalWorkflows,
skills,
customModes,
handleSlashCommandsSelect,
selectedSlashCommandsIndex,
Expand Down Expand Up @@ -949,6 +953,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
customModes,
localWorkflows,
globalWorkflows,
skills,
)
// kilocode_change end

Expand Down Expand Up @@ -1024,10 +1029,11 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
},
[
// kilocode_change start: workflow toggles dependencies
// kilocode_change start: workflow toggles and skills dependencies
customModes,
localWorkflows,
globalWorkflows,
skills,
// kilocode_change end
setInputValue,
setSearchRequestId,
Expand Down Expand Up @@ -1187,12 +1193,27 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(

// 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 = `<mark class="slash-command-match-textarea-highlight${typeClass}">${fullCommand}</mark>`

// 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 += ` <span class="slash-command-argument-hint">${escapeHtml(matchedCommand.argumentHint)}</span>`
}

const highlighted = `<mark class="slash-command-match-textarea-highlight">${fullCommand}</mark>`
processedText =
processedText.substring(0, slashIndex) + highlighted + processedText.substring(endIndex)
}
Expand Down Expand Up @@ -1225,7 +1246,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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()
Expand Down
56 changes: 51 additions & 5 deletions webview-ui/src/components/chat/SlashCommandMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -11,6 +11,33 @@ interface SlashCommandMenuProps {
customModes?: any[]
}

const typeBadgeColors: Record<string, { bg: string; text: string }> = {
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<SlashCommandMenuProps> = ({
onSelect,
selectedIndex,
Expand All @@ -19,7 +46,7 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
query,
customModes,
}) => {
const { localWorkflows, globalWorkflows } = useExtensionState() // kilocode_change
const { localWorkflows, globalWorkflows, skills } = useExtensionState() // kilocode_change
const menuRef = useRef<HTMLDivElement>(null)

const handleClick = useCallback(
Expand Down Expand Up @@ -47,7 +74,7 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
}, [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 (
<div
Expand All @@ -69,8 +96,27 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
} hover:bg-[var(--vscode-list-hoverBackground)]`}
onClick={() => handleClick(command)}
onMouseEnter={() => setSelectedIndex(index)}>
<div className="font-bold whitespace-nowrap overflow-hidden text-ellipsis">
/{command.name}
<div className="flex items-center justify-between gap-2">
<div className="font-bold whitespace-nowrap overflow-hidden text-ellipsis">
/{command.name}
</div>
<div className="flex items-center gap-1 shrink-0">
{command.type && (
<span
className="text-[0.7em] px-1.5 py-0.5 rounded-sm leading-none"
style={{
backgroundColor: getTypeBadgeColors(command.type).bg,
color: getTypeBadgeColors(command.type).text,
}}>
{command.type}
</span>
)}
{getSourceLabel(command.source) && (
<span className="text-[0.65em] text-[var(--vscode-descriptionForeground)] opacity-70 leading-none">
{getSourceLabel(command.source)}
</span>
)}
</div>
</div>
<div className="text-[0.85em] text-[var(--vscode-descriptionForeground)] whitespace-normal overflow-hidden text-ellipsis">
{command.description}
Expand Down
16 changes: 16 additions & 0 deletions webview-ui/src/context/ExtensionStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -372,6 +373,16 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [filePaths, setFilePaths] = useState<string[]>([])
const [openedTabs, setOpenedTabs] = useState<Array<{ label: string; isActive: boolean; path?: string }>>([])
const [commands, setCommands] = useState<Command[]>([])
const [skills, setSkills] = useState<
Array<{
name: string
description: string
path: string
source: "global" | "project"
mode?: string
argumentHint?: string
}>
>([])
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
const [mcpMarketplaceCatalog, setMcpMarketplaceCatalog] = useState<McpMarketplaceCatalog>({ items: [] }) // kilocode_change
const [currentCheckpoint, setCurrentCheckpoint] = useState<string>()
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions webview-ui/src/kilocode.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%,
Expand Down
Loading
Loading