diff --git a/.changeset/fix-workflow-tool-display.md b/.changeset/fix-workflow-tool-display.md new file mode 100644 index 00000000000..f279451e67e --- /dev/null +++ b/.changeset/fix-workflow-tool-display.md @@ -0,0 +1,8 @@ +--- +"kilo-code": patch + + +Fix workflow tool display bug - always send tool message to webview even when auto-execute is enabled + + +When AUTO_EXECUTE_WORKFLOW experiment is enabled, the workflow tool message was created in the backend but never sent to the webview. This caused the workflow UI to not appear. The fix ensures that the tool message is always sent to the webview via task.ask() regardless of the auto-execute setting, so users can see what workflow is being executed. diff --git a/.changeset/fix-workflow-translation-key.md b/.changeset/fix-workflow-translation-key.md new file mode 100644 index 00000000000..be5a0593eeb --- /dev/null +++ b/.changeset/fix-workflow-translation-key.md @@ -0,0 +1,7 @@ +--- +"kilo-code": patch +--- + +Fix translation key mismatch for workflow access experimental setting + +Changed translation key from RUN_SLASH_COMMAND to AUTO_EXECUTE_WORKFLOW to match the experiment constant. Updated name from "Enable model-initiated slash commands" to "Enable workflow access" and description to better reflect the feature's actual behavior of accessing workflow content without approval. diff --git a/.changeset/remove-workflow-discovery.md b/.changeset/remove-workflow-discovery.md new file mode 100644 index 00000000000..0402de5b5af --- /dev/null +++ b/.changeset/remove-workflow-discovery.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Remove WORKFLOW_DISCOVERY experiment and consolidate to AUTO_EXECUTE_WORKFLOW diff --git a/.changeset/workflow-auto-experiment.md b/.changeset/workflow-auto-experiment.md new file mode 100644 index 00000000000..c7d1366d84c --- /dev/null +++ b/.changeset/workflow-auto-experiment.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Separate workflow discovery from auto-execution. Workflow discovery is now always available, while auto-execution without approval is controlled by the `autoExecuteWorkflow` experiment flag. diff --git a/.changeset/workflow-discovery-feature.md b/.changeset/workflow-discovery-feature.md new file mode 100644 index 00000000000..6e6a0cb7259 --- /dev/null +++ b/.changeset/workflow-discovery-feature.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add automatic workflow discovery feature for Kilo agent to discover available workflows from global and workspace directories without manual directory exploration. diff --git a/.changeset/workflow-execution-tool.md b/.changeset/workflow-execution-tool.md new file mode 100644 index 00000000000..bf1f35acae6 --- /dev/null +++ b/.changeset/workflow-execution-tool.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Implement workflow execution tool for Kilo Code. Added workflow discovery service in `.kilocode/workflows/`, adapted RunSlashCommandTool to use workflows instead of commands, and created sample workflows for common tasks. diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index c2936b3bfde..0155fcf2f39 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -12,7 +12,7 @@ export const experimentIds = [ "multiFileApplyDiff", "preventFocusDisruption", "imageGeneration", - "runSlashCommand", + "autoExecuteWorkflow", "multipleNativeToolCalls", ] as const @@ -31,7 +31,7 @@ export const experimentsSchema = z.object({ multiFileApplyDiff: z.boolean().optional(), preventFocusDisruption: z.boolean().optional(), imageGeneration: z.boolean().optional(), - runSlashCommand: z.boolean().optional(), + autoExecuteWorkflow: z.boolean().optional(), multipleNativeToolCalls: z.boolean().optional(), }) diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index c8e6fd69c5b..f44033054ca 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -22,6 +22,10 @@ import { getGitStatus } from "../../utils/git" import { Task } from "../task/Task" import { formatReminderSection } from "./reminder" +// kilocode_change start +import { getWorkflowsForEnvironment } from "../workflow-discovery/getWorkflowsForEnvironment" +import { refreshWorkflowToggles } from "../context/instructions/workflows" +// kilocode_change end // kilocode_change start import { OpenRouterHandler } from "../../api/providers/openrouter" @@ -378,5 +382,29 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo ? state.apiConfiguration.todoListEnabled : true const reminderSection = todoListEnabled ? formatReminderSection(cline.todoList) : "" - return `\n${details.trim()}\n${reminderSection}\n` + + // kilocode_change start + // Add workflow discovery information if experiment is enabled + let localWorkflowToggles: Record = {} + let globalWorkflowToggles: Record = {} + + if (clineProvider?.context) { + const toggles = await refreshWorkflowToggles(clineProvider.context, cline.cwd) + localWorkflowToggles = toggles.localWorkflowToggles + globalWorkflowToggles = toggles.globalWorkflowToggles + } + // kilocode_change end + + const enabledWorkflows = new Map() + Object.entries(localWorkflowToggles || {}).forEach(([path, enabled]) => { + enabledWorkflows.set(path, enabled) + }) + Object.entries(globalWorkflowToggles || {}).forEach(([path, enabled]) => { + enabledWorkflows.set(path, enabled) + }) + + const workflowSection = await getWorkflowsForEnvironment(cline.cwd, experiments, enabledWorkflows) + // kilocode_change end + + return `\n${details.trim()}\n${reminderSection}\n${workflowSection}\n` } diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts index ec89da51c67..2759c08b6d3 100644 --- a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -416,7 +416,7 @@ describe("filterNativeToolsForMode", () => { toolsWithSlashCommand, "code", [codeMode], - { runSlashCommand: false }, + { autoExecuteWorkflow: false }, undefined, {}, undefined, diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 865da2e2f24..a0efdfe8d40 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -301,7 +301,7 @@ export function filterNativeToolsForMode( } // Conditionally exclude run_slash_command if experiment is not enabled - if (!experiments?.runSlashCommand) { + if (!experiments?.autoExecuteWorkflow) { allowedToolNames.delete("run_slash_command") } @@ -404,7 +404,7 @@ export function isToolAllowedInMode( return experiments?.imageGeneration === true } if (toolName === "run_slash_command") { - return experiments?.runSlashCommand === true + return experiments?.autoExecuteWorkflow === true } return true } diff --git a/src/core/prompts/tools/index.ts b/src/core/prompts/tools/index.ts index e80b57c0f0e..ea3bd0abb11 100644 --- a/src/core/prompts/tools/index.ts +++ b/src/core/prompts/tools/index.ts @@ -159,7 +159,7 @@ export function getToolDescriptionsForMode( } // Conditionally exclude run_slash_command if experiment is not enabled - if (!experiments?.runSlashCommand) { + if (!experiments?.autoExecuteWorkflow) { tools.delete("run_slash_command") } diff --git a/src/core/prompts/tools/native-tools/run_slash_command.ts b/src/core/prompts/tools/native-tools/run_slash_command.ts index 71bf2528ddc..f84840ef3f9 100644 --- a/src/core/prompts/tools/native-tools/run_slash_command.ts +++ b/src/core/prompts/tools/native-tools/run_slash_command.ts @@ -1,10 +1,12 @@ +// kilocode_change start import type OpenAI from "openai" -const RUN_SLASH_COMMAND_DESCRIPTION = `Execute a slash command to get specific instructions or content. Slash commands are predefined templates that provide detailed guidance for common tasks.` +const RUN_SLASH_COMMAND_DESCRIPTION = `Execute a workflow to get specific instructions or content. Workflows are predefined templates stored in .kilocode/workflows/ that provide detailed guidance for common tasks. Always shows workflow content; requires user approval unless auto-execute experiment is enabled.` -const COMMAND_PARAMETER_DESCRIPTION = `Name of the slash command to run (e.g., init, test, deploy)` +const COMMAND_PARAMETER_DESCRIPTION = `Name of the workflow to execute (without .md extension)` -const ARGS_PARAMETER_DESCRIPTION = `Optional additional context or arguments for the command` +const ARGS_PARAMETER_DESCRIPTION = `Optional additional arguments or context to pass to the workflow` +// kilocode_change end export default { type: "function", diff --git a/src/core/tools/RunSlashCommandTool.ts b/src/core/tools/RunSlashCommandTool.ts index 8af8bd6e12a..f34913f7ed4 100644 --- a/src/core/tools/RunSlashCommandTool.ts +++ b/src/core/tools/RunSlashCommandTool.ts @@ -1,9 +1,11 @@ +// kilocode_change start import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" -import { getCommand, getCommandNames } from "../../services/command/commands" +import { getWorkflow, getWorkflowNames } from "../../services/workflow/workflows" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" +// kilocode_change end interface RunSlashCommandParams { command: string @@ -24,23 +26,14 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { const { command: commandName, args } = params const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks - // Check if run slash command experiment is enabled + // Check if auto-execute workflow experiment is enabled const provider = task.providerRef.deref() const state = await provider?.getState() - const isRunSlashCommandEnabled = experiments.isEnabled( + const isAutoExecuteEnabled = experiments.isEnabled( state?.experiments ?? {}, - EXPERIMENT_IDS.RUN_SLASH_COMMAND, + EXPERIMENT_IDS.AUTO_EXECUTE_WORKFLOW, ) - if (!isRunSlashCommandEnabled) { - pushToolResult( - formatResponse.toolError( - "Run slash command is an experimental feature that must be enabled in settings. Please enable 'Run Slash Command' in the Experimental Settings section.", - ), - ) - return - } - try { if (!commandName) { task.consecutiveMistakeCount++ @@ -52,17 +45,17 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { task.consecutiveMistakeCount = 0 - // Get the command from the commands service - const command = await getCommand(task.cwd, commandName) + // Get the workflow from the workflows service + const workflow = await getWorkflow(task.cwd, commandName) - if (!command) { - // Get available commands for error message - const availableCommands = await getCommandNames(task.cwd) + if (!workflow) { + // Get available workflows for error message + const availableWorkflows = await getWorkflowNames(task.cwd) task.recordToolError("run_slash_command") task.didToolFailInCurrentTurn = true pushToolResult( formatResponse.toolError( - `Command '${commandName}' not found. Available commands: ${availableCommands.join(", ") || "(none)"}`, + `Workflow '${commandName}' not found. Available workflows: ${availableWorkflows.join(", ") || "(none)"}`, ), ) return @@ -72,35 +65,46 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { tool: "runSlashCommand", command: commandName, args: args, - source: command.source, - description: command.description, + source: workflow.source, + description: workflow.description, }) - const didApprove = await askApproval("tool", toolMessage) - - if (!didApprove) { - return + // kilocode_change: Fix workflow display bug - always send tool message to webview even when auto-execute is enabled + // This ensures that user can see what workflow is being executed + // If auto-execute is disabled, wait for approval + // If auto-execute is enabled, still send message but don't wait for approval + if (!isAutoExecuteEnabled) { + const didApprove = await askApproval("tool", toolMessage) + if (!didApprove) { + return + } + } else { + // kilocode_change: When auto-execute is enabled, send message to webview without waiting for approval + // This ensures that workflow tool UI is displayed even when auto-executing + await task.ask("tool", toolMessage, false).catch(() => {}) } + // kilocode_change end - // Build the result message - let result = `Command: /${commandName}` + // kilocode_change: Update message text with complete tool result content + // Build the result message with complete workflow data + let result = `Workflow: /${commandName}` - if (command.description) { - result += `\nDescription: ${command.description}` + if (workflow.description) { + result += `\nDescription: ${workflow.description}` } - if (command.argumentHint) { - result += `\nArgument hint: ${command.argumentHint}` + if (workflow.arguments) { + result += `\nArguments: ${workflow.arguments}` } if (args) { result += `\nProvided arguments: ${args}` } - result += `\nSource: ${command.source}` - result += `\n\n--- Command Content ---\n\n${command.content}` + result += `\nSource: ${workflow.source}` + result += `\n\n--- Workflow Content ---\n\n${workflow.content}` - // Return the command content as the tool result + // Return the workflow content as the tool result pushToolResult(result) } catch (error) { await handleError("running slash command", error as Error) @@ -111,13 +115,41 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { const commandName: string | undefined = block.params.command const args: string | undefined = block.params.args - const partialMessage = JSON.stringify({ - tool: "runSlashCommand", - command: this.removeClosingTag("command", commandName, block.partial), - args: this.removeClosingTag("args", args, block.partial), - }) - - await task.ask("tool", partialMessage, block.partial).catch(() => {}) + // kilocode_change: Fix workflow display bug - include complete workflow data when transitioning to complete + // When transitioning from partial to complete (block.partial === false), we need to include + // the complete workflow data (source, description, content) in the message text. + // Without this, the tool object parsed from message.text still contains the old partial + // tool message data, which causes SlashCommandItem to render it incorrectly + // (e.g., showing partial=true when the workflow is actually complete). + if (!block.partial) { + // Transitioning to complete - fetch and include complete workflow data + const workflow = await getWorkflow(task.cwd, commandName || "") + const completeMessage = JSON.stringify({ + tool: "runSlashCommand", + command: commandName, + args: args, + source: workflow?.source, + description: workflow?.description, + }) + // kilocode_change: Add diagnostic logging for workflow tool display issue + console.log(`[RunSlashCommandTool.handlePartial] Sending COMPLETE message to webview:`, completeMessage) + await task.ask("tool", completeMessage, false).catch(() => {}) + console.log(`[RunSlashCommandTool.handlePartial] COMPLETE message sent successfully`) + // kilocode_change end + } else { + // Partial message - use minimal data structure + const partialMessage = JSON.stringify({ + tool: "runSlashCommand", + command: this.removeClosingTag("command", commandName, block.partial), + args: this.removeClosingTag("args", args, block.partial), + }) + // kilocode_change: Add diagnostic logging for workflow tool display issue + console.log(`[RunSlashCommandTool.handlePartial] Sending PARTIAL message to webview:`, partialMessage) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) + console.log(`[RunSlashCommandTool.handlePartial] PARTIAL message sent successfully`) + // kilocode_change end + } + // kilocode_change end } } diff --git a/src/core/tools/__tests__/runSlashCommandTool.spec.ts b/src/core/tools/__tests__/runSlashCommandTool.spec.ts index e3c8180e381..fb3ddb6694e 100644 --- a/src/core/tools/__tests__/runSlashCommandTool.spec.ts +++ b/src/core/tools/__tests__/runSlashCommandTool.spec.ts @@ -1,15 +1,17 @@ +// kilocode_change start import { describe, it, expect, vi, beforeEach } from "vitest" import { runSlashCommandTool } from "../RunSlashCommandTool" import { Task } from "../../task/Task" import { formatResponse } from "../../prompts/responses" -import { getCommand, getCommandNames } from "../../../services/command/commands" +import { getWorkflow, getWorkflowNames } from "../../../services/workflow/workflows" import type { ToolUse } from "../../../shared/tools" // Mock dependencies -vi.mock("../../../services/command/commands", () => ({ - getCommand: vi.fn(), - getCommandNames: vi.fn(), +vi.mock("../../../services/workflow/workflows", () => ({ + getWorkflow: vi.fn(), + getWorkflowNames: vi.fn(), })) +// kilocode_change end describe("runSlashCommandTool", () => { let mockTask: any @@ -28,7 +30,7 @@ describe("runSlashCommandTool", () => { deref: vi.fn().mockReturnValue({ getState: vi.fn().mockResolvedValue({ experiments: { - runSlashCommand: true, + autoExecuteWorkflow: false, }, }), }), @@ -59,7 +61,7 @@ describe("runSlashCommandTool", () => { expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith("Missing parameter error") }) - it("should handle command not found", async () => { + it("should handle workflow not found", async () => { const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, @@ -69,18 +71,18 @@ describe("runSlashCommandTool", () => { partial: false, } - vi.mocked(getCommand).mockResolvedValue(undefined) - vi.mocked(getCommandNames).mockResolvedValue(["init", "test", "deploy"]) + vi.mocked(getWorkflow).mockResolvedValue(undefined) + vi.mocked(getWorkflowNames).mockResolvedValue(["init", "test", "deploy"]) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockTask.recordToolError).toHaveBeenCalledWith("run_slash_command") expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( - formatResponse.toolError("Command 'nonexistent' not found. Available commands: init, test, deploy"), + formatResponse.toolError("Workflow 'nonexistent' not found. Available workflows: init, test, deploy"), ) }) - it("should handle user rejection", async () => { + it("should ask for approval when auto-execute is disabled", async () => { const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, @@ -90,15 +92,15 @@ describe("runSlashCommandTool", () => { partial: false, } - const mockCommand = { + const mockWorkflow = { name: "init", content: "Initialize project", - source: "built-in" as const, - filePath: "", + source: "project" as const, + filePath: ".kilocode/workflows/init.md", description: "Initialize the project", } - vi.mocked(getCommand).mockResolvedValue(mockCommand) + vi.mocked(getWorkflow).mockResolvedValue(mockWorkflow) mockCallbacks.askApproval.mockResolvedValue(false) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) @@ -107,7 +109,7 @@ describe("runSlashCommandTool", () => { expect(mockCallbacks.pushToolResult).not.toHaveBeenCalled() }) - it("should successfully execute built-in command", async () => { + it("should auto-execute when auto-execute experiment is enabled", async () => { const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, @@ -117,15 +119,57 @@ describe("runSlashCommandTool", () => { partial: false, } - const mockCommand = { + const mockWorkflow = { + name: "init", + content: "Initialize project", + source: "project" as const, + filePath: ".kilocode/workflows/init.md", + description: "Initialize the project", + } + + vi.mocked(getWorkflow).mockResolvedValue(mockWorkflow) + + // Mock task with auto-execute enabled + const mockTaskWithAutoExecute = { + ...mockTask, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + experiments: { + autoExecuteWorkflow: true, + }, + }), + }), + }, + } + + await runSlashCommandTool.handle(mockTaskWithAutoExecute as Task, block, mockCallbacks) + + // Should not ask for approval when auto-execute is enabled + expect(mockCallbacks.askApproval).not.toHaveBeenCalled() + // Should still push the workflow result + expect(mockCallbacks.pushToolResult).toHaveBeenCalled() + }) + + it("should successfully execute project workflow", async () => { + const block: ToolUse<"run_slash_command"> = { + type: "tool_use" as const, + name: "run_slash_command" as const, + params: { + command: "init", + }, + partial: false, + } + + const mockWorkflow = { name: "init", content: "Initialize project content here", - source: "built-in" as const, - filePath: "", + source: "project" as const, + filePath: ".kilocode/workflows/init.md", description: "Analyze codebase and create AGENTS.md", } - vi.mocked(getCommand).mockResolvedValue(mockCommand) + vi.mocked(getWorkflow).mockResolvedValue(mockWorkflow) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) @@ -135,23 +179,23 @@ describe("runSlashCommandTool", () => { tool: "runSlashCommand", command: "init", args: undefined, - source: "built-in", + source: "project", description: "Analyze codebase and create AGENTS.md", }), ) expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( - `Command: /init + `Workflow: /init Description: Analyze codebase and create AGENTS.md -Source: built-in +Source: project ---- Command Content --- +--- Workflow Content --- Initialize project content here`, ) }) - it("should successfully execute command with arguments", async () => { + it("should successfully execute workflow with arguments", async () => { const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, @@ -162,33 +206,33 @@ Initialize project content here`, partial: false, } - const mockCommand = { + const mockWorkflow = { name: "test", content: "Run tests with specific focus", source: "project" as const, - filePath: ".roo/commands/test.md", + filePath: ".kilocode/workflows/test.md", description: "Run project tests", - argumentHint: "test type or focus area", + arguments: "test type or focus area", } - vi.mocked(getCommand).mockResolvedValue(mockCommand) + vi.mocked(getWorkflow).mockResolvedValue(mockWorkflow) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( - `Command: /test + `Workflow: /test Description: Run project tests -Argument hint: test type or focus area +Arguments: test type or focus area Provided arguments: focus on unit tests Source: project ---- Command Content --- +--- Workflow Content --- Run tests with specific focus`, ) }) - it("should handle global command", async () => { + it("should handle global workflow", async () => { const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, @@ -198,22 +242,22 @@ Run tests with specific focus`, partial: false, } - const mockCommand = { + const mockWorkflow = { name: "deploy", content: "Deploy application to production", source: "global" as const, - filePath: "~/.roo/commands/deploy.md", + filePath: "~/.kilocode/workflows/deploy.md", } - vi.mocked(getCommand).mockResolvedValue(mockCommand) + vi.mocked(getWorkflow).mockResolvedValue(mockWorkflow) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( - `Command: /deploy + `Workflow: /deploy Source: global ---- Command Content --- +--- Workflow Content --- Deploy application to production`, ) @@ -255,14 +299,14 @@ Deploy application to production`, } const error = new Error("Test error") - vi.mocked(getCommand).mockRejectedValue(error) + vi.mocked(getWorkflow).mockRejectedValue(error) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockCallbacks.handleError).toHaveBeenCalledWith("running slash command", error) }) - it("should handle empty available commands list", async () => { + it("should handle empty available workflows list", async () => { const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, @@ -272,17 +316,17 @@ Deploy application to production`, partial: false, } - vi.mocked(getCommand).mockResolvedValue(undefined) - vi.mocked(getCommandNames).mockResolvedValue([]) + vi.mocked(getWorkflow).mockResolvedValue(undefined) + vi.mocked(getWorkflowNames).mockResolvedValue([]) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( - formatResponse.toolError("Command 'nonexistent' not found. Available commands: (none)"), + formatResponse.toolError("Workflow 'nonexistent' not found. Available workflows: (none)"), ) }) - it("should reset consecutive mistake count on valid command", async () => { + it("should reset consecutive mistake count on valid workflow", async () => { const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, @@ -294,14 +338,14 @@ Deploy application to production`, mockTask.consecutiveMistakeCount = 5 - const mockCommand = { + const mockWorkflow = { name: "init", content: "Initialize project", - source: "built-in" as const, - filePath: "", + source: "project" as const, + filePath: ".kilocode/workflows/init.md", } - vi.mocked(getCommand).mockResolvedValue(mockCommand) + vi.mocked(getWorkflow).mockResolvedValue(mockWorkflow) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9dbc296eefc..3b94993b90a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -91,6 +91,7 @@ import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/provi import { ContextProxy } from "../config/ContextProxy" import { getEnabledRules } from "./kilorules" +import { refreshWorkflowToggles } from "../context/instructions/workflows" import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { Task } from "../task/Task" @@ -1981,6 +1982,8 @@ ${prompt} async postRulesDataToWebview() { const workspacePath = this.cwd if (workspacePath) { + // Refresh workflow toggles to ensure newly created workflow files are recognized + await refreshWorkflowToggles(this.context, workspacePath) this.postMessageToWebview({ type: "rulesData", ...(await getEnabledRules(workspacePath, this.contextProxy, this.context)), diff --git a/src/core/workflow-discovery/WorkflowDiscoveryService.ts b/src/core/workflow-discovery/WorkflowDiscoveryService.ts new file mode 100644 index 00000000000..3c0f6e49f03 --- /dev/null +++ b/src/core/workflow-discovery/WorkflowDiscoveryService.ts @@ -0,0 +1,186 @@ +// kilocode_change - new file + +import * as path from "path" +import type { DiscoveredWorkflow, WorkflowDiscoveryConfig, WorkflowDiscoveryResult, WorkflowCacheEntry } from "./types" +import { WorkflowScanner } from "./WorkflowScanner" + +/** + * Default configuration for workflow discovery + */ +const DEFAULT_CONFIG: WorkflowDiscoveryConfig = { + includeGlobal: true, + includeWorkspace: true, + enableCache: true, + cacheTtlMs: 5 * 60 * 1000, // 5 minutes +} + +/** + * Main service for discovering workflows in global and workspace directories + */ +export class WorkflowDiscoveryService { + private scanner: WorkflowScanner + private config: WorkflowDiscoveryConfig + private cache: Map + + constructor(config?: Partial) { + this.scanner = new WorkflowScanner() + this.config = { ...DEFAULT_CONFIG, ...config } + this.cache = new Map() + } + + /** + * Discover all workflows (global and workspace) + * @param cwd - Current working directory + * @param enabledWorkflows - Map of enabled workflows (path -> boolean) + * @returns Discovery result with all workflows + */ + async discoverWorkflows(cwd: string, enabledWorkflows?: Map): Promise { + // Check cache first if enabled + const cacheKey = this.getCacheKey(cwd) + if (this.config.enableCache && this.cache.has(cacheKey)) { + const cached = this.cache.get(cacheKey)! + const now = Date.now() + + // Check if cache is still valid + if (now - cached.timestamp < this.config.cacheTtlMs) { + const workflows = this.applyEnabledStatus(cached.workflows, enabledWorkflows) + return { + workflows, + globalCount: workflows.filter((w) => w.source === "global").length, + workspaceCount: workflows.filter((w) => w.source === "workspace").length, + fromCache: true, + } + } + } + + // Discover workflows + const workflows: DiscoveredWorkflow[] = [] + + // Scan global workflows + if (this.config.includeGlobal) { + const globalDir = this.getGlobalWorkflowsDir() + const globalWorkflows = await this.scanner.scanGlobalWorkflows(globalDir) + workflows.push(...globalWorkflows) + } + + // Scan workspace workflows + if (this.config.includeWorkspace) { + const workspaceDir = this.getWorkspaceWorkflowsDir(cwd) + const workspaceWorkflows = await this.scanner.scanWorkspaceWorkflows(workspaceDir) + workflows.push(...workspaceWorkflows) + } + + // Apply enabled status from workflow toggles + const workflowsWithStatus = this.applyEnabledStatus(workflows, enabledWorkflows) + + // Cache the result if enabled + if (this.config.enableCache) { + this.cache.set(cacheKey, { + workflows: workflowsWithStatus, + timestamp: Date.now(), + }) + } + + return { + workflows: workflowsWithStatus, + globalCount: workflowsWithStatus.filter((w) => w.source === "global").length, + workspaceCount: workflowsWithStatus.filter((w) => w.source === "workspace").length, + fromCache: false, + } + } + + /** + * Get enabled workflows only + * @param cwd - Current working directory + * @param enabledWorkflows - Map of enabled workflows (path -> boolean) + * @returns Array of enabled workflows + */ + async getEnabledWorkflows(cwd: string, enabledWorkflows?: Map): Promise { + const result = await this.discoverWorkflows(cwd, enabledWorkflows) + return result.workflows.filter((w) => w.enabled) + } + + /** + * Clear the cache + */ + clearCache(): void { + this.cache.clear() + } + + /** + * Clear cache for a specific directory + * @param cwd - Current working directory + */ + clearCacheForDir(cwd: string): void { + const cacheKey = this.getCacheKey(cwd) + this.cache.delete(cacheKey) + } + + /** + * Update configuration + * @param config - Partial configuration to update + */ + updateConfig(config: Partial): void { + this.config = { ...this.config, ...config } + } + + /** + * Get current configuration + * @returns Current configuration + */ + getConfig(): WorkflowDiscoveryConfig { + return { ...this.config } + } + + /** + * Apply enabled status to workflows based on workflow toggles + * @param workflows - Array of workflows + * @param enabledWorkflows - Map of enabled workflows (path -> boolean) + * @returns Workflows with updated enabled status + */ + private applyEnabledStatus( + workflows: DiscoveredWorkflow[], + enabledWorkflows?: Map, + ): DiscoveredWorkflow[] { + if (!enabledWorkflows || enabledWorkflows.size === 0) { + // If no toggles provided, all workflows are enabled by default + return workflows.map((w) => ({ ...w, enabled: true })) + } + + return workflows.map((workflow) => { + // Check if workflow is in the enabled map + const isEnabled = enabledWorkflows.get(workflow.filePath) + return { + ...workflow, + enabled: isEnabled !== false, // Default to true if not in map + } + }) + } + + /** + * Get cache key for a directory + * @param cwd - Current working directory + * @returns Cache key + */ + private getCacheKey(cwd: string): string { + return cwd + } + + /** + * Get global workflows directory path + * @returns Path to global workflows directory + */ + private getGlobalWorkflowsDir(): string { + const homeDir = process.env.HOME || process.env.USERPROFILE || "" + return path.join(homeDir, ".kilocode", "workflows") + } + + /** + * Get workspace workflows directory path + * @param cwd - Current working directory + * @returns Path to workspace workflows directory + */ + private getWorkspaceWorkflowsDir(cwd: string): string { + return path.join(cwd, ".kilocode", "workflows") + } +} diff --git a/src/core/workflow-discovery/WorkflowMetadataExtractor.ts b/src/core/workflow-discovery/WorkflowMetadataExtractor.ts new file mode 100644 index 00000000000..d9e1e94dc33 --- /dev/null +++ b/src/core/workflow-discovery/WorkflowMetadataExtractor.ts @@ -0,0 +1,88 @@ +// kilocode_change - new file + +import matter from "gray-matter" +import type { WorkflowFrontmatter } from "./types" + +/** + * Maximum number of words for description truncation + */ +const MAX_DESCRIPTION_WORDS = 30 + +/** + * Extracts metadata from workflow files by parsing YAML frontmatter + */ +export class WorkflowMetadataExtractor { + /** + * Parse frontmatter from workflow content + * @param content - Raw workflow file content + * @returns Parsed frontmatter and content without frontmatter + */ + parseFrontmatter(content: string): { frontmatter: WorkflowFrontmatter; content: string } { + try { + const parsed = matter(content) + return { + frontmatter: parsed.data as WorkflowFrontmatter, + content: parsed.content.trim(), + } + } catch (error) { + // If parsing fails, return empty frontmatter and original content + console.warn("Failed to parse workflow frontmatter:", error) + return { + frontmatter: {}, + content: content.trim(), + } + } + } + + /** + * Extract and truncate description from frontmatter + * @param frontmatter - Parsed frontmatter + * @returns Description truncated to 30 words, or undefined if not present + */ + extractDescription(frontmatter: WorkflowFrontmatter): string | undefined { + if (typeof frontmatter.description !== "string" || !frontmatter.description.trim()) { + return undefined + } + + const description = frontmatter.description.trim() + const words = description.split(/\s+/) + + if (words.length <= MAX_DESCRIPTION_WORDS) { + return description + } + + // Truncate to 30 words and add ellipsis + return words.slice(0, MAX_DESCRIPTION_WORDS).join(" ") + "..." + } + + /** + * Extract arguments hint from frontmatter + * @param frontmatter - Parsed frontmatter + * @returns Arguments hint, or undefined if not present + */ + extractArguments(frontmatter: WorkflowFrontmatter): string | undefined { + if (typeof frontmatter.arguments !== "string" || !frontmatter.arguments.trim()) { + return undefined + } + return frontmatter.arguments.trim() + } + + /** + * Extract all metadata from workflow content + * @param content - Raw workflow file content + * @returns Object containing description, arguments, and content + */ + extractMetadata(content: string): { + description?: string + arguments?: string + content: string + } { + const { frontmatter, content: workflowContent } = this.parseFrontmatter(content) + + return { + description: this.extractDescription(frontmatter), + arguments: this.extractArguments(frontmatter), + content: workflowContent, + } + } +} diff --git a/src/core/workflow-discovery/WorkflowScanner.ts b/src/core/workflow-discovery/WorkflowScanner.ts new file mode 100644 index 00000000000..c78dcf87f71 --- /dev/null +++ b/src/core/workflow-discovery/WorkflowScanner.ts @@ -0,0 +1,225 @@ +// kilocode_change - new file + +import fs from "fs/promises" +import * as path from "path" +import { Dirent } from "fs" +import type { DiscoveredWorkflow } from "./types" +import { WorkflowMetadataExtractor } from "./WorkflowMetadataExtractor" + +/** + * Maximum depth for resolving symlinks to prevent cyclic symlink loops + */ +const MAX_DEPTH = 5 + +/** + * Information about a resolved workflow file + */ +interface WorkflowFileInfo { + /** Original path (symlink path if symlinked, otherwise the file path) */ + originalPath: string + /** Resolved path (target of symlink if symlinked, otherwise the file path) */ + resolvedPath: string +} + +/** + * Scans workflow directories and discovers workflow files + */ +export class WorkflowScanner { + private metadataExtractor: WorkflowMetadataExtractor + + constructor() { + this.metadataExtractor = new WorkflowMetadataExtractor() + } + + /** + * Scan global workflow directory + * @param globalDir - Path to global workflows directory + * @returns Array of discovered global workflows + */ + async scanGlobalWorkflows(globalDir: string): Promise { + return this.scanDirectory(globalDir, "global") + } + + /** + * Scan workspace workflow directory + * @param workspaceDir - Path to workspace workflows directory + * @returns Array of discovered workspace workflows + */ + async scanWorkspaceWorkflows(workspaceDir: string): Promise { + return this.scanDirectory(workspaceDir, "workspace") + } + + /** + * Scan a workflow directory for markdown files + * @param dirPath - Path to directory to scan + * @param source - Source type (global or workspace) + * @returns Array of discovered workflows + */ + private async scanDirectory(dirPath: string, source: "global" | "workspace"): Promise { + const workflows: DiscoveredWorkflow[] = [] + + try { + const stats = await fs.stat(dirPath) + if (!stats.isDirectory()) { + return workflows + } + + const entries = await fs.readdir(dirPath, { withFileTypes: true }) + + // Collect all workflow files, including those from symlinks + const fileInfo: WorkflowFileInfo[] = [] + const promises: Promise[] = [] + + for (const entry of entries) { + promises.push(this.resolveDirectoryEntry(entry, dirPath, fileInfo, 0)) + } + + await Promise.all(promises) + + // Process each collected file + for (const { originalPath, resolvedPath } of fileInfo) { + const workflow = await this.createWorkflowFromFile(resolvedPath, originalPath, source) + if (workflow) { + workflows.push(workflow) + } + } + } catch { + // Directory doesn't exist or can't be read - this is fine + } + + return workflows + } + + /** + * Resolve a directory entry and collect workflow file info + * @param entry - Directory entry + * @param dirPath - Parent directory path + * @param fileInfo - Array to collect file info + * @param depth - Current depth for symlink resolution + */ + private async resolveDirectoryEntry( + entry: Dirent, + dirPath: string, + fileInfo: WorkflowFileInfo[], + depth: number, + ): Promise { + // Avoid cyclic symlinks + if (depth > MAX_DEPTH) { + return + } + + const fullPath = path.resolve(entry.parentPath || dirPath, entry.name) + + if (entry.isFile()) { + // Only include markdown files + if (this.isMarkdownFile(entry.name)) { + // Regular file - both original and resolved paths are the same + fileInfo.push({ originalPath: fullPath, resolvedPath: fullPath }) + } + } else if (entry.isSymbolicLink()) { + // Resolve the symbolic link + await this.resolveSymlink(fullPath, fileInfo, depth + 1) + } + } + + /** + * Resolve a symbolic link and collect workflow file info + * @param symlinkPath - Path to symlink + * @param fileInfo - Array to collect file info + * @param depth - Current depth for symlink resolution + */ + private async resolveSymlink(symlinkPath: string, fileInfo: WorkflowFileInfo[], depth: number): Promise { + // Avoid cyclic symlinks + if (depth > MAX_DEPTH) { + return + } + + try { + // Get the symlink target + const linkTarget = await fs.readlink(symlinkPath) + // Resolve the target path (relative to the symlink location) + const resolvedTarget = path.resolve(path.dirname(symlinkPath), linkTarget) + + // Check if the target is a file + const stats = await fs.lstat(resolvedTarget) + if (stats.isFile()) { + // Only include markdown files + if (this.isMarkdownFile(resolvedTarget)) { + // For symlinks to files, store the symlink path as original and target as resolved + fileInfo.push({ originalPath: symlinkPath, resolvedPath: resolvedTarget }) + } + } else if (stats.isDirectory()) { + // Read the target directory and process its entries + const entries = await fs.readdir(resolvedTarget, { withFileTypes: true }) + const promises: Promise[] = [] + + for (const entry of entries) { + promises.push(this.resolveDirectoryEntry(entry, resolvedTarget, fileInfo, depth + 1)) + } + + await Promise.all(promises) + } else if (stats.isSymbolicLink()) { + // Handle nested symlinks + await this.resolveSymlink(resolvedTarget, fileInfo, depth + 1) + } + } catch { + // Skip invalid symlinks + } + } + + /** + * Create a discovered workflow object from a file + * @param filePath - Path to workflow file + * @param originalPath - Original path (for symlinks) + * @param source - Source type + * @returns Discovered workflow or undefined if file cannot be read + */ + private async createWorkflowFromFile( + filePath: string, + originalPath: string, + source: "global" | "workspace", + ): Promise { + try { + const content = await fs.readFile(filePath, "utf-8") + const metadata = this.metadataExtractor.extractMetadata(content) + + // Extract workflow name from filename (strip .md extension) + const filename = path.basename(originalPath) + const name = this.getWorkflowNameFromFile(filename) + + return { + name, + commandName: `/${name}`, + description: metadata.description, + arguments: metadata.arguments, + filePath, + source, + enabled: true, // Default to enabled, will be updated by workflow toggles + } + } catch (error) { + console.warn(`Failed to read workflow file ${filePath}:`, error) + return undefined + } + } + + /** + * Extract workflow name from filename (strip .md extension only) + * @param filename - Filename with or without extension + * @returns Workflow name + */ + private getWorkflowNameFromFile(filename: string): string { + if (filename.toLowerCase().endsWith(".md")) { + return filename.slice(0, -3) + } + return filename + } + + /** + * Check if a file is a markdown file + * @param filename - Filename to check + * @returns True if file has .md extension + */ + private isMarkdownFile(filename: string): boolean { + return filename.toLowerCase().endsWith(".md") + } +} diff --git a/src/core/workflow-discovery/__tests__/WorkflowMetadataExtractor.spec.ts b/src/core/workflow-discovery/__tests__/WorkflowMetadataExtractor.spec.ts new file mode 100644 index 00000000000..e8998a2409d --- /dev/null +++ b/src/core/workflow-discovery/__tests__/WorkflowMetadataExtractor.spec.ts @@ -0,0 +1,226 @@ +// kilocode_change - new file + +import { describe, test, expect } from "vitest" +import { WorkflowMetadataExtractor } from "../WorkflowMetadataExtractor" + +describe("WorkflowMetadataExtractor", () => { + let extractor: WorkflowMetadataExtractor + + beforeEach(() => { + extractor = new WorkflowMetadataExtractor() + }) + + describe("parseFrontmatter", () => { + it("should parse valid YAML frontmatter", () => { + const content = `--- +description: Test workflow +arguments: --verbose +--- +Workflow content here` + + const result = extractor.parseFrontmatter(content) + + expect(result.frontmatter.description).toBe("Test workflow") + expect(result.frontmatter.arguments).toBe("--verbose") + expect(result.content).toBe("Workflow content here") + }) + + it("should handle content without frontmatter", () => { + const content = "Just workflow content without frontmatter" + + const result = extractor.parseFrontmatter(content) + + expect(result.frontmatter).toEqual({}) + expect(result.content).toBe("Just workflow content without frontmatter") + }) + + it("should handle malformed frontmatter gracefully", () => { + const content = `--- +invalid yaml: [unclosed +--- +Content` + + const result = extractor.parseFrontmatter(content) + + // gray-matter returns entire content when frontmatter is malformed + expect(result.frontmatter).toEqual({}) + expect(result.content).toBe(content) + }) + + it("should handle empty frontmatter", () => { + const content = `--- +--- +Workflow content` + + const result = extractor.parseFrontmatter(content) + + expect(result.frontmatter).toEqual({}) + expect(result.content).toBe("Workflow content") + }) + }) + + describe("extractDescription", () => { + it("should extract description when present", () => { + const frontmatter = { + description: "A test workflow for testing purposes", + } + + const result = extractor.extractDescription(frontmatter) + + expect(result).toBe("A test workflow for testing purposes") + }) + + it("should return undefined when description is missing", () => { + const frontmatter = {} + + const result = extractor.extractDescription(frontmatter) + + expect(result).toBeUndefined() + }) + + it("should return undefined when description is empty string", () => { + const frontmatter = { + description: " ", + } + + const result = extractor.extractDescription(frontmatter) + + expect(result).toBeUndefined() + }) + + it("should truncate description to 30 words", () => { + const longDescription = + "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty-one twenty-two twenty-three twenty-four twenty-five twenty-six twenty-seven twenty-eight twenty-nine thirty thirty-one" + + const frontmatter = { + description: longDescription, + } + + const result = extractor.extractDescription(frontmatter) + + expect(result).toBeDefined() + // Result should end with "..." after truncation + expect(result).toContain("...") + // Should be truncated to 30 words plus "..." + expect(result).toBe( + "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty-one twenty-two twenty-three twenty-four twenty-five twenty-six twenty-seven twenty-eight twenty-nine thirty...", + ) + }) + + it("should not truncate description with exactly 30 words", () => { + const exactDescription = + "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty-one twenty-two twenty-three twenty-four twenty-five twenty-six twenty-seven twenty-eight twenty-nine thirty" + + const frontmatter = { + description: exactDescription, + } + + const result = extractor.extractDescription(frontmatter) + + expect(result).toBe(exactDescription) + expect(result).not.toContain("...") + }) + + it("should handle description with extra whitespace", () => { + const frontmatter = { + description: " Test description with spaces ", + } + + const result = extractor.extractDescription(frontmatter) + + expect(result).toBe("Test description with spaces") + }) + }) + + describe("extractArguments", () => { + it("should extract arguments when present", () => { + const frontmatter = { + arguments: "--verbose --output=file.txt", + } + + const result = extractor.extractArguments(frontmatter) + + expect(result).toBe("--verbose --output=file.txt") + }) + + it("should return undefined when arguments is missing", () => { + const frontmatter = {} + + const result = extractor.extractArguments(frontmatter) + + expect(result).toBeUndefined() + }) + + it("should return undefined when arguments is empty string", () => { + const frontmatter = { + arguments: " ", + } + + const result = extractor.extractArguments(frontmatter) + + expect(result).toBeUndefined() + }) + + it("should handle arguments with extra whitespace", () => { + const frontmatter = { + arguments: " --verbose ", + } + + const result = extractor.extractArguments(frontmatter) + + expect(result).toBe("--verbose") + }) + }) + + describe("extractMetadata", () => { + it("should extract all metadata from workflow content", () => { + const content = `--- +description: Test workflow +arguments: --verbose +--- +Workflow content here` + + const result = extractor.extractMetadata(content) + + expect(result.description).toBe("Test workflow") + expect(result.arguments).toBe("--verbose") + expect(result.content).toBe("Workflow content here") + }) + + it("should handle workflow with only description", () => { + const content = `--- +description: Just a description +--- +Content` + + const result = extractor.extractMetadata(content) + + expect(result.description).toBe("Just a description") + expect(result.arguments).toBeUndefined() + expect(result.content).toBe("Content") + }) + + it("should handle workflow with only arguments", () => { + const content = `--- +arguments: --test +--- +Content` + + const result = extractor.extractMetadata(content) + + expect(result.description).toBeUndefined() + expect(result.arguments).toBe("--test") + expect(result.content).toBe("Content") + }) + + it("should handle workflow without frontmatter", () => { + const content = "Plain workflow content" + + const result = extractor.extractMetadata(content) + + expect(result.description).toBeUndefined() + expect(result.arguments).toBeUndefined() + expect(result.content).toBe("Plain workflow content") + }) + }) +}) diff --git a/src/core/workflow-discovery/getWorkflowsForEnvironment.ts b/src/core/workflow-discovery/getWorkflowsForEnvironment.ts new file mode 100644 index 00000000000..575f7481109 --- /dev/null +++ b/src/core/workflow-discovery/getWorkflowsForEnvironment.ts @@ -0,0 +1,131 @@ +// kilocode_change - new file + +import type { DiscoveredWorkflow } from "./types" +import { WorkflowDiscoveryService } from "./WorkflowDiscoveryService" +import { EXPERIMENT_IDS, experiments as Experiments } from "../../shared/experiments" + +/** + * Singleton instance of workflow discovery service + */ +let workflowDiscoveryService: WorkflowDiscoveryService | null = null + +/** + * Get or create workflow discovery service instance + * @returns Workflow discovery service instance + */ +function getWorkflowDiscoveryService(): WorkflowDiscoveryService { + if (!workflowDiscoveryService) { + workflowDiscoveryService = new WorkflowDiscoveryService({ + enableCache: true, + cacheTtlMs: 5 * 60 * 1000, // 5 minutes + }) + } + return workflowDiscoveryService +} + +/** + * Format discovered workflows for environment details + * @param workflows - Array of discovered workflows + * @returns Formatted string for environment details + */ +function formatWorkflowsForEnvironment(workflows: DiscoveredWorkflow[]): string { + if (workflows.length === 0) { + return "(No workflows available)" + } + + const lines: string[] = [] + + // Group by source + const globalWorkflows = workflows.filter((w) => w.source === "global" && w.enabled) + const workspaceWorkflows = workflows.filter((w) => w.source === "workspace" && w.enabled) + + if (globalWorkflows.length > 0) { + lines.push("## Global Workflows") + for (const workflow of globalWorkflows) { + const line = `- \`${workflow.commandName}\`` + if (workflow.description) { + lines.push(`${line}: ${workflow.description}`) + } else { + lines.push(line) + } + } + } + + if (workspaceWorkflows.length > 0) { + if (globalWorkflows.length > 0) { + lines.push("") // Empty line between sections + } + lines.push("## Workspace Workflows") + for (const workflow of workspaceWorkflows) { + const line = `- \`${workflow.commandName}\`` + if (workflow.description) { + lines.push(`${line}: ${workflow.description}`) + } else { + lines.push(line) + } + } + } + + return lines.join("\n") +} + +/** + * Get workflow information for environment details + * This function is called by getEnvironmentDetails to add workflow information + * when the workflow discovery experiment is enabled. + * + * @param cwd - Current working directory + * @param experiments - Experiments configuration + * @param enabledWorkflows - Map of enabled workflows (path -> boolean) + * @returns Formatted workflow information string, or empty string if experiment is disabled + */ +export async function getWorkflowsForEnvironment( + cwd: string, + experiments: Record = {}, + enabledWorkflows?: Map, +): Promise { + // kilocode_change: Use Experiments.isEnabled to properly check experiment status with fallback to defaults + // Check if workflow discovery experiment is enabled // kilocode_change + if (!Experiments.isEnabled(experiments, EXPERIMENT_IDS.AUTO_EXECUTE_WORKFLOW)) { + return "" + } + + try { + const service = getWorkflowDiscoveryService() + const result = await service.discoverWorkflows(cwd, enabledWorkflows) + + // Only include enabled workflows + const enabledWorkflowsList = result.workflows.filter((w) => w.enabled) + + if (enabledWorkflowsList.length === 0) { + return "" + } + + const formatted = formatWorkflowsForEnvironment(enabledWorkflowsList) + return `\n\n# Available Workflows\n${formatted}` + } catch (error) { + // Log error but don't break environment details generation + console.warn("[WorkflowDiscovery] Failed to discover workflows for environment details:", error) + return "" + } +} + +/** + * Clear workflow discovery cache + * This should be called when workflow files are added/removed/modified + */ +export function clearWorkflowDiscoveryCache(): void { + if (workflowDiscoveryService) { + workflowDiscoveryService.clearCache() + } +} + +/** + * Clear workflow discovery cache for a specific directory + * @param cwd - Current working directory + */ +export function clearWorkflowDiscoveryCacheForDir(cwd: string): void { + if (workflowDiscoveryService) { + workflowDiscoveryService.clearCacheForDir(cwd) + } +} diff --git a/src/core/workflow-discovery/index.ts b/src/core/workflow-discovery/index.ts new file mode 100644 index 00000000000..f891823fe56 --- /dev/null +++ b/src/core/workflow-discovery/index.ts @@ -0,0 +1,12 @@ +// kilocode_change - new file + +export { WorkflowDiscoveryService } from "./WorkflowDiscoveryService" +export { WorkflowMetadataExtractor } from "./WorkflowMetadataExtractor" +export { WorkflowScanner } from "./WorkflowScanner" +export type { + DiscoveredWorkflow, + WorkflowDiscoveryConfig, + WorkflowDiscoveryResult, + WorkflowFrontmatter, + WorkflowCacheEntry, +} from "./types" diff --git a/src/core/workflow-discovery/types.ts b/src/core/workflow-discovery/types.ts new file mode 100644 index 00000000000..2fe91f8acc4 --- /dev/null +++ b/src/core/workflow-discovery/types.ts @@ -0,0 +1,66 @@ +// kilocode_change - new file + +/** + * Cache entry for discovered workflows + */ +export interface WorkflowCacheEntry { + workflows: DiscoveredWorkflow[] + timestamp: number +} + +/** + * Metadata about a discovered workflow + */ +export interface DiscoveredWorkflow { + /** Workflow name (from filename without .md extension) */ + name: string + /** Command name with / prefix (e.g., "/analyze-codebase") */ + commandName: string + /** Short description from YAML frontmatter (truncated to 30 words) */ + description?: string + /** Arguments hint from YAML frontmatter */ + arguments?: string + /** Full path to the workflow file */ + filePath: string + /** Origin location */ + source: "global" | "workspace" + /** Whether workflow is currently enabled */ + enabled: boolean +} + +/** + * Parsed frontmatter from workflow file + */ +export interface WorkflowFrontmatter { + description?: string + arguments?: string + [key: string]: unknown +} + +/** + * Configuration for workflow discovery + */ +export interface WorkflowDiscoveryConfig { + /** Whether to include global workflows */ + includeGlobal: boolean + /** Whether to include workspace workflows */ + includeWorkspace: boolean + /** Whether to cache discovered workflows */ + enableCache: boolean + /** Cache TTL in milliseconds */ + cacheTtlMs: number +} + +/** + * Result of workflow discovery operation + */ +export interface WorkflowDiscoveryResult { + /** All discovered workflows */ + workflows: DiscoveredWorkflow[] + /** Number of global workflows */ + globalCount: number + /** Number of workspace workflows */ + workspaceCount: number + /** Whether cache was used */ + fromCache: boolean +} diff --git a/src/services/workflow/__tests__/workflows.spec.ts b/src/services/workflow/__tests__/workflows.spec.ts new file mode 100644 index 00000000000..6ffb077adfe --- /dev/null +++ b/src/services/workflow/__tests__/workflows.spec.ts @@ -0,0 +1,115 @@ +// kilocode_change - new file + +import { describe, it, expect } from "vitest" +import * as path from "path" +import { getWorkflows, getWorkflow, getWorkflowNames, getWorkflowNameFromFile, isMarkdownFile } from "../workflows" + +const testWorkspaceDir = path.join(__dirname, "../../../") + +describe("getWorkflowNameFromFile", () => { + it("should strip .md extension only", () => { + expect(getWorkflowNameFromFile("my-workflow.md")).toBe("my-workflow") + expect(getWorkflowNameFromFile("test.txt")).toBe("test.txt") + expect(getWorkflowNameFromFile("no-extension")).toBe("no-extension") + expect(getWorkflowNameFromFile("multiple.dots.file.md")).toBe("multiple.dots.file") + expect(getWorkflowNameFromFile("api.config.md")).toBe("api.config") + expect(getWorkflowNameFromFile("deploy_prod.md")).toBe("deploy_prod") + }) + + it("should handle edge cases", () => { + // Files without extensions + expect(getWorkflowNameFromFile("workflow")).toBe("workflow") + expect(getWorkflowNameFromFile("my-workflow")).toBe("my-workflow") + + // Files with multiple dots - only strip .md extension + expect(getWorkflowNameFromFile("my.complex.workflow.md")).toBe("my.complex.workflow") + expect(getWorkflowNameFromFile("v1.2.3.txt")).toBe("v1.2.3.txt") + + // Edge cases + expect(getWorkflowNameFromFile(".")).toBe(".") + expect(getWorkflowNameFromFile("..")).toBe("..") + expect(getWorkflowNameFromFile(".hidden.md")).toBe(".hidden") + }) +}) + +describe("isMarkdownFile", () => { + it("should identify markdown files correctly", () => { + expect(isMarkdownFile("workflow.md")).toBe(true) + expect(isMarkdownFile("WORKFLOW.MD")).toBe(true) + expect(isMarkdownFile("Workflow.Md")).toBe(true) + expect(isMarkdownFile("workflow.markdown")).toBe(false) + expect(isMarkdownFile("workflow.txt")).toBe(false) + expect(isMarkdownFile("workflow")).toBe(false) + }) +}) + +describe("getWorkflows", () => { + it("should return array when workflow directories exist", async () => { + const workflows = await getWorkflows(testWorkspaceDir) + expect(Array.isArray(workflows)).toBe(true) + }) + + it("should return workflows with valid properties", async () => { + const workflows = await getWorkflows(testWorkspaceDir) + + workflows.forEach((workflow) => { + expect(workflow.name).toBeDefined() + expect(typeof workflow.name).toBe("string") + expect(workflow.source).toMatch(/^(project|global)$/) + expect(workflow.content).toBeDefined() + expect(typeof workflow.content).toBe("string") + }) + }) +}) + +describe("getWorkflowNames", () => { + it("should return array of strings", async () => { + const names = await getWorkflowNames(testWorkspaceDir) + expect(Array.isArray(names)).toBe(true) + + // If workflow names exist, they should be strings + names.forEach((name) => { + expect(typeof name).toBe("string") + expect(name.length).toBeGreaterThan(0) + }) + }) +}) + +describe("getWorkflow", () => { + it("should return undefined for non-existent workflow", async () => { + const result = await getWorkflow(testWorkspaceDir, "non-existent") + expect(result).toBeUndefined() + }) + + it("should load workflow with valid properties", async () => { + const workflows = await getWorkflows(testWorkspaceDir) + + if (workflows.length > 0) { + const firstWorkflow = workflows[0] + const loadedWorkflow = await getWorkflow(testWorkspaceDir, firstWorkflow.name) + + expect(loadedWorkflow).toBeDefined() + expect(loadedWorkflow?.name).toBe(firstWorkflow.name) + expect(loadedWorkflow?.source).toMatch(/^(project|global)$/) + expect(loadedWorkflow?.content).toBeDefined() + expect(typeof loadedWorkflow?.content).toBe("string") + } + }) +}) + +describe("workflow loading behavior", () => { + it("should handle multiple calls to getWorkflows", async () => { + const workflows1 = await getWorkflows(testWorkspaceDir) + const workflows2 = await getWorkflows(testWorkspaceDir) + + expect(Array.isArray(workflows1)).toBe(true) + expect(Array.isArray(workflows2)).toBe(true) + }) + + it("should handle invalid workflow names gracefully", async () => { + // These should not throw errors + expect(await getWorkflow(testWorkspaceDir, "")).toBeUndefined() + expect(await getWorkflow(testWorkspaceDir, " ")).toBeUndefined() + expect(await getWorkflow(testWorkspaceDir, "non/existent/path")).toBeUndefined() + }) +}) diff --git a/src/services/workflow/workflows.ts b/src/services/workflow/workflows.ts new file mode 100644 index 00000000000..0e33168514f --- /dev/null +++ b/src/services/workflow/workflows.ts @@ -0,0 +1,358 @@ +// kilocode_change - new file + +import fs from "fs/promises" +import * as path from "path" +import { Dirent } from "fs" +import matter from "gray-matter" + +/** + * Maximum depth for resolving symlinks to prevent cyclic symlink loops + */ +const MAX_DEPTH = 5 + +export interface Workflow { + name: string + content: string + source: "project" | "global" + filePath: string + description?: string + arguments?: string +} + +/** + * Information about a resolved workflow file + */ +interface WorkflowFileInfo { + /** Original path (symlink path if symlinked, otherwise the file path) */ + originalPath: string + /** Resolved path (target of symlink if symlinked, otherwise the file path) */ + resolvedPath: string +} + +/** + * Recursively resolve a symbolic link and collect workflow file info + */ +async function resolveWorkflowSymLink(symlinkPath: string, fileInfo: WorkflowFileInfo[], depth: number): Promise { + // Avoid cyclic symlinks + if (depth > MAX_DEPTH) { + return + } + try { + // Get the symlink target + const linkTarget = await fs.readlink(symlinkPath) + // Resolve the target path (relative to the symlink location) + const resolvedTarget = path.resolve(path.dirname(symlinkPath), linkTarget) + + // Check if the target is a file (use lstat to detect nested symlinks) + const stats = await fs.lstat(resolvedTarget) + if (stats.isFile()) { + // Only include markdown files + if (isMarkdownFile(resolvedTarget)) { + // For symlinks to files, store the symlink path as original and target as resolved + fileInfo.push({ originalPath: symlinkPath, resolvedPath: resolvedTarget }) + } + } else if (stats.isDirectory()) { + // Read the target directory and process its entries + const entries = await fs.readdir(resolvedTarget, { withFileTypes: true }) + const directoryPromises: Promise[] = [] + for (const entry of entries) { + directoryPromises.push(resolveWorkflowDirectoryEntry(entry, resolvedTarget, fileInfo, depth + 1)) + } + await Promise.all(directoryPromises) + } else if (stats.isSymbolicLink()) { + // Handle nested symlinks + await resolveWorkflowSymLink(resolvedTarget, fileInfo, depth + 1) + } + } catch { + // Skip invalid symlinks + } +} + +/** + * Recursively resolve directory entries and collect workflow file paths + */ +async function resolveWorkflowDirectoryEntry( + entry: Dirent, + dirPath: string, + fileInfo: WorkflowFileInfo[], + depth: number, +): Promise { + // Avoid cyclic symlinks + if (depth > MAX_DEPTH) { + return + } + + const fullPath = path.resolve(entry.parentPath || dirPath, entry.name) + if (entry.isFile()) { + // Only include markdown files + if (isMarkdownFile(entry.name)) { + // Regular file - both original and resolved paths are the same + fileInfo.push({ originalPath: fullPath, resolvedPath: fullPath }) + } + } else if (entry.isSymbolicLink()) { + // Await the resolution of the symbolic link + await resolveWorkflowSymLink(fullPath, fileInfo, depth + 1) + } +} + +/** + * Try to resolve a symlinked workflow file + */ +async function tryResolveSymlinkedWorkflow(filePath: string): Promise { + try { + const lstat = await fs.lstat(filePath) + if (lstat.isSymbolicLink()) { + // Get the symlink target + const linkTarget = await fs.readlink(filePath) + // Resolve the target path (relative to the symlink location) + const resolvedTarget = path.resolve(path.dirname(filePath), linkTarget) + + // Check if the target is a file + const stats = await fs.stat(resolvedTarget) + if (stats.isFile()) { + return resolvedTarget + } + } + } catch { + // Not a symlink or invalid symlink + } + return undefined +} + +/** + * Get all available workflows from global and project directories + * Priority order: project > global (later sources override earlier ones) + */ +export async function getWorkflows(cwd: string): Promise { + const workflows = new Map() + + // Scan global workflows first (lower priority) + const globalDir = path.join(getGlobalKiloCodeDirectory(), "workflows") + await scanWorkflowDirectory(globalDir, "global", workflows) + + // Scan project workflows (higher priority - override global) + const projectDir = path.join(getProjectKiloCodeDirectoryForCwd(cwd), "workflows") + await scanWorkflowDirectory(projectDir, "project", workflows) + + return Array.from(workflows.values()) +} + +/** + * Get a specific workflow by name (optimized to avoid scanning all workflows) + * Priority order: project > global + */ +export async function getWorkflow(cwd: string, name: string): Promise { + // Try to find the workflow directly without scanning all workflows + const projectDir = path.join(getProjectKiloCodeDirectoryForCwd(cwd), "workflows") + const globalDir = path.join(getGlobalKiloCodeDirectory(), "workflows") + + // Check project directory first (highest priority) + const projectWorkflow = await tryLoadWorkflow(projectDir, name, "project") + if (projectWorkflow) { + return projectWorkflow + } + + // Check global directory if not found in project + return await tryLoadWorkflow(globalDir, name, "global") +} + +/** + * Try to load a specific workflow from a directory (supports symlinks) + */ +async function tryLoadWorkflow( + dirPath: string, + name: string, + source: "global" | "project", +): Promise { + try { + const stats = await fs.stat(dirPath) + if (!stats.isDirectory()) { + return undefined + } + + // Try to find the workflow file directly + const workflowFileName = `${name}.md` + const filePath = path.join(dirPath, workflowFileName) + + // Check if this is a regular file first + let resolvedPath = filePath + let content: string | undefined + + try { + content = await fs.readFile(filePath, "utf-8") + } catch { + // File doesn't exist or can't be read - try resolving as symlink + const symlinkedPath = await tryResolveSymlinkedWorkflow(filePath) + if (symlinkedPath) { + try { + content = await fs.readFile(symlinkedPath, "utf-8") + resolvedPath = symlinkedPath + } catch { + // Symlink target can't be read + return undefined + } + } else { + return undefined + } + } + + if (!content) { + return undefined + } + + let parsed + let description: string | undefined + let argumentsHint: string | undefined + let workflowContent: string + + try { + // Try to parse frontmatter with gray-matter + parsed = matter(content) + description = + typeof parsed.data.description === "string" && parsed.data.description.trim() + ? parsed.data.description.trim() + : undefined + argumentsHint = + typeof parsed.data.arguments === "string" && parsed.data.arguments.trim() + ? parsed.data.arguments.trim() + : undefined + workflowContent = parsed.content.trim() + } catch { + // If frontmatter parsing fails, treat the entire content as workflow content + description = undefined + argumentsHint = undefined + workflowContent = content.trim() + } + + return { + name, + content: workflowContent, + source, + filePath: resolvedPath, + description, + arguments: argumentsHint, + } + } catch { + // Directory doesn't exist or can't be read + return undefined + } +} + +/** + * Get workflow names for autocomplete + */ +export async function getWorkflowNames(cwd: string): Promise { + const workflows = await getWorkflows(cwd) + return workflows.map((workflow) => workflow.name) +} + +/** + * Scan a specific workflow directory (supports symlinks) + */ +async function scanWorkflowDirectory( + dirPath: string, + source: "global" | "project", + workflows: Map, +): Promise { + try { + const stats = await fs.stat(dirPath) + if (!stats.isDirectory()) { + return + } + + const entries = await fs.readdir(dirPath, { withFileTypes: true }) + + // Collect all workflow files, including those from symlinks + const fileInfo: WorkflowFileInfo[] = [] + const initialPromises: Promise[] = [] + + for (const entry of entries) { + initialPromises.push(resolveWorkflowDirectoryEntry(entry, dirPath, fileInfo, 0)) + } + + // Wait for all files to be resolved + await Promise.all(initialPromises) + + // Process each collected file + for (const { originalPath, resolvedPath } of fileInfo) { + // Workflow name comes from the original path (symlink name if symlinked) + const workflowName = getWorkflowNameFromFile(path.basename(originalPath)) + + try { + const content = await fs.readFile(resolvedPath, "utf-8") + + let parsed + let description: string | undefined + let argumentsHint: string | undefined + let workflowContent: string + + try { + // Try to parse frontmatter with gray-matter + parsed = matter(content) + description = + typeof parsed.data.description === "string" && parsed.data.description.trim() + ? parsed.data.description.trim() + : undefined + argumentsHint = + typeof parsed.data.arguments === "string" && parsed.data.arguments.trim() + ? parsed.data.arguments.trim() + : undefined + workflowContent = parsed.content.trim() + } catch { + // If frontmatter parsing fails, treat the entire content as workflow content + description = undefined + argumentsHint = undefined + workflowContent = content.trim() + } + + // Project workflows override global ones + if (source === "project" || !workflows.has(workflowName)) { + workflows.set(workflowName, { + name: workflowName, + content: workflowContent, + source, + filePath: resolvedPath, + description, + arguments: argumentsHint, + }) + } + } catch (error) { + console.warn(`Failed to read workflow file ${resolvedPath}:`, error) + } + } + } catch { + // Directory doesn't exist or can't be read - this is fine + } +} + +/** + * Extract workflow name from filename (strip .md extension only) + */ +export function getWorkflowNameFromFile(filename: string): string { + if (filename.toLowerCase().endsWith(".md")) { + return filename.slice(0, -3) + } + return filename +} + +/** + * Check if a file is a markdown file + */ +export function isMarkdownFile(filename: string): boolean { + return filename.toLowerCase().endsWith(".md") +} + +/** + * Get the global Kilo Code directory path + */ +function getGlobalKiloCodeDirectory(): string { + const homeDir = process.env.HOME || process.env.USERPROFILE || "" + return path.join(homeDir, ".kilocode") +} + +/** + * Get the project-level Kilo Code directory path for a given working directory + */ +function getProjectKiloCodeDirectoryForCwd(cwd: string): string { + return path.join(cwd, ".kilocode") +} diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index 7d18d9b6a29..307242e9512 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -41,7 +41,7 @@ describe("experiments", () => { multiFileApplyDiff: false, preventFocusDisruption: false, imageGeneration: false, - runSlashCommand: false, + autoExecuteWorkflow: false, multipleNativeToolCalls: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) @@ -55,7 +55,7 @@ describe("experiments", () => { multiFileApplyDiff: false, preventFocusDisruption: false, imageGeneration: false, - runSlashCommand: false, + autoExecuteWorkflow: false, multipleNativeToolCalls: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(true) @@ -69,7 +69,7 @@ describe("experiments", () => { multiFileApplyDiff: false, preventFocusDisruption: false, imageGeneration: false, - runSlashCommand: false, + autoExecuteWorkflow: false, multipleNativeToolCalls: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index bf4c4e14790..a12c8fecd75 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -7,7 +7,7 @@ export const EXPERIMENT_IDS = { POWER_STEERING: "powerSteering", PREVENT_FOCUS_DISRUPTION: "preventFocusDisruption", IMAGE_GENERATION: "imageGeneration", - RUN_SLASH_COMMAND: "runSlashCommand", + AUTO_EXECUTE_WORKFLOW: "autoExecuteWorkflow", MULTIPLE_NATIVE_TOOL_CALLS: "multipleNativeToolCalls", } as const satisfies Record @@ -26,7 +26,7 @@ export const experimentConfigsMap: Record = { POWER_STEERING: { enabled: false }, PREVENT_FOCUS_DISRUPTION: { enabled: false }, IMAGE_GENERATION: { enabled: false }, - RUN_SLASH_COMMAND: { enabled: false }, + AUTO_EXECUTE_WORKFLOW: { enabled: false }, // kilocode_change: Auto-execute workflows without approval MULTIPLE_NATIVE_TOOL_CALLS: { enabled: false }, } diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 1d5d3dc5603..b938273adcf 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -2,7 +2,6 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from " import { useSize } from "react-use" import { useTranslation, Trans } from "react-i18next" import deepEqual from "fast-deep-equal" -import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react" import type { ClineMessage, FollowUpData, SuggestionItem } from "@roo-code/types" import { Mode } from "@roo/modes" @@ -17,6 +16,8 @@ import { vscode } from "@src/utils/vscode" import { formatPathTooltip } from "@src/utils/formatPathTooltip" import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock" +// kilocode_change: Use extended SlashCommandItem for workflow execution +import { SlashCommandItem } from "./SlashCommandItem" import UpdateTodoListToolBlock from "./UpdateTodoListToolBlock" import { TodoChangeDisplay } from "./TodoChangeDisplay" import CodeAccordian from "../common/CodeAccordian" @@ -988,74 +989,23 @@ export const ChatRowContent = ({ ) case "runSlashCommand": { - const slashCommandInfo = tool + // kilocode_change: Add diagnostic logging for workflow display issue + console.log(`[ChatRow] Processing runSlashCommand tool:`, { + tool, + messageType: message.type, + isExpanded, + messageText: message.text, + }) + // kilocode_change end + // kilocode_change: Use extended SlashCommandItem for workflow execution return ( - <> -
- {toolIcon("play")} - - {message.type === "ask" - ? t("chat:slashCommand.wantsToRun") - : t("chat:slashCommand.didRun")} - -
-
- -
- - /{slashCommandInfo.command} - - {slashCommandInfo.source && ( - - {slashCommandInfo.source} - - )} -
- -
- {isExpanded && (slashCommandInfo.args || slashCommandInfo.description) && ( -
- {slashCommandInfo.args && ( -
- Arguments: - - {slashCommandInfo.args} - -
- )} - {slashCommandInfo.description && ( -
- {slashCommandInfo.description} -
- )} -
- )} -
- + ) } case "generateImage": @@ -1507,73 +1457,23 @@ export const ChatRowContent = ({ switch (sayTool.tool) { case "runSlashCommand": { - const slashCommandInfo = sayTool + // kilocode_change: Add diagnostic logging for workflow display issue + console.log(`[ChatRow] Processing say runSlashCommand tool:`, { + sayTool, + messageType: message.type, + isExpanded, + messageText: message.text, + }) + // kilocode_change end + // kilocode_change: Use extended SlashCommandItem for workflow execution return ( - <> -
- - {t("chat:slashCommand.didRun")} -
-
- - -
- - /{slashCommandInfo.command} - - {slashCommandInfo.args && ( - - {slashCommandInfo.args} - - )} -
- {slashCommandInfo.description && ( -
- {slashCommandInfo.description} -
- )} - {slashCommandInfo.source && ( -
- - {slashCommandInfo.source} - -
- )} -
-
-
- + ) } default: diff --git a/webview-ui/src/components/chat/SlashCommandItem.tsx b/webview-ui/src/components/chat/SlashCommandItem.tsx index 90d5b39e16d..15443658e82 100644 --- a/webview-ui/src/components/chat/SlashCommandItem.tsx +++ b/webview-ui/src/components/chat/SlashCommandItem.tsx @@ -1,4 +1,6 @@ import React from "react" +import { useTranslation } from "react-i18next" +import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react" import { Edit, Trash2 } from "lucide-react" import type { Command } from "@roo/ExtensionMessage" @@ -6,15 +8,196 @@ import type { Command } from "@roo/ExtensionMessage" import { useAppTranslation } from "@/i18n/TranslationContext" import { Button, StandardTooltip } from "@/components/ui" import { vscode } from "@/utils/vscode" +import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock" +// kilocode_change start: Add workflow execution support interface SlashCommandItemProps { - command: Command - onDelete: (command: Command) => void + // Original props for command list mode + command?: Command + onDelete?: (command: Command) => void onClick?: (command: Command) => void + + // New props for workflow execution mode + isWorkflowExecution?: boolean + tool?: { + tool: "runSlashCommand" + command: string + args?: string + source?: string + description?: string + } + messageType?: "ask" | "say" + isExpanded?: boolean + onToggleExpand?: () => void } +// kilocode_change end + +export const SlashCommandItem: React.FC = ({ + command, + onDelete, + onClick, + isWorkflowExecution, + tool, + messageType = "say", + isExpanded = false, + onToggleExpand, +}) => { + const { t: tList } = useAppTranslation() // kilocode_change: always call hooks at top level + const { t: t2 } = useTranslation() // kilocode_change: for workflow execution translations -export const SlashCommandItem: React.FC = ({ command, onDelete, onClick }) => { - const { t } = useAppTranslation() + // kilocode_change: Add diagnostic logging for workflow display issue + console.log(`[SlashCommandItem] Rendering with props:`, { + isWorkflowExecution, + tool, + messageType, + isExpanded, + toolKeys: tool ? Object.keys(tool) : "no tool", + }) + // kilocode_change end + + // kilocode_change start: Workflow execution mode + if (isWorkflowExecution && tool) { + // kilocode_change: Add diagnostic logging for workflow display issue + console.log(`[SlashCommandItem] Rendering workflow execution UI with tool:`, tool) + // kilocode_change end + const slashCommandInfo = tool + + return ( + <> +
+ + + {messageType === "ask" ? t2("chat:slashCommand.wantsToRun") : t2("chat:slashCommand.didRun")} + +
+ {messageType === "ask" ? ( +
+ +
+ + /{slashCommandInfo.command} + + {slashCommandInfo.source && ( + + {slashCommandInfo.source} + + )} +
+ +
+ {isExpanded && (slashCommandInfo.args || slashCommandInfo.description) && ( +
+ {slashCommandInfo.args && ( +
+ Arguments: + + {slashCommandInfo.args} + +
+ )} + {slashCommandInfo.description && ( +
+ {slashCommandInfo.description} +
+ )} +
+ )} +
+ ) : ( +
+ + +
+ + /{slashCommandInfo.command} + + {slashCommandInfo.args && ( + + {slashCommandInfo.args} + + )} +
+ {slashCommandInfo.description && ( +
+ {slashCommandInfo.description} +
+ )} + {slashCommandInfo.source && ( +
+ + {slashCommandInfo.source} + +
+ )} +
+
+
+ )} + + ) + } + // kilocode_change end + + // kilocode_change: Add diagnostic logging for workflow display issue + console.log( + `[SlashCommandItem] Not rendering workflow execution - returning null. isWorkflowExecution:`, + isWorkflowExecution, + ", tool:", + tool, + ) + // kilocode_change end + + // Original command list mode + if (!command || !onDelete) { + return null + } // Built-in commands cannot be edited or deleted const isBuiltIn = command.source === "built-in" @@ -56,7 +239,7 @@ export const SlashCommandItem: React.FC = ({ command, onD {/* Action buttons - only show for non-built-in commands */} {!isBuiltIn && (
- + - +