mirror of
https://github.com/cline/cline.git
synced 2026-09-13 01:39:57 +08:00
merge conflicts
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix eternal loading states when the last message is a checkpoint
|
||||
@@ -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).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add Tailwind CSS IntelliSense to the the recommended extensions list
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
new workflow feature
|
||||
Vendored
+6
-1
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -143,6 +143,7 @@ export interface ExtensionState {
|
||||
vscMachineId: string
|
||||
globalClineRulesToggles: ClineRulesToggles
|
||||
localClineRulesToggles: ClineRulesToggles
|
||||
workflowToggles: ClineRulesToggles
|
||||
localCursorRulesToggles: ClineRulesToggles
|
||||
localWindsurfRulesToggles: ClineRulesToggles
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user