merge conflicts

This commit is contained in:
Elephant Lumps
2025-05-16 11:00:34 -07:00
31 changed files with 977 additions and 353 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix eternal loading states when the last message is a checkpoint
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Recent task list is now collapsible, allowing users to hide their recent tasks (e.g. when sharing their screen).
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add Tailwind CSS IntelliSense to the the recommended extensions list
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
new workflow feature
+6 -1
View File
@@ -1,5 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": ["dbaeumer.vscode-eslint", "connor4312.esbuild-problem-matchers", "ms-vscode.extension-test-runner"]
"recommendations": [
"dbaeumer.vscode-eslint",
"connor4312.esbuild-problem-matchers",
"ms-vscode.extension-test-runner",
"bradlc.vscode-tailwindcss"
]
}
+1
View File
@@ -85,6 +85,7 @@ message RuleFileRequest {
bool is_global = 2; // Common field for all operations
optional string rule_path = 3; // Path field for deleteRuleFile (optional)
optional string filename = 4; // Filename field for createRuleFile (optional)
optional string type = 5; // Type of the file to create (optional)
}
// Result for rule file operations with meaningful data only
@@ -8,45 +8,6 @@ import { getGlobalState, getWorkspaceState, updateGlobalState, updateWorkspaceSt
import * as vscode from "vscode"
import { synchronizeRuleToggles, getRuleFilesTotalContent } from "@core/context/instructions/user-instructions/rule-helpers"
/**
* Converts .clinerules file to directory and places old .clinerule file inside directory, renaming it
* Doesn't do anything if .clinerules dir already exists or doesn't exist
* Returns whether there are any uncaught errors
*/
export async function ensureLocalClinerulesDirExists(cwd: string): Promise<boolean> {
const clinerulePath = path.resolve(cwd, GlobalFileNames.clineRules)
const defaultRuleFilename = "default-rules.md"
try {
const exists = await fileExistsAtPath(clinerulePath)
if (exists && !(await isDirectory(clinerulePath))) {
// logic to convert .clinerules file into directory, and rename the rules file to {defaultRuleFilename}
const content = await fs.readFile(clinerulePath, "utf8")
const tempPath = clinerulePath + ".bak"
await fs.rename(clinerulePath, tempPath) // create backup
try {
await fs.mkdir(clinerulePath, { recursive: true })
await fs.writeFile(path.join(clinerulePath, defaultRuleFilename), content, "utf8")
await fs.unlink(tempPath).catch(() => {}) // delete backup
return false // conversion successful with no errors
} catch (conversionError) {
// attempt to restore backup on conversion failure
try {
await fs.rm(clinerulePath, { recursive: true, force: true }).catch(() => {})
await fs.rename(tempPath, clinerulePath) // restore backup
} catch (restoreError) {}
return true // in either case here we consider this an error
}
}
// exists and is a dir or doesn't exist, either of these cases we dont need to handle here
return false
} catch (error) {
return true
}
}
export const getGlobalClineRules = async (globalClineRulesFilePath: string, toggles: ClineRulesToggles) => {
if (await fileExistsAtPath(globalClineRulesFilePath)) {
if (await isDirectory(globalClineRulesFilePath)) {
@@ -80,7 +41,8 @@ export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles
if (await fileExistsAtPath(clineRulesFilePath)) {
if (await isDirectory(clineRulesFilePath)) {
try {
const rulesFilePaths = await readDirectory(clineRulesFilePath)
const rulesFilePaths = await readDirectory(clineRulesFilePath, [[".clinerules", "workflows"]])
const rulesFilesTotalContent = await getRuleFilesTotalContent(rulesFilePaths, cwd, toggles)
if (rulesFilesTotalContent) {
clineRulesFileInstructions = formatResponse.clineRulesLocalDirectoryInstructions(cwd, rulesFilesTotalContent)
@@ -121,7 +83,9 @@ export async function refreshClineRulesToggles(
// Local toggles
const localClineRulesToggles = ((await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles) || {}
const localClineRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.clineRules)
const updatedLocalToggles = await synchronizeRuleToggles(localClineRulesFilePath, localClineRulesToggles)
const updatedLocalToggles = await synchronizeRuleToggles(localClineRulesFilePath, localClineRulesToggles, "", [
[".clinerules", "workflows"],
])
await updateWorkspaceState(context, "localClineRulesToggles", updatedLocalToggles)
return {
@@ -129,82 +93,3 @@ export async function refreshClineRulesToggles(
localToggles: updatedLocalToggles,
}
}
export const createRuleFile = async (isGlobal: boolean, filename: string, cwd: string) => {
try {
let filePath: string
if (isGlobal) {
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
filePath = path.join(globalClineRulesFilePath, filename)
} else {
const localClineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
const hasError = await ensureLocalClinerulesDirExists(cwd)
if (hasError === true) {
return { filePath: null, fileExists: false }
}
await fs.mkdir(localClineRulesFilePath, { recursive: true })
filePath = path.join(localClineRulesFilePath, filename)
}
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
return { filePath, fileExists }
}
await fs.writeFile(filePath, "", "utf8")
return { filePath, fileExists: false }
} catch (error) {
return { filePath: null, fileExists: false }
}
}
export async function deleteRuleFile(
context: vscode.ExtensionContext,
rulePath: string,
isGlobal: boolean,
): Promise<{ success: boolean; message: string }> {
try {
// Check if file exists
const fileExists = await fileExistsAtPath(rulePath)
if (!fileExists) {
return {
success: false,
message: `Rule file does not exist: ${rulePath}`,
}
}
// Delete the file from disk
await fs.unlink(rulePath)
// Get the filename for messages
const fileName = path.basename(rulePath)
// Update the appropriate toggles
if (isGlobal) {
const toggles = ((await getGlobalState(context, "globalClineRulesToggles")) as ClineRulesToggles) || {}
delete toggles[rulePath]
await updateGlobalState(context, "globalClineRulesToggles", toggles)
} else {
const toggles = ((await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles) || {}
delete toggles[rulePath]
await updateWorkspaceState(context, "localClineRulesToggles", toggles)
}
return {
success: true,
message: `Rule file "${fileName}" deleted successfully`,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`Error deleting rule file: ${errorMessage}`, error)
return {
success: false,
message: `Failed to delete rule file.`,
}
}
}
@@ -1,14 +1,21 @@
import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
import { ensureRulesDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { getGlobalState, getWorkspaceState, updateGlobalState, updateWorkspaceState } from "@core/storage/state"
import * as path from "path"
import fs from "fs/promises"
import { ClineRulesToggles } from "@shared/cline-rules"
import * as vscode from "vscode"
/**
* Recursively traverses directory and finds all files, including checking for optional whitelisted file extension
*/
export async function readDirectoryRecursive(directoryPath: string, allowedFileExtension: string): Promise<string[]> {
export async function readDirectoryRecursive(
directoryPath: string,
allowedFileExtension: string,
excludedPaths: string[][] = [],
): Promise<string[]> {
try {
const entries = await readDirectory(directoryPath)
const entries = await readDirectory(directoryPath, excludedPaths)
let results: string[] = []
for (const entry of entries) {
if (allowedFileExtension !== "") {
@@ -33,6 +40,7 @@ export async function synchronizeRuleToggles(
rulesDirectoryPath: string,
currentToggles: ClineRulesToggles,
allowedFileExtension: string = "",
excludedPaths: string[][] = [],
): Promise<ClineRulesToggles> {
// Create a copy of toggles to modify
const updatedToggles = { ...currentToggles }
@@ -45,7 +53,7 @@ export async function synchronizeRuleToggles(
if (isDir) {
// DIRECTORY CASE
const filePaths = await readDirectoryRecursive(rulesDirectoryPath, allowedFileExtension)
const filePaths = await readDirectoryRecursive(rulesDirectoryPath, allowedFileExtension, excludedPaths)
const existingRulePaths = new Set<string>()
for (const filePath of filePaths) {
@@ -119,3 +127,155 @@ export const getRuleFilesTotalContent = async (rulesFilePaths: string[], basePat
).then((contents) => contents.filter(Boolean).join("\n\n"))
return ruleFilesTotalContent
}
/**
* Handles converting any directory into a file (specifically used for .clinerules and .clinerules/workflows)
* The old .clinerules file or .clinerules/workflows file will be renamed to a default filename
* Doesn't do anything if the dir already exists or doesn't exist
* Returns whether there are any uncaught errors
*/
export async function ensureLocalClineDirExists(clinerulePath: string, defaultRuleFilename: string): Promise<boolean> {
try {
const exists = await fileExistsAtPath(clinerulePath)
if (exists && !(await isDirectory(clinerulePath))) {
// logic to convert .clinerules file into directory, and rename the rules file to {defaultRuleFilename}
const content = await fs.readFile(clinerulePath, "utf8")
const tempPath = clinerulePath + ".bak"
await fs.rename(clinerulePath, tempPath) // create backup
try {
await fs.mkdir(clinerulePath, { recursive: true })
await fs.writeFile(path.join(clinerulePath, defaultRuleFilename), content, "utf8")
await fs.unlink(tempPath).catch(() => {}) // delete backup
return false // conversion successful with no errors
} catch (conversionError) {
// attempt to restore backup on conversion failure
try {
await fs.rm(clinerulePath, { recursive: true, force: true }).catch(() => {})
await fs.rename(tempPath, clinerulePath) // restore backup
} catch (restoreError) {}
return true // in either case here we consider this an error
}
}
// exists and is a dir or doesn't exist, either of these cases we dont need to handle here
return false
} catch (error) {
return true
}
}
/**
* Create a rule file or workflow file
*/
export const createRuleFile = async (isGlobal: boolean, filename: string, cwd: string, type: string) => {
try {
let filePath: string
if (isGlobal) {
// global means its implicitly clinerules
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
filePath = path.join(globalClineRulesFilePath, filename)
} else {
const localClineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
const hasError = await ensureLocalClineDirExists(localClineRulesFilePath, "default-rules.md")
if (hasError === true) {
return { filePath: null, fileExists: false }
}
await fs.mkdir(localClineRulesFilePath, { recursive: true })
if (type === "workflow") {
const localWorkflowsFilePath = path.resolve(cwd, GlobalFileNames.workflows)
const hasError = await ensureLocalClineDirExists(localWorkflowsFilePath, "default-workflows.md")
if (hasError === true) {
return { filePath: null, fileExists: false }
}
await fs.mkdir(localWorkflowsFilePath, { recursive: true })
filePath = path.join(localWorkflowsFilePath, filename)
} else {
// clinerules file creation
filePath = path.join(localClineRulesFilePath, filename)
}
}
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
return { filePath, fileExists }
}
await fs.writeFile(filePath, "", "utf8")
return { filePath, fileExists: false }
} catch (error) {
return { filePath: null, fileExists: false }
}
}
/**
* Delete a rule file or workflow file
*/
export async function deleteRuleFile(
context: vscode.ExtensionContext,
rulePath: string,
isGlobal: boolean,
type: string,
): Promise<{ success: boolean; message: string }> {
try {
// Check if file exists
const fileExists = await fileExistsAtPath(rulePath)
if (!fileExists) {
return {
success: false,
message: `File does not exist: ${rulePath}`,
}
}
// Delete the file from disk
await fs.unlink(rulePath)
// Get the filename for messages
const fileName = path.basename(rulePath)
// Update the appropriate toggles
if (isGlobal) {
const toggles = ((await getGlobalState(context, "globalClineRulesToggles")) as ClineRulesToggles) || {}
delete toggles[rulePath]
await updateGlobalState(context, "globalClineRulesToggles", toggles)
} else {
if (type === "workflow") {
const toggles = ((await getWorkspaceState(context, "workflowToggles")) as ClineRulesToggles) || {}
delete toggles[rulePath]
await updateWorkspaceState(context, "workflowToggles", toggles)
} else if (type === "cursor") {
const toggles = ((await getWorkspaceState(context, "localCursorRulesToggles")) as ClineRulesToggles) || {}
delete toggles[rulePath]
await updateWorkspaceState(context, "localCursorRulesToggles", toggles)
} else if (type === "windsurf") {
const toggles = ((await getWorkspaceState(context, "localWindsurfRulesToggles")) as ClineRulesToggles) || {}
delete toggles[rulePath]
await updateWorkspaceState(context, "localWindsurfRulesToggles", toggles)
} else {
const toggles = ((await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles) || {}
delete toggles[rulePath]
await updateWorkspaceState(context, "localClineRulesToggles", toggles)
}
}
return {
success: true,
message: `File "${fileName}" deleted successfully`,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`Error deleting file: ${errorMessage}`, error)
return {
success: false,
message: `Failed to delete file.`,
}
}
}
@@ -0,0 +1,20 @@
import path from "path"
import { GlobalFileNames } from "@core/storage/disk"
import { ClineRulesToggles } from "@shared/cline-rules"
import { getWorkspaceState, updateWorkspaceState } from "@core/storage/state"
import * as vscode from "vscode"
import { synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
/**
* Refresh the workflow toggles
*/
export async function refreshWorkflowToggles(
context: vscode.ExtensionContext,
workingDirectory: string,
): Promise<ClineRulesToggles> {
const workflowRulesToggles = ((await getWorkspaceState(context, "workflowToggles")) as ClineRulesToggles) || {}
const workflowsDirPath = path.resolve(workingDirectory, GlobalFileNames.workflows)
const updatedWorkflowToggles = await synchronizeRuleToggles(workflowsDirPath, workflowRulesToggles)
await updateWorkspaceState(context, "workflowToggles", updatedWorkflowToggles)
return updatedWorkflowToggles
}
+22 -10
View File
@@ -1,14 +1,13 @@
import { Controller } from ".."
import { RuleFileRequest, RuleFile } from "@shared/proto/file"
import { FileMethodHandler } from "./index"
import {
createRuleFile as createRuleFileImpl,
refreshClineRulesToggles,
} from "@core/context/instructions/user-instructions/cline-rules"
import { refreshClineRulesToggles } from "@core/context/instructions/user-instructions/cline-rules"
import { createRuleFile as createRuleFileImpl } from "@core/context/instructions/user-instructions/rule-helpers"
import * as vscode from "vscode"
import * as path from "path"
import { handleFileServiceRequest } from "./index"
import { cwd } from "@core/task"
import { refreshWorkflowToggles } from "@/core/context/instructions/user-instructions/workflows"
/**
* Creates a rule file in either global or workspace rules directory
@@ -18,32 +17,45 @@ import { cwd } from "@core/task"
* @throws Error if operation fails
*/
export const createRuleFile: FileMethodHandler = async (controller: Controller, request: RuleFileRequest): Promise<RuleFile> => {
if (typeof request.isGlobal !== "boolean" || typeof request.filename !== "string" || !request.filename) {
if (
typeof request.isGlobal !== "boolean" ||
!request.filename ||
typeof request.filename !== "string" ||
!request.type ||
typeof request.type !== "string"
) {
console.error("createRuleFile: Missing or invalid parameters", {
isGlobal: typeof request.isGlobal === "boolean" ? request.isGlobal : `Invalid: ${typeof request.isGlobal}`,
filename: typeof request.filename === "string" ? request.filename : `Invalid: ${typeof request.filename}`,
type: typeof request.type === "string" ? request.type : `Invalid: ${typeof request.type}`,
})
throw new Error("Missing or invalid parameters")
}
const { filePath, fileExists } = await createRuleFileImpl(request.isGlobal, request.filename, cwd)
const { filePath, fileExists } = await createRuleFileImpl(request.isGlobal, request.filename, cwd, request.type)
if (!filePath) {
throw new Error("Failed to create rule file.")
throw new Error("Failed to create file.")
}
const fileTypeName = request.type === "workflow" ? "workflow" : "rule"
if (fileExists) {
vscode.window.showWarningMessage(`Rule file "${request.filename}" already exists.`)
vscode.window.showWarningMessage(`${fileTypeName} file "${request.filename}" already exists.`)
// Still open it for editing
await handleFileServiceRequest(controller, "openFile", { value: filePath })
} else {
await refreshClineRulesToggles(controller.context, cwd)
if (request.type === "workflow") {
await refreshWorkflowToggles(controller.context, cwd)
} else {
await refreshClineRulesToggles(controller.context, cwd)
}
await controller.postStateToWebview()
await handleFileServiceRequest(controller, "openFile", { value: filePath })
vscode.window.showInformationMessage(
`Created new ${request.isGlobal ? "global" : "workspace"} rule file: ${request.filename}`,
`Created new ${request.isGlobal ? "global" : "workspace"} ${fileTypeName} file: ${request.filename}`,
)
}
+20 -9
View File
@@ -1,11 +1,10 @@
import { Controller } from ".."
import { RuleFileRequest, RuleFile } from "@shared/proto/file"
import { FileMethodHandler } from "./index"
import {
deleteRuleFile as deleteRuleFileImpl,
refreshClineRulesToggles,
} from "@core/context/instructions/user-instructions/cline-rules"
import { refreshClineRulesToggles } from "@core/context/instructions/user-instructions/cline-rules"
import { deleteRuleFile as deleteRuleFileImpl } from "@core/context/instructions/user-instructions/rule-helpers"
import { refreshExternalRulesToggles } from "@core/context/instructions/user-instructions/external-rules"
import { refreshWorkflowToggles } from "@core/context/instructions/user-instructions/workflows"
import * as vscode from "vscode"
import * as path from "path"
import { cwd } from "@core/task"
@@ -18,26 +17,38 @@ import { cwd } from "@core/task"
* @throws Error if operation fails
*/
export const deleteRuleFile: FileMethodHandler = async (controller: Controller, request: RuleFileRequest): Promise<RuleFile> => {
if (typeof request.isGlobal !== "boolean" || typeof request.rulePath !== "string" || !request.rulePath) {
if (
typeof request.isGlobal !== "boolean" ||
typeof request.rulePath !== "string" ||
!request.rulePath ||
!request.type ||
typeof request.type !== "string"
) {
console.error("deleteRuleFile: Missing or invalid parameters", {
isGlobal: typeof request.isGlobal === "boolean" ? request.isGlobal : `Invalid: ${typeof request.isGlobal}`,
rulePath: typeof request.rulePath === "string" ? request.rulePath : `Invalid: ${typeof request.rulePath}`,
type: typeof request.type === "string" ? request.type : `Invalid: ${typeof request.type}`,
})
throw new Error("Missing or invalid parameters")
}
const result = await deleteRuleFileImpl(controller.context, request.rulePath, request.isGlobal)
const result = await deleteRuleFileImpl(controller.context, request.rulePath, request.isGlobal, request.type)
if (!result.success) {
throw new Error(result.message || "Failed to delete rule file")
}
await refreshClineRulesToggles(controller.context, cwd)
await refreshExternalRulesToggles(controller.context, cwd)
// we refresh inside of the deleteRuleFileImpl(..) call
//await refreshClineRulesToggles(controller.context, cwd)
//await refreshExternalRulesToggles(controller.context, cwd)
//await refreshWorkflowToggles(controller.context, cwd)
await controller.postStateToWebview()
const fileName = path.basename(request.rulePath)
vscode.window.showInformationMessage(`Rule file "${fileName}" deleted successfully`)
const fileTypeName = request.type === "workflow" ? "workflow" : "rule"
vscode.window.showInformationMessage(`${fileTypeName} file "${fileName}" deleted successfully`)
return RuleFile.create({
filePath: request.rulePath,
+15
View File
@@ -50,6 +50,7 @@ import { ClineRulesToggles } from "@shared/cline-rules"
import { sendStateUpdate } from "./state/subscribeToState"
import { refreshClineRulesToggles } from "@core/context/instructions/user-instructions/cline-rules"
import { refreshExternalRulesToggles } from "@core/context/instructions/user-instructions/external-rules"
import { refreshWorkflowToggles } from "@core/context/instructions/user-instructions/workflows"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -335,6 +336,7 @@ export class Controller {
case "refreshClineRules":
await refreshClineRulesToggles(this.context, cwd)
await refreshExternalRulesToggles(this.context, cwd)
await refreshWorkflowToggles(this.context, cwd)
await this.postStateToWebview()
break
case "openInBrowser":
@@ -486,6 +488,16 @@ export class Controller {
}
break
}
case "toggleWorkflow": {
const { workflowPath, enabled } = message
if (workflowPath && typeof enabled === "boolean") {
const toggles = ((await getWorkspaceState(this.context, "workflowToggles")) as ClineRulesToggles) || {}
toggles[workflowPath] = enabled
await updateWorkspaceState(this.context, "workflowToggles", toggles)
await this.postStateToWebview()
}
break
}
case "requestTotalTasksSize": {
this.refreshTotalTasksSize()
break
@@ -1367,6 +1379,8 @@ export class Controller {
const localCursorRulesToggles =
((await getWorkspaceState(this.context, "localCursorRulesToggles")) as ClineRulesToggles) || {}
const workflowToggles = ((await getWorkspaceState(this.context, "workflowToggles")) as ClineRulesToggles) || {}
return {
version: this.context.extension?.packageJSON?.version ?? "",
apiConfiguration,
@@ -1394,6 +1408,7 @@ export class Controller {
localClineRulesToggles: localClineRulesToggles || {},
localWindsurfRulesToggles: localWindsurfRulesToggles || {},
localCursorRulesToggles: localCursorRulesToggles || {},
workflowToggles: workflowToggles || {},
shellIntegrationTimeout,
isNewUser,
}
+55 -7
View File
@@ -1,11 +1,16 @@
import { newTaskToolResponse, condenseToolResponse, newRuleToolResponse, reportBugToolResponse } from "../prompts/commands"
import { ClineRulesToggles } from "@shared/cline-rules"
import fs from "fs/promises"
/**
* Processes text for slash commands and transforms them with appropriate instructions
* This is called after parseMentions() to process any slash commands in the user's message
*/
export function parseSlashCommands(text: string): { processedText: string; needsClinerulesFileCheck: boolean } {
const SUPPORTED_COMMANDS = ["newtask", "smol", "compact", "newrule", "reportbug"]
export async function parseSlashCommands(
text: string,
workflowToggles: ClineRulesToggles,
): Promise<{ processedText: string; needsClinerulesFileCheck: boolean }> {
const SUPPORTED_DEFAULT_COMMANDS = ["newtask", "smol", "compact", "newrule", "reportbug"]
const commandReplacements: Record<string, string> = {
newtask: newTaskToolResponse(),
@@ -17,10 +22,10 @@ export function parseSlashCommands(text: string): { processedText: string; needs
// this currently allows matching prepended whitespace prior to /slash-command
const tagPatterns = [
{ tag: "task", regex: /<task>(\s*\/([a-zA-Z0-9_-]+))(\s+.+?)?\s*<\/task>/is },
{ tag: "feedback", regex: /<feedback>(\s*\/([a-zA-Z0-9_-]+))(\s+.+?)?\s*<\/feedback>/is },
{ tag: "answer", regex: /<answer>(\s*\/([a-zA-Z0-9_-]+))(\s+.+?)?\s*<\/answer>/is },
{ tag: "user_message", regex: /<user_message>(\s*\/([a-zA-Z0-9_-]+))(\s+.+?)?\s*<\/user_message>/is },
{ tag: "task", regex: /<task>(\s*\/([a-zA-Z0-9_\.-]+))(\s+.+?)?\s*<\/task>/is },
{ tag: "feedback", regex: /<feedback>(\s*\/([a-zA-Z0-9_\.-]+))(\s+.+?)?\s*<\/feedback>/is },
{ tag: "answer", regex: /<answer>(\s*\/([a-zA-Z0-9_\.-]+))(\s+.+?)?\s*<\/answer>/is },
{ tag: "user_message", regex: /<user_message>(\s*\/([a-zA-Z0-9_\.-]+))(\s+.+?)?\s*<\/user_message>/is },
]
// if we find a valid match, we will return inside that block
@@ -34,7 +39,8 @@ export function parseSlashCommands(text: string): { processedText: string; needs
const commandName = match[2] // casing matters
if (SUPPORTED_COMMANDS.includes(commandName)) {
// we give preference to the default commands if the user has a file with the same name
if (SUPPORTED_DEFAULT_COMMANDS.includes(commandName)) {
const fullMatchStartIndex = match.index
// find position of slash command within the full match
@@ -51,6 +57,48 @@ export function parseSlashCommands(text: string): { processedText: string; needs
return { processedText: processedText, needsClinerulesFileCheck: commandName === "newrule" ? true : false }
}
// in practice we want to minimize this work, so we only do it if theres a possible match
const enabledWorkflows = Object.entries(workflowToggles)
.filter(([_, enabled]) => enabled)
.map(([filePath, _]) => {
const fileName = filePath.replace(/^.*[/\\]/, "")
return {
fullPath: filePath,
fileName: fileName,
}
})
// Then check if the command matches any enabled workflow filename
const matchingWorkflow = enabledWorkflows.find((workflow) => workflow.fileName === commandName)
if (matchingWorkflow) {
try {
// Read workflow file content from the full path
const workflowContent = (await fs.readFile(matchingWorkflow.fullPath, "utf8")).trim()
// find position of slash command within the full match
const fullMatchStartIndex = match.index
const fullMatch = match[0]
const relativeStartIndex = fullMatch.indexOf(match[1])
// calculate absolute indices in the original string
const slashCommandStartIndex = fullMatchStartIndex + relativeStartIndex
const slashCommandEndIndex = slashCommandStartIndex + match[1].length
// remove the slash command and add custom instructions at the top of this message
const textWithoutSlashCommand =
text.substring(0, slashCommandStartIndex) + text.substring(slashCommandEndIndex)
const processedText =
`<explicit_instructions type="${matchingWorkflow.fileName}">\n${workflowContent}\n</explicit_instructions>\n` +
textWithoutSlashCommand
return { processedText, needsClinerulesFileCheck: false }
} catch (error) {
console.error(`Error reading workflow file ${matchingWorkflow.fullPath}: ${error}`)
}
}
}
}
+1
View File
@@ -15,6 +15,7 @@ export const GlobalFileNames = {
openRouterModels: "openrouter_models.json",
mcpSettings: "cline_mcp_settings.json",
clineRules: ".clinerules",
workflows: ".clinerules/workflows",
cursorRulesDir: ".cursor/rules",
cursorRulesFile: ".cursorrules",
windsurfRules: ".windsurfrules",
+10 -4
View File
@@ -80,6 +80,7 @@ import {
ensureTaskDirectoryExists,
getSavedApiConversationHistory,
getSavedClineMessages,
GlobalFileNames,
saveApiConversationHistory,
saveClineMessages,
} from "@core/storage/disk"
@@ -87,13 +88,14 @@ import {
getGlobalClineRules,
getLocalClineRules,
refreshClineRulesToggles,
ensureLocalClinerulesDirExists,
} from "@core/context/instructions/user-instructions/cline-rules"
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
import {
refreshExternalRulesToggles,
getLocalWindsurfRules,
getLocalCursorRules,
} from "@core/context/instructions/user-instructions/external-rules"
import { refreshWorkflowToggles } from "../context/instructions/user-instructions/workflows"
import { getGlobalState } from "@core/storage/state"
import { parseSlashCommands } from "@core/slash-commands"
import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
@@ -599,7 +601,6 @@ export class Task {
async doesLatestTaskCompletionHaveNewChanges() {
if (!this.enableCheckpoints) {
console.error("Checkpoints are disabled")
return false
}
@@ -4068,6 +4069,8 @@ export class Task {
// Track if we need to check clinerulesFile
let needsClinerulesFileCheck = false
const workflowToggles = await refreshWorkflowToggles(this.getContext(), cwd)
const processUserContent = async () => {
// This is a temporary solution to dynamically load context mentions from tool results. It checks for the presence of tags that indicate that the tool was rejected and feedback was provided (see formatToolDeniedFeedback, attemptCompletion, executeCommand, and consecutiveMistakeCount >= 3) or "<answer>" (see askFollowupQuestion), we place all user generated content in these tags so they can effectively be used as markers for when we should parse mentions). However if we allow multiple tools responses in the future, we will need to parse mentions specifically within the user content tags.
// (Note: this caused the @/ import alias bug where file contents were being parsed as well, since v2 converted tool results to text blocks)
@@ -4090,7 +4093,10 @@ export class Task {
)
// when parsing slash commands, we still want to allow the user to provide their desired context
const { processedText, needsClinerulesFileCheck: needsCheck } = parseSlashCommands(parsedText)
const { processedText, needsClinerulesFileCheck: needsCheck } = await parseSlashCommands(
parsedText,
workflowToggles,
)
if (needsCheck) {
needsClinerulesFileCheck = true
@@ -4116,7 +4122,7 @@ export class Task {
// After processing content, check clinerulesData if needed
let clinerulesError = false
if (needsClinerulesFileCheck) {
clinerulesError = await ensureLocalClinerulesDirExists(cwd)
clinerulesError = await ensureLocalClineDirExists(cwd, GlobalFileNames.clineRules)
}
// Return all results
+1
View File
@@ -143,6 +143,7 @@ export interface ExtensionState {
vscMachineId: string
globalClineRulesToggles: ClineRulesToggles
localClineRulesToggles: ClineRulesToggles
workflowToggles: ClineRulesToggles
localCursorRulesToggles: ClineRulesToggles
localWindsurfRulesToggles: ClineRulesToggles
}
+3 -1
View File
@@ -57,6 +57,7 @@ export interface WebviewMessage {
| "toggleClineRule"
| "toggleCursorRule"
| "toggleWindsurfRule"
| "toggleWorkflow"
| "deleteClineRule"
| "copyToClipboard"
| "updateTerminalConnectionTimeout"
@@ -109,9 +110,10 @@ export interface WebviewMessage {
grpc_request_cancel?: {
request_id: string // ID of the request to cancel
}
// For cline rules
// For cline rules and workflows
isGlobal?: boolean
rulePath?: string
workflowPath?: string
enabled?: boolean
filename?: string
@@ -2,22 +2,24 @@ import { RuleFileRequest } from "../../proto/file"
// Helper for creating delete requests
export const DeleteRuleFileRequest = {
create: (params: { rulePath: string; isGlobal: boolean; metadata?: any }): RuleFileRequest => {
create: (params: { rulePath: string; isGlobal: boolean; metadata?: any; type?: string }): RuleFileRequest => {
return RuleFileRequest.create({
rulePath: params.rulePath,
isGlobal: params.isGlobal,
metadata: params.metadata,
type: params.type,
})
},
}
// Helper for creating create requests
export const CreateRuleFileRequest = {
create: (params: { filename: string; isGlobal: boolean; metadata?: any }): RuleFileRequest => {
create: (params: { filename: string; isGlobal: boolean; metadata?: any; type?: string }): RuleFileRequest => {
return RuleFileRequest.create({
filename: params.filename,
isGlobal: params.isGlobal,
metadata: params.metadata,
type: params.type,
})
},
}
+19 -1
View File
@@ -73,6 +73,8 @@ export interface RuleFileRequest {
rulePath?: string | undefined
/** Filename field for createRuleFile (optional) */
filename?: string | undefined
/** Type of the file to create (optional) */
type?: string | undefined
}
/** Result for rule file operations with meaningful data only */
@@ -682,7 +684,7 @@ export const GitCommit: MessageFns<GitCommit> = {
}
function createBaseRuleFileRequest(): RuleFileRequest {
return { metadata: undefined, isGlobal: false, rulePath: undefined, filename: undefined }
return { metadata: undefined, isGlobal: false, rulePath: undefined, filename: undefined, type: undefined }
}
export const RuleFileRequest: MessageFns<RuleFileRequest> = {
@@ -699,6 +701,9 @@ export const RuleFileRequest: MessageFns<RuleFileRequest> = {
if (message.filename !== undefined) {
writer.uint32(34).string(message.filename)
}
if (message.type !== undefined) {
writer.uint32(42).string(message.type)
}
return writer
},
@@ -741,6 +746,14 @@ export const RuleFileRequest: MessageFns<RuleFileRequest> = {
message.filename = reader.string()
continue
}
case 5: {
if (tag !== 42) {
break
}
message.type = reader.string()
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
@@ -756,6 +769,7 @@ export const RuleFileRequest: MessageFns<RuleFileRequest> = {
isGlobal: isSet(object.isGlobal) ? globalThis.Boolean(object.isGlobal) : false,
rulePath: isSet(object.rulePath) ? globalThis.String(object.rulePath) : undefined,
filename: isSet(object.filename) ? globalThis.String(object.filename) : undefined,
type: isSet(object.type) ? globalThis.String(object.type) : undefined,
}
},
@@ -773,6 +787,9 @@ export const RuleFileRequest: MessageFns<RuleFileRequest> = {
if (message.filename !== undefined) {
obj.filename = message.filename
}
if (message.type !== undefined) {
obj.type = message.type
}
return obj
},
@@ -786,6 +803,7 @@ export const RuleFileRequest: MessageFns<RuleFileRequest> = {
message.isGlobal = object.isGlobal ?? false
message.rulePath = object.rulePath ?? undefined
message.filename = object.filename ?? undefined
message.type = object.type ?? undefined
return message
},
}
+169 -1
View File
@@ -3,7 +3,7 @@ import { after, describe, it } from "mocha"
import * as os from "os"
import * as path from "path"
import "should"
import { createDirectoriesForFile, fileExistsAtPath, isDirectory } from "./fs"
import { createDirectoriesForFile, fileExistsAtPath, isDirectory, readDirectory } from "./fs"
describe("Filesystem Utilities", () => {
const tmpDir = path.join(os.tmpdir(), "cline-test-" + Math.random().toString(36).slice(2))
@@ -88,4 +88,172 @@ describe("Filesystem Utilities", () => {
isDir.should.be.false()
})
})
describe("readDirectory", () => {
it("should list files in a directory", async () => {
// Create test directory with files
const testDir = path.join(tmpDir, "read-test")
await fs.mkdir(testDir, { recursive: true })
await fs.writeFile(path.join(testDir, "file1.txt"), "content")
await fs.writeFile(path.join(testDir, "file2.txt"), "content")
// Get files
const files = await readDirectory(testDir)
files.length.should.equal(2)
files.should.containDeep([path.resolve(testDir, "file1.txt"), path.resolve(testDir, "file2.txt")])
})
it("should exclude specified directories", async () => {
// Create test directory with files and an excluded directory
const testDir = path.join(tmpDir, "exclude-test")
const excludeDir = path.join(testDir, "exclude-me")
await fs.mkdir(excludeDir, { recursive: true })
await fs.writeFile(path.join(testDir, "include.txt"), "content")
await fs.writeFile(path.join(excludeDir, "excluded.txt"), "content")
// Get files, excluding the "exclude-me" directory
const files = await readDirectory(testDir, [["exclude-me"]])
files.length.should.equal(1)
files.should.containDeep([path.resolve(testDir, "include.txt")])
files.should.not.containDeep([path.resolve(excludeDir, "excluded.txt")])
})
})
it("should correctly handle complex nested directory structures", async () => {
// Create a complex directory structure
const complexDir = path.join(tmpDir, "complex-test")
// Create main dir
await fs.mkdir(complexDir, { recursive: true })
await fs.writeFile(path.join(complexDir, "root.txt"), "content")
// Create first branch
await fs.mkdir(path.join(complexDir, "dir1"), { recursive: true })
await fs.writeFile(path.join(complexDir, "dir1", "file1.txt"), "content")
// Create second branch with nested structure
await fs.mkdir(path.join(complexDir, "dir2", "subdir1"), { recursive: true })
await fs.writeFile(path.join(complexDir, "dir2", "file2.txt"), "content")
await fs.writeFile(path.join(complexDir, "dir2", "subdir1", "file3.txt"), "content")
// Create third branch with deep nesting
await fs.mkdir(path.join(complexDir, "dir3", "subdir2", "deepdir"), { recursive: true })
await fs.writeFile(path.join(complexDir, "dir3", "file4.txt"), "content")
await fs.writeFile(path.join(complexDir, "dir3", "subdir2", "file5.txt"), "content")
await fs.writeFile(path.join(complexDir, "dir3", "subdir2", "deepdir", "file6.txt"), "content")
// Get all files
const files = await readDirectory(complexDir)
const expectedFiles = [
path.resolve(complexDir, "root.txt"),
path.resolve(complexDir, "dir1", "file1.txt"),
path.resolve(complexDir, "dir2", "file2.txt"),
path.resolve(complexDir, "dir2", "subdir1", "file3.txt"),
path.resolve(complexDir, "dir3", "file4.txt"),
path.resolve(complexDir, "dir3", "subdir2", "file5.txt"),
path.resolve(complexDir, "dir3", "subdir2", "deepdir", "file6.txt"),
]
files.length.should.equal(expectedFiles.length)
files.sort().should.deepEqual(expectedFiles.sort())
})
it("should correctly exclude multiple directories in complex structures", async () => {
// Use the same complex directory structure
const complexDir = path.join(tmpDir, "complex-exclude-test")
// Create main dir
await fs.mkdir(complexDir, { recursive: true })
await fs.writeFile(path.join(complexDir, "root.txt"), "content")
// Create first branch
await fs.mkdir(path.join(complexDir, "dir1"), { recursive: true })
await fs.writeFile(path.join(complexDir, "dir1", "file1.txt"), "content")
// Create second branch with nested structure
await fs.mkdir(path.join(complexDir, "dir2", "subdir1"), { recursive: true })
await fs.writeFile(path.join(complexDir, "dir2", "file2.txt"), "content")
await fs.writeFile(path.join(complexDir, "dir2", "subdir1", "file3.txt"), "content")
// Create third branch with deep nesting
await fs.mkdir(path.join(complexDir, "dir3", "subdir2", "deepdir"), { recursive: true })
await fs.writeFile(path.join(complexDir, "dir3", "file4.txt"), "content")
await fs.writeFile(path.join(complexDir, "dir3", "subdir2", "file5.txt"), "content")
await fs.writeFile(path.join(complexDir, "dir3", "subdir2", "deepdir", "file6.txt"), "content")
// Get files excluding multiple directories
const files = await readDirectory(complexDir, [["dir1"], ["subdir2"]])
const expectedFiles = [
path.resolve(complexDir, "root.txt"),
path.resolve(complexDir, "dir2", "file2.txt"),
path.resolve(complexDir, "dir2", "subdir1", "file3.txt"),
path.resolve(complexDir, "dir3", "file4.txt"),
]
files.length.should.equal(expectedFiles.length)
files.sort().should.deepEqual(expectedFiles.sort())
})
it("should exclude .clinerules/workflows directory specifically", async () => {
// Create a test directory structure
const clinerulesDirTest = path.join(tmpDir, "clinerules-test")
const clinerulesDirPath = path.join(clinerulesDirTest, ".clinerules")
// Create .clinerules directory and root files
await fs.mkdir(clinerulesDirPath, { recursive: true })
await fs.writeFile(path.join(clinerulesDirPath, "config.json"), "{}")
await fs.writeFile(path.join(clinerulesDirPath, "settings.js"), "// settings")
// Create .clinerules/other directory and files
const otherDirPath = path.join(clinerulesDirPath, "other")
await fs.mkdir(otherDirPath, { recursive: true })
await fs.writeFile(path.join(otherDirPath, "helper.js"), "// helper code")
await fs.writeFile(path.join(otherDirPath, "util.js"), "// util functions")
// Create .clinerules/workflows directory and files
const workflowsDirPath = path.join(clinerulesDirPath, "workflows")
await fs.mkdir(workflowsDirPath, { recursive: true })
await fs.writeFile(path.join(workflowsDirPath, "workflow1.js"), "// workflow1")
await fs.writeFile(path.join(workflowsDirPath, "workflow2.js"), "// workflow2")
// Get all files WITHOUT exclusion
const allFiles = await readDirectory(clinerulesDirPath)
// Verify all files are included
allFiles.length.should.equal(6) // 2 in root + 2 in other + 2 in workflows
allFiles.some((file) => file.includes("workflow1.js")).should.be.true()
allFiles.some((file) => file.includes("workflow2.js")).should.be.true()
// Get files WITH workflows directory excluded
const filteredFiles = await readDirectory(clinerulesDirPath, [[".clinerules", "workflows"]])
// Verify workflows files are excluded but others remain
filteredFiles.length.should.equal(4) // 2 in root + 2 in other
const expectedFiles = [
path.resolve(clinerulesDirPath, "config.json"),
path.resolve(clinerulesDirPath, "settings.js"),
path.resolve(otherDirPath, "helper.js"),
path.resolve(otherDirPath, "util.js"),
]
filteredFiles.sort().should.deepEqual(expectedFiles.sort())
// Test with multiple exclusions
const multiExcludeFiles = await readDirectory(clinerulesDirPath, [
[".clinerules", "workflows"],
[".clinerules", "other"],
])
// Verify both workflows and other directories are excluded
multiExcludeFiles.length.should.equal(2) // only the 2 files in root
const rootOnlyFiles = [path.resolve(clinerulesDirPath, "config.json"), path.resolve(clinerulesDirPath, "settings.js")]
multiExcludeFiles.sort().should.deepEqual(rootOnlyFiles.sort())
})
})
+19 -1
View File
@@ -86,16 +86,34 @@ const OS_GENERATED_FILES = [
* Recursively reads a directory and returns an array of absolute file paths.
*
* @param directoryPath - The path to the directory to read.
* @param excludedPaths - Nested array of paths to ignore.
* @returns A promise that resolves to an array of absolute file paths.
* @throws Error if the directory cannot be read.
*/
export const readDirectory = async (directoryPath: string) => {
export const readDirectory = async (directoryPath: string, excludedPaths: string[][] = []) => {
try {
const filePaths = await fs
.readdir(directoryPath, { withFileTypes: true, recursive: true })
.then((entries) => entries.filter((entry) => !OS_GENERATED_FILES.includes(entry.name)))
.then((entries) => entries.filter((entry) => entry.isFile()))
.then((files) => files.map((file) => path.resolve(file.parentPath, file.name)))
.then((filePaths) =>
filePaths.filter((filePath) => {
if (excludedPaths.length === 0) {
return true
}
for (const excludedPathList of excludedPaths) {
const pathToSearchFor = path.sep + excludedPathList.join(path.sep) + path.sep
if (filePath.includes(pathToSearchFor)) {
return false
}
}
return true
}),
)
return filePaths
} catch {
throw new Error(`Error reading directory at ${directoryPath}`)
@@ -259,7 +259,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
},
ref,
) => {
const { filePaths, chatSettings, apiConfiguration, openRouterModels, platform } = useExtensionState()
const { filePaths, chatSettings, apiConfiguration, openRouterModels, platform, workflowToggles } = useExtensionState()
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [isDraggingOver, setIsDraggingOver] = useState(false)
const [gitCommits, setGitCommits] = useState<GitCommit[]>([])
@@ -373,6 +373,25 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
}, [showContextMenu, setShowContextMenu])
useEffect(() => {
const handleClickOutsideSlashMenu = (event: MouseEvent) => {
if (
slashCommandsMenuContainerRef.current &&
!slashCommandsMenuContainerRef.current.contains(event.target as Node)
) {
setShowSlashCommandsMenu(false)
}
}
if (showSlashCommandsMenu) {
document.addEventListener("mousedown", handleClickOutsideSlashMenu)
}
return () => {
document.removeEventListener("mousedown", handleClickOutsideSlashMenu)
}
}, [showSlashCommandsMenu])
const handleMentionSelect = useCallback(
(type: ContextMenuOptionType, value?: string) => {
if (type === ContextMenuOptionType.NoResults) {
@@ -463,13 +482,18 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
event.preventDefault()
setSelectedSlashCommandsIndex((prevIndex) => {
const direction = event.key === "ArrowUp" ? -1 : 1
const commands = getMatchingSlashCommands(slashCommandsQuery)
// Get commands with workflow toggles
const allCommands = getMatchingSlashCommands(slashCommandsQuery, workflowToggles)
if (commands.length === 0) {
if (allCommands.length === 0) {
return prevIndex
}
const newIndex = (prevIndex + direction + commands.length) % commands.length
// Calculate total command count
const totalCommandCount = allCommands.length
// Create wraparound navigation - moves from last item to first and vice versa
const newIndex = (prevIndex + direction + totalCommandCount) % totalCommandCount
return newIndex
})
return
@@ -477,7 +501,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
if ((event.key === "Enter" || event.key === "Tab") && selectedSlashCommandsIndex !== -1) {
event.preventDefault()
const commands = getMatchingSlashCommands(slashCommandsQuery)
const commands = getMatchingSlashCommands(slashCommandsQuery, workflowToggles)
if (commands.length > 0) {
handleSlashCommandsSelect(commands[selectedSlashCommandsIndex])
}
@@ -880,7 +904,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
// extract and validate the exact command text
const commandText = processedText.substring(slashIndex + 1, endIndex)
const isValidCommand = validateSlashCommand(commandText)
const isValidCommand = validateSlashCommand(commandText, workflowToggles)
if (isValidCommand) {
const fullCommand = processedText.substring(slashIndex, endIndex) // includes slash
@@ -893,7 +917,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
highlightLayerRef.current.innerHTML = processedText
highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
}, [])
}, [workflowToggles])
useLayoutEffect(() => {
updateHighlights()
@@ -1373,6 +1397,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setSelectedIndex={setSelectedSlashCommandsIndex}
onMouseDown={handleMenuMouseDown}
query={slashCommandsQuery}
workflowToggles={workflowToggles}
/>
</div>
)}
+9 -1
View File
@@ -983,6 +983,14 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
)
}
// We display certain statuses for the last message only
// If the last message is a checkpoint, we want to show the status of the previous message
const nextMessage = index < groupedMessages.length - 1 && groupedMessages[index + 1]
const isNextCheckpoint = !Array.isArray(nextMessage) && nextMessage && nextMessage?.say === "checkpoint_created"
const isLastMessageGroup = isNextCheckpoint && index === groupedMessages.length - 2
const isLast = index === groupedMessages.length - 1 || isLastMessageGroup
// regular message
return (
<ChatRow
@@ -991,7 +999,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
isExpanded={expandedRows[messageOrGroup.ts] || false}
onToggleExpand={() => toggleRowExpansion(messageOrGroup.ts)}
lastModifiedMessage={modifiedMessages.at(-1)}
isLast={index === groupedMessages.length - 1}
isLast={isLast}
onHeightChange={handleRowHeightChange}
inputValue={inputValue}
sendMessageFromChatRow={handleSendMessage}
@@ -7,9 +7,17 @@ interface SlashCommandMenuProps {
setSelectedIndex: (index: number) => void
onMouseDown: () => void
query: string
workflowToggles?: Record<string, boolean>
}
const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({ onSelect, selectedIndex, setSelectedIndex, onMouseDown, query }) => {
const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
onSelect,
selectedIndex,
setSelectedIndex,
onMouseDown,
query,
workflowToggles = {},
}) => {
const menuRef = useRef<HTMLDivElement>(null)
const handleClick = useCallback(
@@ -19,10 +27,9 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({ onSelect, selectedI
[onSelect],
)
// Auto-scroll logic remains the same...
useEffect(() => {
if (menuRef.current) {
const selectedElement = menuRef.current.children[selectedIndex] as HTMLElement
const selectedElement = menuRef.current.querySelector(`#slash-command-menu-item-${selectedIndex}`) as HTMLElement
if (selectedElement) {
const menuRect = menuRef.current.getBoundingClientRect()
const selectedRect = selectedElement.getBoundingClientRect()
@@ -37,7 +44,46 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({ onSelect, selectedI
}, [selectedIndex])
// Filter commands based on query
const filteredCommands = getMatchingSlashCommands(query)
const filteredCommands = getMatchingSlashCommands(query, workflowToggles)
const defaultCommands = filteredCommands.filter((cmd) => cmd.section === "default" || !cmd.section)
const workflowCommands = filteredCommands.filter((cmd) => cmd.section === "custom")
// Create a reusable function for rendering a command section
const renderCommandSection = (commands: SlashCommand[], title: string, indexOffset: number, showDescriptions: boolean) => {
if (commands.length === 0) return null
return (
<>
<div className="text-xs text-[var(--vscode-descriptionForeground)] px-3 py-1 font-bold border-b border-[var(--vscode-editorGroup-border)]">
{title}
</div>
{commands.map((command, index) => {
const itemIndex = index + indexOffset
return (
<div
key={command.name}
id={`slash-command-menu-item-${itemIndex}`}
className={`slash-command-menu-item py-2 px-3 cursor-pointer flex flex-col border-b border-[var(--vscode-editorGroup-border)] ${
itemIndex === selectedIndex
? "bg-[var(--vscode-quickInputList-focusBackground)] text-[var(--vscode-quickInputList-focusForeground)]"
: ""
} hover:bg-[var(--vscode-list-hoverBackground)]`}
onClick={() => handleClick(command)}
onMouseEnter={() => setSelectedIndex(itemIndex)}>
<div className="font-bold whitespace-nowrap overflow-hidden text-ellipsis">
<span className="ph-no-capture">/{command.name}</span>
</div>
{showDescriptions && command.description && (
<div className="text-[0.85em] text-[var(--vscode-descriptionForeground)] whitespace-normal overflow-hidden text-ellipsis">
<span className="ph-no-capture">{command.description}</span>
</div>
)}
</div>
)
})}
</>
)
}
return (
<div
@@ -45,33 +91,15 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({ onSelect, selectedI
onMouseDown={onMouseDown}>
<div
ref={menuRef}
className="bg-[var(--vscode-dropdown-background)] border border-[var(--vscode-editorGroup-border)] rounded-[3px] shadow-[0_4px_10px_rgba(0,0,0,0.25)] flex flex-col max-h-[200px] overflow-y-auto" // Corrected rounded and shadow
>
className="bg-[var(--vscode-dropdown-background)] border border-[var(--vscode-editorGroup-border)] rounded-[3px] shadow-[0_4px_10px_rgba(0,0,0,0.25)] flex flex-col overflow-y-auto"
style={{ maxHeight: "min(200px, calc(50vh))", overscrollBehavior: "contain" }}>
{filteredCommands.length > 0 ? (
filteredCommands.map((command, index) => (
<div
key={command.name}
id={`slash-command-menu-item-${index}`}
className={`slash-command-menu-item py-2 px-3 cursor-pointer flex flex-col border-b border-[var(--vscode-editorGroup-border)] ${
// Corrected padding
index === selectedIndex
? "bg-[var(--vscode-quickInputList-focusBackground)] text-[var(--vscode-quickInputList-focusForeground)]"
: "" // Removed bg-transparent
} hover:bg-[var(--vscode-list-hoverBackground)]`}
onClick={() => handleClick(command)}
onMouseEnter={() => setSelectedIndex(index)}>
<div className="font-bold whitespace-nowrap overflow-hidden text-ellipsis">
<span className="ph-no-capture">/{command.name}</span>
</div>
<div className="text-[0.85em] text-[var(--vscode-descriptionForeground)] whitespace-normal overflow-hidden text-ellipsis">
<span className="ph-no-capture">{command.description}</span>
</div>
</div>
))
<>
{renderCommandSection(defaultCommands, "Default Commands", 0, true)}
{renderCommandSection(workflowCommands, "Workflow Commands", defaultCommands.length, false)}
</>
) : (
<div className="py-2 px-3 cursor-default flex flex-col">
{" "}
{/* Corrected padding, removed border, changed cursor */}
<div className="text-[0.85em] text-[var(--vscode-descriptionForeground)]">No matching commands found</div>
</div>
)}
@@ -6,6 +6,7 @@ import { vscode } from "@/utils/vscode"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import RulesToggleList from "./RulesToggleList"
import Tooltip from "@/components/common/Tooltip"
import styled from "styled-components"
const ClineRulesToggleModal: React.FC = () => {
const {
@@ -13,6 +14,7 @@ const ClineRulesToggleModal: React.FC = () => {
localClineRulesToggles = {},
localCursorRulesToggles = {},
localWindsurfRulesToggles = {},
workflowToggles = {},
} = useExtensionState()
const [isVisible, setIsVisible] = useState(false)
const buttonRef = useRef<HTMLDivElement>(null)
@@ -20,6 +22,7 @@ const ClineRulesToggleModal: React.FC = () => {
const { width: viewportWidth, height: viewportHeight } = useWindowSize()
const [arrowPosition, setArrowPosition] = useState(0)
const [menuPosition, setMenuPosition] = useState(0)
const [currentView, setCurrentView] = useState<"rules" | "workflows">("rules")
useEffect(() => {
if (isVisible) {
@@ -45,6 +48,10 @@ const ClineRulesToggleModal: React.FC = () => {
.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
.sort(([a], [b]) => a.localeCompare(b))
const workflows = Object.entries(workflowToggles || {})
.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
.sort(([a], [b]) => a.localeCompare(b))
// Handle toggle rule
const toggleRule = (isGlobal: boolean, rulePath: string, enabled: boolean) => {
vscode.postMessage({
@@ -71,6 +78,14 @@ const ClineRulesToggleModal: React.FC = () => {
})
}
const toggleWorkflow = (workflowPath: string, enabled: boolean) => {
vscode.postMessage({
type: "toggleWorkflow",
workflowPath,
enabled,
})
}
// Close modal when clicking outside
useClickAway(modalRef, () => {
setIsVisible(false)
@@ -91,7 +106,7 @@ const ClineRulesToggleModal: React.FC = () => {
return (
<div ref={modalRef}>
<div ref={buttonRef} className="inline-flex min-w-0 max-w-full">
<Tooltip tipText="Manage Cline Rules" visible={isVisible ? false : undefined}>
<Tooltip tipText="Manage Cline Rules & Workflows" visible={isVisible ? false : undefined}>
<VSCodeButton
appearance="icon"
aria-label="Cline Rules"
@@ -125,68 +140,146 @@ const ClineRulesToggleModal: React.FC = () => {
}}
/>
<div className="flex justify-between items-center mb-2.5">
<div className="m-0 text-base font-semibold">Cline Rules</div>
<VSCodeButton
appearance="icon"
onClick={() => {
vscode.postMessage({
type: "openExtensionSettings",
})
setIsVisible(false)
}}></VSCodeButton>
{/* Tabs container */}
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: "10px",
}}>
<div
style={{
display: "flex",
gap: "1px",
borderBottom: "1px solid var(--vscode-panel-border)",
}}>
<TabButton isActive={currentView === "rules"} onClick={() => setCurrentView("rules")}>
Rules
</TabButton>
<TabButton isActive={currentView === "workflows"} onClick={() => setCurrentView("workflows")}>
Workflows
</TabButton>
</div>
</div>
{/* Global Rules Section */}
<div className="mb-3">
<div className="text-sm font-normal mb-2">Global Rules</div>
<RulesToggleList
rules={globalRules}
toggleRule={(rulePath, enabled) => toggleRule(true, rulePath, enabled)}
listGap="small"
isGlobal={true}
ruleType={"cline"}
showNewRule={true}
showNoRules={true}
/>
{/* Description text */}
<div className="text-xs text-[var(--vscode-descriptionForeground)] mb-4">
{currentView === "rules" ? (
<p>
Rules allow you to provide Cline with system-level guidance. Think of them as a persistent way to
include context and preferences for your projects or globally for every conversation.
</p>
) : (
<p>
Workflows allow you to define a series of steps to guide Cline through a repetitive set of tasks,
such as deploying a service or submitting a PR. To invoke a workflow, type{" "}
<span
className="
text-[var(--vscode-foreground)] font-bold">
/workflow-name
</span>{" "}
in the chat.
</p>
)}
</div>
{/* Local Rules Section */}
<div style={{ marginBottom: -10 }}>
<div className="text-sm font-normal mb-2">Workspace Rules</div>
<RulesToggleList
rules={localRules}
toggleRule={(rulePath, enabled) => toggleRule(false, rulePath, enabled)}
listGap="small"
isGlobal={false}
ruleType={"cline"}
showNewRule={false}
showNoRules={false}
/>
<RulesToggleList
rules={cursorRules}
toggleRule={toggleCursorRule}
listGap="small"
isGlobal={false}
ruleType={"cursor"}
showNewRule={false}
showNoRules={false}
/>
<RulesToggleList
rules={windsurfRules}
toggleRule={toggleWindsurfRule}
listGap="small"
isGlobal={false}
ruleType={"windsurf"}
showNewRule={true}
showNoRules={localRules.length === 0 && cursorRules.length === 0 && windsurfRules.length === 0}
/>
</div>
{currentView === "rules" ? (
<>
{/* Global Rules Section */}
<div className="mb-3">
<div className="text-sm font-normal mb-2">Global Rules</div>
<RulesToggleList
rules={globalRules}
toggleRule={(rulePath, enabled) => toggleRule(true, rulePath, enabled)}
listGap="small"
isGlobal={true}
ruleType={"cline"}
showNewRule={true}
showNoRules={false}
/>
</div>
{/* Local Rules Section */}
<div style={{ marginBottom: -10 }}>
<div className="text-sm font-normal mb-2">Workspace Rules</div>
<RulesToggleList
rules={localRules}
toggleRule={(rulePath, enabled) => toggleRule(false, rulePath, enabled)}
listGap="small"
isGlobal={false}
ruleType={"cline"}
showNewRule={false}
showNoRules={false}
/>
<RulesToggleList
rules={cursorRules}
toggleRule={toggleCursorRule}
listGap="small"
isGlobal={false}
ruleType={"cursor"}
showNewRule={false}
showNoRules={false}
/>
<RulesToggleList
rules={windsurfRules}
toggleRule={toggleWindsurfRule}
listGap="small"
isGlobal={false}
ruleType={"windsurf"}
showNewRule={true}
showNoRules={false}
/>
</div>
</>
) : (
/* Workflows section */
<div style={{ marginBottom: -10 }}>
<div className="text-sm font-normal mb-2">Workspace Workflows</div>
<RulesToggleList
rules={workflows}
toggleRule={toggleWorkflow}
listGap="small"
isGlobal={false}
ruleType={"workflow"}
showNewRule={true}
showNoRules={false}
/>
</div>
)}
</div>
)}
</div>
)
}
const StyledTabButton = styled.button<{ isActive: boolean }>`
background: none;
border: none;
border-bottom: 2px solid ${(props) => (props.isActive ? "var(--vscode-foreground)" : "transparent")};
color: ${(props) => (props.isActive ? "var(--vscode-foreground)" : "var(--vscode-descriptionForeground)")};
padding: 8px 16px;
cursor: pointer;
font-size: 13px;
margin-bottom: -1px;
font-family: inherit;
&:hover {
color: var(--vscode-foreground);
}
`
export const TabButton = ({
children,
isActive,
onClick,
}: {
children: React.ReactNode
isActive: boolean
onClick: () => void
}) => (
<StyledTabButton isActive={isActive} onClick={onClick}>
{children}
</StyledTabButton>
)
export default ClineRulesToggleModal
@@ -7,9 +7,10 @@ import { CreateRuleFileRequest } from "@shared/proto-conversions/file/rule-files
interface NewRuleRowProps {
isGlobal: boolean
ruleType?: string
}
const NewRuleRow: React.FC<NewRuleRowProps> = ({ isGlobal }) => {
const NewRuleRow: React.FC<NewRuleRowProps> = ({ isGlobal, ruleType }) => {
const [isExpanded, setIsExpanded] = useState(false)
const [filename, setFilename] = useState("")
const inputRef = useRef<HTMLInputElement>(null)
@@ -64,6 +65,7 @@ const NewRuleRow: React.FC<NewRuleRowProps> = ({ isGlobal }) => {
CreateRuleFileRequest.create({
isGlobal,
filename: finalFilename,
type: ruleType || "cline",
}),
)
} catch (err) {
@@ -97,7 +99,11 @@ const NewRuleRow: React.FC<NewRuleRowProps> = ({ isGlobal }) => {
<input
ref={inputRef}
type="text"
placeholder="rule-name (.md, .txt, or no extension)"
placeholder={
ruleType === "workflow"
? "workflow-name (.md, .txt, or no extension)"
: "rule-name (.md, .txt, or no extension)"
}
value={filename}
onChange={(e) => setFilename(e.target.value)}
onKeyDown={handleKeyDown}
@@ -121,7 +127,7 @@ const NewRuleRow: React.FC<NewRuleRowProps> = ({ isGlobal }) => {
) : (
<>
<span className="flex-1 text-[var(--vscode-descriptionForeground)] bg-[var(--vscode-input-background)] italic text-xs">
New rule file...
{ruleType === "workflow" ? "New workflow file..." : "New rule file..."}
</span>
<div className="flex items-center ml-2 space-x-2">
<VSCodeButton
@@ -62,6 +62,7 @@ const RuleRow: React.FC<{
DeleteRuleFileRequest.create({
rulePath: rulePath,
isGlobal: isGlobal,
type: ruleType || "cline",
}),
).catch((err) => console.error("Failed to delete rule file:", err))
}
@@ -40,16 +40,16 @@ const RulesToggleList = ({
ruleType={ruleType}
/>
))}
{showNewRule && <NewRuleRow isGlobal={isGlobal} />}
{showNewRule && <NewRuleRow isGlobal={isGlobal} ruleType={ruleType} />}
</>
) : (
<>
{showNoRules && (
<div className="flex flex-col items-center gap-3 my-3 text-[var(--vscode-descriptionForeground)]">
No rules found
{ruleType === "workflow" ? "No workflows found" : "No rules found"}
</div>
)}
{showNewRule && <NewRuleRow isGlobal={isGlobal} />}
{showNewRule && <NewRuleRow isGlobal={isGlobal} ruleType={ruleType} />}
</>
)}
</div>
@@ -1,7 +1,7 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { vscode } from "@/utils/vscode"
import { memo } from "react"
import { memo, useState } from "react"
import { TaskServiceClient } from "@/services/grpc-client"
import { formatLargeNumber } from "@/utils/format"
@@ -11,10 +11,16 @@ type HistoryPreviewProps = {
const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
const { taskHistory } = useExtensionState()
const [isExpanded, setIsExpanded] = useState(true)
const handleHistorySelect = (id: string) => {
TaskServiceClient.showTaskWithId({ value: id }).catch((error) => console.error("Error showing task:", error))
}
const toggleExpanded = () => {
setIsExpanded(!isExpanded)
}
const formatDate = (timestamp: number) => {
const date = new Date(timestamp)
return date
@@ -48,16 +54,31 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
opacity: 1;
pointer-events: auto;
}
.history-header {
cursor: pointer;
user-select: none;
}
.history-header:hover {
opacity: 0.8;
}
`}
</style>
<div
className="history-header"
onClick={toggleExpanded}
style={{
color: "var(--vscode-descriptionForeground)",
margin: "10px 20px 10px 20px",
display: "flex",
alignItems: "center",
}}>
<span
className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}
style={{
marginRight: "4px",
transform: "scale(0.9)",
}}></span>
<span
className="codicon codicon-comment-discussion"
style={{
@@ -74,102 +95,122 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
</span>
</div>
<div style={{ padding: "0px 20px 0 20px" }}>
{taskHistory
.filter((item) => item.ts && item.task)
.slice(0, 3)
.map((item) => (
<div key={item.id} className="history-preview-item" onClick={() => handleHistorySelect(item.id)}>
<div style={{ padding: "12px" }}>
<div style={{ marginBottom: "8px" }}>
<span
style={{
color: "var(--vscode-descriptionForeground)",
fontWeight: 500,
fontSize: "0.85em",
textTransform: "uppercase",
}}>
{formatDate(item.ts)}
</span>
</div>
{item.isFavorited && (
{isExpanded && (
<div style={{ padding: "0px 20px 0 20px" }}>
{taskHistory.filter((item) => item.ts && item.task).length > 0 ? (
<>
{taskHistory
.filter((item) => item.ts && item.task)
.slice(0, 3)
.map((item) => (
<div
key={item.id}
className="history-preview-item"
onClick={() => handleHistorySelect(item.id)}>
<div style={{ padding: "12px" }}>
<div style={{ marginBottom: "8px" }}>
<span
style={{
color: "var(--vscode-descriptionForeground)",
fontWeight: 500,
fontSize: "0.85em",
textTransform: "uppercase",
}}>
{formatDate(item.ts)}
</span>
</div>
{item.isFavorited && (
<div
style={{
position: "absolute",
top: "12px",
right: "12px",
color: "var(--vscode-button-background)",
}}>
<span className="codicon codicon-star-full" aria-label="Favorited" />
</div>
)}
<div
id={`history-preview-task-${item.id}`}
className="history-preview-task"
style={{
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-descriptionForeground)",
marginBottom: "8px",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}>
<span className="ph-no-capture">{item.task}</span>
</div>
<div
style={{
fontSize: "0.85em",
color: "var(--vscode-descriptionForeground)",
}}>
<span>
Tokens: {formatLargeNumber(item.tokensIn || 0)}
{formatLargeNumber(item.tokensOut || 0)}
</span>
{!!item.cacheWrites && (
<>
{" • "}
<span>
Cache: +{formatLargeNumber(item.cacheWrites || 0)} {" "}
{formatLargeNumber(item.cacheReads || 0)}
</span>
</>
)}
{!!item.totalCost && (
<>
{" • "}
<span>API Cost: ${item.totalCost?.toFixed(4)}</span>
</>
)}
</div>
</div>
</div>
))}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<VSCodeButton
appearance="icon"
onClick={() => showHistoryView()}
style={{
opacity: 0.9,
}}>
<div
style={{
position: "absolute",
top: "12px",
right: "12px",
color: "var(--vscode-button-background)",
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-descriptionForeground)",
}}>
<span className="codicon codicon-star-full" aria-label="Favorited" />
View all history
</div>
)}
<div
id={`history-preview-task-${item.id}`}
className="history-preview-task"
style={{
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-descriptionForeground)",
marginBottom: "8px",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}>
<span className="ph-no-capture">{item.task}</span>
</div>
<div
style={{
fontSize: "0.85em",
color: "var(--vscode-descriptionForeground)",
}}>
<span>
Tokens: {formatLargeNumber(item.tokensIn || 0)} {formatLargeNumber(item.tokensOut || 0)}
</span>
{!!item.cacheWrites && (
<>
{" • "}
<span>
Cache: +{formatLargeNumber(item.cacheWrites || 0)} {" "}
{formatLargeNumber(item.cacheReads || 0)}
</span>
</>
)}
{!!item.totalCost && (
<>
{" • "}
<span>API Cost: ${item.totalCost?.toFixed(4)}</span>
</>
)}
</div>
</VSCodeButton>
</div>
</div>
))}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<VSCodeButton
appearance="icon"
onClick={() => showHistoryView()}
style={{
opacity: 0.9,
}}>
</>
) : (
<div
style={{
fontSize: "var(--vscode-font-size)",
textAlign: "center",
color: "var(--vscode-descriptionForeground)",
fontSize: "var(--vscode-font-size)",
padding: "10px 0",
}}>
View all history
No recent tasks
</div>
</VSCodeButton>
)}
</div>
</div>
)}
</div>
)
}
@@ -78,6 +78,7 @@ export const ExtensionStateContextProvider: React.FC<{
localClineRulesToggles: {},
localCursorRulesToggles: {},
localWindsurfRulesToggles: {},
workflowToggles: {},
shellIntegrationTimeout: 4000, // default timeout for shell integration
isNewUser: false,
})
@@ -273,6 +274,7 @@ export const ExtensionStateContextProvider: React.FC<{
localClineRulesToggles: state.localClineRulesToggles || {},
localCursorRulesToggles: state.localCursorRulesToggles || {},
localWindsurfRulesToggles: state.localWindsurfRulesToggles || {},
workflowToggles: state.workflowToggles || {},
enableCheckpointsSetting: state.enableCheckpointsSetting,
setApiConfiguration: (value) =>
setState((prevState) => ({
+37 -10
View File
@@ -1,31 +1,52 @@
export interface SlashCommand {
name: string
description: string
description?: string
section?: "default" | "custom"
}
export const SUPPORTED_SLASH_COMMANDS: SlashCommand[] = [
export const DEFAULT_SLASH_COMMANDS: SlashCommand[] = [
{
name: "newtask",
description: "Create a new task with context from the current task",
section: "default",
},
{
name: "smol",
description: "Condenses your current context window",
section: "default",
},
{
name: "newrule",
description: "Create a new Cline rule based on your conversation",
section: "default",
},
{
name: "reportbug",
description: "Create a Github issue with Cline",
section: "default",
},
]
export function getWorkflowCommands(workflowToggles: Record<string, boolean>): SlashCommand[] {
return Object.entries(workflowToggles)
.filter(([_, enabled]) => enabled)
.map(([filePath, _]) => {
// potentially remove the file extension if there is one, but this would then require
// that we prevent users from having the same fname with different extensions
const fileName = filePath.replace(/^.*[/\\]/, "")
return {
name: fileName,
section: "custom",
}
})
}
// Regex for detecting slash commands in text
export const slashCommandRegex = /\/([a-zA-Z0-9_-]+)(\s|$)/
// currently doesn't allow whitespace inside of the filename
export const slashCommandRegex = /\/([a-zA-Z0-9_\.-]+)(\s|$)/
export const slashCommandRegexGlobal = new RegExp(slashCommandRegex.source, "g")
export const slashCommandDeleteRegex = /^\s*\/([a-zA-Z0-9_-]+)$/
export const slashCommandDeleteRegex = /^\s*\/([a-zA-Z0-9_\.-]+)$/
/**
* Removes a slash command at the cursor position
@@ -81,13 +102,16 @@ export function shouldShowSlashCommandsMenu(text: string, cursorPosition: number
/**
* Gets filtered slash commands that match the current input
*/
export function getMatchingSlashCommands(query: string): SlashCommand[] {
export function getMatchingSlashCommands(query: string, workflowToggles: Record<string, boolean> = {}): SlashCommand[] {
const workflowCommands = getWorkflowCommands(workflowToggles)
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...workflowCommands]
if (!query) {
return [...SUPPORTED_SLASH_COMMANDS]
return allCommands
}
// filter commands that start with the query (case sensitive)
return SUPPORTED_SLASH_COMMANDS.filter((cmd) => cmd.name.startsWith(query))
return allCommands.filter((cmd) => cmd.name.startsWith(query))
}
/**
@@ -110,19 +134,22 @@ export function insertSlashCommand(text: string, commandName: string): { newValu
* Determines the validation state of a slash command
* Returns partial if we have a partial match against valid commands, or full for full match
*/
export function validateSlashCommand(command: string): "full" | "partial" | null {
export function validateSlashCommand(command: string, workflowToggles: Record<string, boolean> = {}): "full" | "partial" | null {
if (!command) {
return null
}
const workflowCommands = getWorkflowCommands(workflowToggles)
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...workflowCommands]
// case sensitive matching
const exactMatch = SUPPORTED_SLASH_COMMANDS.some((cmd) => cmd.name === command)
const exactMatch = allCommands.some((cmd) => cmd.name === command)
if (exactMatch) {
return "full"
}
const partialMatch = SUPPORTED_SLASH_COMMANDS.some((cmd) => cmd.name.startsWith(command))
const partialMatch = allCommands.some((cmd) => cmd.name.startsWith(command))
if (partialMatch) {
return "partial"