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 && ( - + = ({ command, onD - + ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "chat:slashCommand.wantsToRun": "Kilo wants to run a workflow:", + "chat:slashCommand.didRun": "Kilo ran a workflow:", + } + return translations[key] || key + }, + }), + Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => { + return <>{children || i18nKey}> + }, + initReactI18next: { + type: "3rdParty", + init: () => {}, + }, +})) + +// Mock VSCodeBadge +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeBadge: ({ children, ...props }: { children: React.ReactNode }) => {children}, +})) + +// Mock vscode +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock useAppTranslation +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +const mockOnToggleExpand = vi.fn() +const mockOnDelete = vi.fn() +const mockOnClick = vi.fn() + +describe("SlashCommandItem", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("Command List Mode (Original)", () => { + it("should display command with name and description", () => { + const command = { + name: "test", + description: "Test command", + source: "project", + } + + const { getByText } = render( + , + ) + + expect(getByText("test")).toBeInTheDocument() + expect(getByText("Test command")).toBeInTheDocument() + }) + + it("should display command without description", () => { + const command = { + name: "simple", + source: "global", + } + + const { getByText, queryByText } = render( + , + ) + + expect(getByText("simple")).toBeInTheDocument() + expect(queryByText(/description/i)).not.toBeInTheDocument() + }) + + it("should show edit and delete buttons for non-built-in commands", () => { + const command = { + name: "custom", + source: "project", + } + + const { container } = render( + , + ) + + // Check for edit and delete buttons (they use lucide-react icons) + expect(container.querySelector("button")).toBeInTheDocument() + }) + + it("should not show edit and delete buttons for built-in commands", () => { + const command = { + name: "builtin", + source: "built-in", + } + + const { container } = render( + , + ) + + // Built-in commands should not have action buttons + const buttons = container.querySelectorAll("button") + expect(buttons.length).toBe(0) + }) + + it("should call onClick when command name is clicked", () => { + const command = { + name: "clickable", + source: "project", + } + + const { getByText } = render( + , + ) + + getByText("clickable").click() + expect(mockOnClick).toHaveBeenCalledTimes(1) + }) + }) + + describe("Workflow Execution Mode", () => { + it("should display workflow execution ask message with command only", () => { + const tool = { + tool: "runSlashCommand" as const, + command: "init", + } + + const { getByText } = render( + , + ) + + expect(getByText("Kilo wants to run a workflow:")).toBeInTheDocument() + expect(getByText("/init")).toBeInTheDocument() + }) + + it("should display workflow execution ask message with command and args", () => { + const tool = { + tool: "runSlashCommand" as const, + command: "test", + args: "focus on unit tests", + description: "Run project tests", + source: "project", + } + + const { getByText } = render( + , + ) + + expect(getByText("Kilo wants to run a workflow:")).toBeInTheDocument() + expect(getByText("/test")).toBeInTheDocument() + expect(getByText("Arguments:")).toBeInTheDocument() + expect(getByText("focus on unit tests")).toBeInTheDocument() + expect(getByText("Run project tests")).toBeInTheDocument() + expect(getByText("project")).toBeInTheDocument() + }) + + it("should display workflow execution say message", () => { + const tool = { + tool: "runSlashCommand" as const, + command: "deploy", + source: "global", + } + + const { getByText } = render( + , + ) + + expect(getByText("Kilo ran a workflow:")).toBeInTheDocument() + expect(getByText("/deploy")).toBeInTheDocument() + expect(getByText("global")).toBeInTheDocument() + }) + + it("should display workflow execution say message with args and description", () => { + const tool = { + tool: "runSlashCommand" as const, + command: "build", + args: "--production", + description: "Build for production environment", + source: "built-in", + } + + const { getByText } = render( + , + ) + + expect(getByText("Kilo ran a workflow:")).toBeInTheDocument() + expect(getByText("/build")).toBeInTheDocument() + expect(getByText("--production")).toBeInTheDocument() + expect(getByText("Build for production environment")).toBeInTheDocument() + expect(getByText("built-in")).toBeInTheDocument() + }) + + it("should call onToggleExpand when clicking expandable ask message", () => { + const tool = { + tool: "runSlashCommand" as const, + command: "test", + args: "all tests", + } + + const { container } = render( + , + ) + + // Click on expandable container + const expandableDiv = container.querySelector('[style*="cursor: pointer"]') + expect(expandableDiv).toBeInTheDocument() + + if (expandableDiv && expandableDiv instanceof HTMLElement) { + expandableDiv.click() + expect(mockOnToggleExpand).toHaveBeenCalledTimes(1) + } + }) + + it("should show chevron down when collapsed", () => { + const tool = { + tool: "runSlashCommand" as const, + command: "test", + } + + const { container } = render( + , + ) + + expect(container.querySelector(".codicon-chevron-down")).toBeInTheDocument() + expect(container.querySelector(".codicon-chevron-up")).not.toBeInTheDocument() + }) + + it("should show chevron up when expanded", () => { + const tool = { + tool: "runSlashCommand" as const, + command: "test", + } + + const { container } = render( + , + ) + + expect(container.querySelector(".codicon-chevron-up")).toBeInTheDocument() + expect(container.querySelector(".codicon-chevron-down")).not.toBeInTheDocument() + }) + + it("should not show expand/collapse details when collapsed and no args/description", () => { + const tool = { + tool: "runSlashCommand" as const, + command: "simple", + } + + const { queryByText } = render( + , + ) + + expect(queryByText("Arguments:")).not.toBeInTheDocument() + }) + + it("should show expand/collapse details when expanded with args", () => { + const tool = { + tool: "runSlashCommand" as const, + command: "test", + args: "--verbose", + } + + const { getByText } = render( + , + ) + + expect(getByText("Arguments:")).toBeInTheDocument() + expect(getByText("--verbose")).toBeInTheDocument() + }) + + it("should show source badge when provided", () => { + const tool = { + tool: "runSlashCommand" as const, + command: "workflow", + source: "project", + } + + const { getByText } = render( + , + ) + + expect(getByText("project")).toBeInTheDocument() + }) + + it("should not show source badge when not provided", () => { + const tool = { + tool: "runSlashCommand" as const, + command: "simple", + } + + const { container } = render( + , + ) + + // VSCodeBadge mock renders as span + const badges = container.querySelectorAll("span") + const sourceBadge = Array.from(badges).find( + (badge) => + badge.textContent === "project" || + badge.textContent === "global" || + badge.textContent === "built-in", + ) + expect(sourceBadge).not.toBeDefined() + }) + + it("should handle different source types", () => { + const sources = ["project", "global", "built-in"] as const + + sources.forEach((source) => { + const tool = { + tool: "runSlashCommand" as const, + command: "test", + source, + } + + const { getByText, unmount } = render( + , + ) + + expect(getByText(source)).toBeInTheDocument() + unmount() + }) + }) + + it("should display description in ask message when expanded", () => { + const tool = { + tool: "runSlashCommand" as const, + command: "test", + description: "This is a test workflow", + } + + const { getByText } = render( + , + ) + + expect(getByText("This is a test workflow")).toBeInTheDocument() + }) + + it("should display description in say message", () => { + const tool = { + tool: "runSlashCommand" as const, + command: "deploy", + description: "Deploy to production", + } + + const { getByText } = render( + , + ) + + expect(getByText("Deploy to production")).toBeInTheDocument() + }) + }) + + describe("Edge Cases", () => { + it("should return null when command is not provided in command list mode", () => { + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it("should return null when onDelete is not provided in command list mode", () => { + const command = { + name: "test", + source: "project", + } + + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it("should not render workflow execution when tool is not provided", () => { + const { container } = render( + , + ) + + // Should fall back to command list mode and return null + expect(container.firstChild).toBeNull() + }) + }) +}) diff --git a/webview-ui/src/components/settings/ExperimentalSettings.tsx b/webview-ui/src/components/settings/ExperimentalSettings.tsx index 140b389ff9f..986cd0f4194 100644 --- a/webview-ui/src/components/settings/ExperimentalSettings.tsx +++ b/webview-ui/src/components/settings/ExperimentalSettings.tsx @@ -80,7 +80,10 @@ export const ExperimentalSettings = ({ .filter((config) => config[0] !== "MARKETPLACE") // kilocode_change: we have our own market place, filter this out for now // Hide MULTIPLE_NATIVE_TOOL_CALLS - feature is on hold .filter(([key]) => key !== "MULTIPLE_NATIVE_TOOL_CALLS") + // Hide WORKFLOW_DISCOVERY - use AUTO_EXECUTE_WORKFLOW instead // kilocode_change + .filter(([key]) => key !== "WORKFLOW_DISCOVERY") // kilocode_change .map((config) => { + // kilocode_change start: Special handling for experiments with custom components if (config[0] === "MULTI_FILE_APPLY_DIFF") { return ( ) } - // kilocode_change start if (config[0] === "MORPH_FAST_APPLY") { const enabled = experiments[EXPERIMENT_IDS[config[0] as keyof typeof EXPERIMENT_IDS]] ?? false @@ -121,7 +123,6 @@ export const ExperimentalSettings = ({ ) } - // kilocode_change end if ( config[0] === "IMAGE_GENERATION" && setImageGenerationProvider && @@ -148,6 +149,17 @@ export const ExperimentalSettings = ({ /> ) } + // kilocode_change end + // kilocode_change start: Skip experiments that have special handling above + // to prevent duplicates in the UI when conditions aren't met + if ( + config[0] === "MULTI_FILE_APPLY_DIFF" || + config[0] === "MORPH_FAST_APPLY" || + config[0] === "IMAGE_GENERATION" + ) { + return null + } + // kilocode_change end return ( { preventFocusDisruption: false, morphFastApply: false, // kilocode_change speechToText: false, // kilocode_change + workflowDiscovery: false, // kilocode_change + autoExecuteWorkflow: false, // kilocode_change newTaskRequireTodos: false, imageGeneration: false, runSlashCommand: false, @@ -304,6 +306,8 @@ describe("mergeExtensionState", () => { preventFocusDisruption: false, morphFastApply: false, // kilocode_change speechToText: false, // kilocode_change + workflowDiscovery: false, // kilocode_change + autoExecuteWorkflow: false, // kilocode_change newTaskRequireTodos: false, imageGeneration: false, runSlashCommand: false, diff --git a/webview-ui/src/i18n/locales/ar/settings.json b/webview-ui/src/i18n/locales/ar/settings.json index 4bb57148c69..9f300c0d41b 100644 --- a/webview-ui/src/i18n/locales/ar/settings.json +++ b/webview-ui/src/i18n/locales/ar/settings.json @@ -1074,7 +1074,7 @@ "warningMissingKey": "⚠️ مفتاح API مطلوب لإنشاء الصور، يرجى تكوينه أعلاه.", "successConfigured": "✓ تم تكوين إنشاء الصور وهو جاهز للاستخدام" }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "تمكين أوامر الشرطة المدفوعة بالنموذج", "description": "عند التمكين، يمكن لـ Kilo Code تشغيل أوامر الشرطة الخاصة بك لتنفيذ سير العمل." }, diff --git a/webview-ui/src/i18n/locales/cs/settings.json b/webview-ui/src/i18n/locales/cs/settings.json index b30ef443623..a17204faf55 100644 --- a/webview-ui/src/i18n/locales/cs/settings.json +++ b/webview-ui/src/i18n/locales/cs/settings.json @@ -1054,7 +1054,7 @@ "warningMissingKey": "⚠️ API klíč je vyžadován pro generování obrázků, nakonfigurujte ho výše.", "successConfigured": "✓ Generování obrázků je nakonfigurováno a připraveno k použití" }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "Povolit slash příkazy iniciované modelem", "description": "Když je povoleno, Kilo Code může spouštět vaše slash příkazy k provedení pracovních toků." }, diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 9cce9de263a..30eb476b161 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -971,7 +971,7 @@ "warningMissingKey": "⚠️ Ein API-Schlüssel ist für die Bildgenerierung erforderlich, bitte konfiguriere ihn oben.", "successConfigured": "✓ Bildgenerierung ist konfiguriert und einsatzbereit" }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "Modellinitierte Slash-Befehle aktivieren", "description": "Wenn aktiviert, kann Kilo Code deine Slash-Befehle ausführen, um Workflows zu starten." }, diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index aaa340f9eb4..7bbd17945b9 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -457,8 +457,8 @@ } }, "slashCommand": { - "wantsToRun": "Kilo Code wants to run a slash command", - "didRun": "Kilo Code ran a slash command" + "wantsToRun": "Kilo Code wants to run a workflow", + "didRun": "Kilo Code ran a workflow" }, "queuedMessages": { "title": "Queued Messages", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 26e31178007..1ec9211a297 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -1012,9 +1012,9 @@ "warningMissingKey": "⚠️ An API key is required for image generation, please configure it above.", "successConfigured": "✓ Image generation is configured and ready to use" }, - "RUN_SLASH_COMMAND": { - "name": "Enable model-initiated slash commands", - "description": "When enabled, Kilo Code can run your slash commands to execute workflows." + "AUTO_EXECUTE_WORKFLOW": { + "name": "Enable Kilo workflow access", + "description": "When enabled, Kilo Code can access and execute workflow slash commands to retrieve their content without requiring approval." }, "MULTIPLE_NATIVE_TOOL_CALLS": { "name": "Parallel tool calls", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 96a2abd3c59..a58486e51a6 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -975,7 +975,7 @@ "name": "Autocomplete", "description": "Habilita las funciones de Autocomplete para sugerencias de código rápidas y mejoras directamente en tu editor. Incluye Tarea Rápida (Cmd+I) para cambios específicos y Autocomplete para mejoras contextuales." }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "Habilitar comandos slash iniciados por el modelo", "description": "Cuando está habilitado, Kilo Code puede ejecutar tus comandos slash para ejecutar flujos de trabajo." }, diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index c713296abb9..b7fd719b86d 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -975,7 +975,7 @@ "name": "Autocomplete", "description": "Active les fonctionnalités Autocomplete pour des suggestions de code rapides et des améliorations directement dans ton éditeur. Inclut Tâche Rapide (Cmd+I) pour des changements ciblés et Autocomplete pour des améliorations contextuelles." }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "Activer les commandes slash initiées par le modèle", "description": "Lorsque activé, Kilo Code peut exécuter tes commandes slash pour lancer des workflows." }, diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 5f170c81e49..0ee9068c331 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -976,7 +976,7 @@ "name": "Autocomplete", "description": "आपके एडिटर में सीधे त्वरित कोड सुझाव और सुधार के लिए Autocomplete सुविधाओं को सक्षम करें। लक्षित परिवर्तनों के लिए त्वरित कार्य (Cmd+I) और संदर्भित सुधारों के लिए Autocomplete शामिल है।" }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "मॉडल द्वारा शुरू किए गए स्लैश कमांड सक्षम करें", "description": "जब सक्षम होता है, Kilo Code वर्कफ़्लो चलाने के लिए आपके स्लैश कमांड चला सकता है।" }, diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 1f2969b257f..0d55c1e4c0f 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -997,7 +997,7 @@ "name": "Autocomplete", "description": "Aktifkan fitur Autocomplete untuk saran kode cepat dan perbaikan langsung di editor Anda. Termasuk Tugas Cepat (Cmd+I) untuk perubahan yang ditargetkan dan Autocomplete untuk perbaikan kontekstual." }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "Aktifkan perintah slash yang dimulai model", "description": "Ketika diaktifkan, Kilo Code dapat menjalankan perintah slash Anda untuk mengeksekusi alur kerja." }, diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 70455bee4e5..2dacb7ab857 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -977,7 +977,7 @@ "relace": "Relace Apply v3" } }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "Abilita comandi slash avviati dal modello", "description": "Quando abilitato, Kilo Code può eseguire i tuoi comandi slash per eseguire flussi di lavoro." }, diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index ccf1e2b664b..6f084c9c2e8 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -977,7 +977,7 @@ "relace": "Relace Apply v3" } }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "モデル開始スラッシュコマンドを有効にする", "description": "有効にすると、Kilo Codeがワークフローを実行するためにあなたのスラッシュコマンドを実行できます。" }, diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 8f6079715ef..f9bed8db842 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -976,7 +976,7 @@ "name": "Autocomplete", "description": "에디터에서 직접 빠른 코드 제안과 개선을 위한 Autocomplete 기능을 활성화합니다. 타겟 변경을 위한 빠른 작업(Cmd+I)과 컨텍스트 개선을 위한 Autocomplete가 포함됩니다." }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "모델 시작 슬래시 명령 활성화", "description": "활성화되면 Kilo Code가 워크플로를 실행하기 위해 슬래시 명령을 실행할 수 있습니다." }, diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 6e9e5d29768..f722fb892b4 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -976,7 +976,7 @@ "name": "Autocomplete", "description": "Schakel Autocomplete-functies in voor snelle codesuggesties en verbeteringen direct in je editor. Bevat Snelle Taak (Cmd+I) voor gerichte wijzigingen en Autocomplete voor contextuele verbeteringen." }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "Model-geïnitieerde slash-commando's inschakelen", "description": "Wanneer ingeschakeld, kan Kilo Code je slash-commando's uitvoeren om workflows uit te voeren." }, diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index df1b606903e..46c13226a06 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -976,7 +976,7 @@ "name": "Autocomplete", "description": "Włącz funkcje Autocomplete dla szybkich sugestii kodu i ulepszeń bezpośrednio w twoim edytorze. Zawiera Szybkie Zadanie (Cmd+I) dla ukierunkowanych zmian i Autocomplete dla kontekstowych ulepszeń." }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "Włącz polecenia slash inicjowane przez model", "description": "Gdy włączone, Kilo Code może uruchamiać twoje polecenia slash w celu wykonywania przepływów pracy." }, diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 05254deeebe..edf0f3490d7 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -976,7 +976,7 @@ "name": "Autocomplete", "description": "Habilita as funcionalidades do Autocomplete para sugestões de código rápidas e melhorias diretamente no seu editor. Inclui Tarefa Rápida (Cmd+I) para mudanças direcionadas e Autocomplete para melhorias contextuais." }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "Ativar comandos slash iniciados pelo modelo", "description": "Quando ativado, Kilo Code pode executar seus comandos slash para executar fluxos de trabalho." }, diff --git a/webview-ui/src/i18n/locales/th/settings.json b/webview-ui/src/i18n/locales/th/settings.json index 6920f3b78cf..fc6340d89cc 100644 --- a/webview-ui/src/i18n/locales/th/settings.json +++ b/webview-ui/src/i18n/locales/th/settings.json @@ -1041,7 +1041,7 @@ "warningMissingKey": "⚠️ ต้องใช้ API key สำหรับการสร้างภาพ กรุณาตั้งค่าข้างต้น", "successConfigured": "✓ การสร้างภาพได้รับการตั้งค่าและพร้อมใช้งาน" }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "เปิดใช้งานคำสั่ง slash ที่เริ่มต้นโดยโมเดล", "description": "เมื่อเปิดใช้งาน Kilo Code สามารถรันคำสั่ง slash ของคุณเพื่อดำเนินการตามเวิร์กโฟลว์" }, diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 03253251547..8d673f491dd 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -977,7 +977,7 @@ "name": "Autocomplete", "description": "Editörünüzde doğrudan hızlı kod önerileri ve iyileştirmeler için Autocomplete özelliklerini etkinleştirin. Hedeflenen değişiklikler için Hızlı Görev (Cmd+I) ve bağlamsal iyileştirmeler için Autocomplete içerir." }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "Model tarafından başlatılan slash komutlarını etkinleştir", "description": "Etkinleştirildiğinde, Kilo Code iş akışlarını yürütmek için slash komutlarınızı çalıştırabilir." }, diff --git a/webview-ui/src/i18n/locales/uk/settings.json b/webview-ui/src/i18n/locales/uk/settings.json index a64c5be61ca..ae5d2458c4b 100644 --- a/webview-ui/src/i18n/locales/uk/settings.json +++ b/webview-ui/src/i18n/locales/uk/settings.json @@ -1057,7 +1057,7 @@ "warningMissingKey": "⚠️ API ключ потрібен для генерації зображень, будь ласка, налаштуйте його вище.", "successConfigured": "✓ Генерація зображень налаштована і готова до використання" }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "Увімкнути slash команди, ініційовані моделлю", "description": "Коли увімкнено, Kilo Code може запускати ваші slash команди для виконання робочих процесів." }, diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index b7a24d909a8..b6d5b02ee20 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -976,7 +976,7 @@ "relace": "Relace Apply v3" } }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "Bật lệnh slash do mô hình khởi tạo", "description": "Khi được bật, Kilo Code có thể chạy các lệnh slash của bạn để thực hiện các quy trình làm việc." }, diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index d4db3a3c159..82b631fd713 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -980,7 +980,7 @@ "relace": "Relace Apply v3" } }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "启用模型发起的斜杠命令", "description": "启用后 Kilo Code 可运行斜杠命令执行工作流程。" }, diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 3303f9bc025..2c597757489 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -977,7 +977,7 @@ "name": "自動補全", "description": "啟用自動補全功能,在編輯器中直接提供快速程式碼建議和改進。包括針對性變更的快速工作(Cmd+I)和內容改進的自動補全" }, - "RUN_SLASH_COMMAND": { + "AUTO_EXECUTE_WORKFLOW": { "name": "啟用模型發起斜線命令", "description": "啟用時,Kilo Code 可以執行您的斜線命令來執行工作流程。" },