mirror of
https://github.com/cline/cline.git
synced 2026-09-02 07:42:19 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd3056e519 | |||
| a702dac0ee | |||
| 740a557dc1 | |||
| 5d9de9e837 |
@@ -106,16 +106,13 @@ import { combineCommandSequences } from "@shared/combineCommandSequences"
|
||||
import { combineHookSequences } from "@shared/combineHookSequences"
|
||||
import type { ClineAsk, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics, getLastApiReqTotalTokens } from "@shared/getApiMetrics"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { getProviderDefaultModelId, getProviderModelIdKey } from "@shared/storage"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { execSync } from "child_process"
|
||||
import { Box, Static, Text, useApp, useInput } from "ink"
|
||||
// biome-ignore lint/style/useImportType: JSX requires React as a value (jsx: "react" in tsconfig)
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { getAvailableSlashCommands } from "@/core/controller/slash/getAvailableSlashCommands"
|
||||
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
@@ -123,6 +120,7 @@ import { Logger } from "@/shared/services/Logger"
|
||||
import { Session } from "@/shared/services/Session"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useTaskContext, useTaskState } from "../context/TaskContext"
|
||||
import { useFilteredSlashCommands } from "../hooks/useFilteredSlashCommands"
|
||||
import { useHomeEndKeys } from "../hooks/useHomeEndKeys"
|
||||
import { useIsSpinnerActive } from "../hooks/useStateSubscriber"
|
||||
import { findWordEnd, findWordStart, useTextInput } from "../hooks/useTextInput"
|
||||
@@ -137,7 +135,7 @@ import {
|
||||
} from "../utils/file-search"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { jsonParseSafe, parseImagesFromInput } from "../utils/parser"
|
||||
import { extractSlashQuery, filterCommands, insertSlashCommand, sortCommandsWorkflowsFirst } from "../utils/slash-commands"
|
||||
import { insertSlashCommand } from "../utils/slash-commands"
|
||||
import { waitFor } from "../utils/timeout"
|
||||
import { isFileEditTool, parseToolFromMessage } from "../utils/tools"
|
||||
import { shutdownEvent } from "../vscode-shim"
|
||||
@@ -403,14 +401,17 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
const PASTE_UPDATE_DEBOUNCE_MS = 50 // Debounce visual updates to avoid flicker
|
||||
|
||||
// Slash command state
|
||||
const [availableCommands, setAvailableCommands] = useState<SlashCommandInfo[]>([])
|
||||
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0)
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false)
|
||||
const lastSlashIndexRef = useRef<number>(-1)
|
||||
|
||||
// Panel state
|
||||
const [activePanel, setActivePanel] = useState<
|
||||
| { type: "settings"; initialMode?: "model-picker" | "featured-models"; initialModelKey?: "actModelId" | "planModelId" }
|
||||
| {
|
||||
type: "settings"
|
||||
initialMode?: "model-picker" | "featured-models"
|
||||
initialModelKey?: "actModelId" | "planModelId"
|
||||
}
|
||||
| { type: "history" }
|
||||
| { type: "help" }
|
||||
| { type: "skills" }
|
||||
@@ -559,22 +560,8 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
|
||||
const { prompt, imagePaths } = parseImagesFromInput(textInput)
|
||||
const mentionInfo = useMemo(() => extractMentionQuery(textInput), [textInput])
|
||||
const slashInfo = useMemo(() => extractSlashQuery(textInput, cursorPos), [textInput, cursorPos])
|
||||
const filteredCommands = useMemo(
|
||||
() => filterCommands(availableCommands, slashInfo.query),
|
||||
[availableCommands, slashInfo.query],
|
||||
)
|
||||
|
||||
// Reset slash menu dismissed state when a new slash is typed
|
||||
useEffect(() => {
|
||||
if (slashInfo.slashIndex !== lastSlashIndexRef.current) {
|
||||
lastSlashIndexRef.current = slashInfo.slashIndex
|
||||
setSlashMenuDismissed(false)
|
||||
setSelectedSlashIndex(0)
|
||||
}
|
||||
}, [slashInfo.slashIndex])
|
||||
|
||||
const workspacePath = useMemo(() => {
|
||||
const initialWorkspacePath = useMemo(() => {
|
||||
try {
|
||||
const root = ctrl?.getWorkspaceManagerSync?.()?.getPrimaryRoot?.()
|
||||
if (root?.path) {
|
||||
@@ -586,6 +573,27 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
return process.cwd()
|
||||
}, [ctrl])
|
||||
|
||||
// Track the current working directory - updated when change_directory tool is used
|
||||
const [currentCwd, setCurrentCwd] = useState<string>(initialWorkspacePath)
|
||||
|
||||
// Sync currentCwd when initialWorkspacePath changes (e.g. task switch)
|
||||
useEffect(() => {
|
||||
setCurrentCwd(initialWorkspacePath)
|
||||
}, [initialWorkspacePath])
|
||||
|
||||
const workspacePath = currentCwd
|
||||
|
||||
const { cmds: filteredCommands, allCmds: allCommands, slashInfo } = useFilteredSlashCommands(ctrl, textInput, cursorPos)
|
||||
|
||||
// Reset slash menu dismissed state when a new slash is typed
|
||||
useEffect(() => {
|
||||
if (slashInfo.slashIndex !== lastSlashIndexRef.current) {
|
||||
lastSlashIndexRef.current = slashInfo.slashIndex
|
||||
setSlashMenuDismissed(false)
|
||||
setSelectedSlashIndex(0)
|
||||
}
|
||||
}, [slashInfo.slashIndex])
|
||||
|
||||
// Get git branch on mount
|
||||
useEffect(() => {
|
||||
setGitBranch(getGitBranch(workspacePath))
|
||||
@@ -607,28 +615,6 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
})
|
||||
}, [taskId, ctrl, onError])
|
||||
|
||||
// Load available slash commands on mount
|
||||
useEffect(() => {
|
||||
const loadCommands = async () => {
|
||||
if (!ctrl) return
|
||||
try {
|
||||
const response = await getAvailableSlashCommands(ctrl, EmptyRequest.create())
|
||||
const cliCommands = response.commands.filter((cmd) => cmd.cliCompatible !== false)
|
||||
// Add CLI-only commands (like /settings) that are handled locally
|
||||
const cliOnlyCommands: SlashCommandInfo[] = CLI_ONLY_COMMANDS.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description || "",
|
||||
section: cmd.section || "default",
|
||||
cliCompatible: true,
|
||||
}))
|
||||
setAvailableCommands([...cliOnlyCommands, ...sortCommandsWorkflowsFirst(cliCommands)])
|
||||
} catch {
|
||||
// Fallback: commands will be empty, menu won't show
|
||||
}
|
||||
}
|
||||
loadCommands()
|
||||
}, [ctrl])
|
||||
|
||||
// Get history items (limited to MAX_HISTORY_ITEMS, most recent first)
|
||||
const getHistoryItems = useCallback(() => {
|
||||
const history = StateManager.get().getGlobalStateKey("taskHistory")
|
||||
@@ -643,6 +629,19 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
|
||||
const messages = taskState.clineMessages || []
|
||||
|
||||
// Watch for CWD changes from the change_directory tool.
|
||||
// Task.changeCwd() updates task.cwd and then calls postStateToWebview(), which triggers
|
||||
// a TaskContext state update and re-renders ChatView. At that point ctrl.task?.cwd has
|
||||
// the new value — we just read it directly. This is clean and handles approval/denial
|
||||
// correctly: if the user rejects the tool, changeCwd() is never called, so task.cwd
|
||||
// stays unchanged and this effect is a no-op.
|
||||
useEffect(() => {
|
||||
const taskCwd = ctrl?.task?.cwd
|
||||
if (taskCwd && taskCwd !== currentCwd) {
|
||||
setCurrentCwd(taskCwd)
|
||||
}
|
||||
}, [taskState, ctrl?.task?.cwd])
|
||||
|
||||
// Refresh git diff stats when messages change (after file edits)
|
||||
const lastMsg = messages[messages.length - 1]
|
||||
useEffect(() => {
|
||||
@@ -1525,7 +1524,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
<Box>
|
||||
{inputPrompt && <Text color={borderColor}>{inputPrompt} </Text>}
|
||||
<HighlightedInput
|
||||
availableCommands={availableCommands.map((c) => c.name)}
|
||||
availableCommands={allCommands.map((c) => c.name)}
|
||||
cursorPos={cursorPos}
|
||||
text={textInput}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState } from "react"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { getAvailableSlashCommands } from "@/core/controller/slash/getAvailableSlashCommands"
|
||||
import { EmptyRequest, SlashCommandInfo } from "@/shared/proto/index.cline"
|
||||
import { CLI_ONLY_COMMANDS } from "@/shared/slashCommands"
|
||||
import { fuzzyFilter } from "../utils/fuzzy-search"
|
||||
|
||||
interface SlashQueryInfo {
|
||||
inSlashMode: boolean
|
||||
query: string
|
||||
slashIndex: number
|
||||
}
|
||||
|
||||
const EMPTY_RESULT = { cmds: [], allCmds: [], slashInfo: { inSlashMode: false, query: "", slashIndex: -1 } }
|
||||
|
||||
export const useFilteredSlashCommands = (
|
||||
ctrl: Controller,
|
||||
textInput: string,
|
||||
cursorPos: number,
|
||||
): { cmds: SlashCommandInfo[]; allCmds: SlashCommandInfo[]; slashInfo: SlashQueryInfo } => {
|
||||
const [allCommands, setAllCommands] = useState<SlashCommandInfo[]>([])
|
||||
if (!ctrl) return EMPTY_RESULT
|
||||
getAvailableSlashCommands(ctrl, EmptyRequest.create())
|
||||
.then((response) => {
|
||||
const cliCommands = response.commands.filter((cmd) => cmd.cliCompatible !== false)
|
||||
const sorted = [...CLI_ONLY_COMMANDS, ...sortCommandsWorkflowsFirst(cliCommands)]
|
||||
setAllCommands(sorted)
|
||||
})
|
||||
.catch(() => {
|
||||
setAllCommands([])
|
||||
})
|
||||
const slashInfo = extractSlashQuery(textInput, cursorPos)
|
||||
const filteredCmds = slashInfo.inSlashMode ? fuzzyFilter(allCommands, slashInfo.query, (cmd) => cmd.name) : []
|
||||
return { cmds: filteredCmds, allCmds: allCommands, slashInfo }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort commands with workflows (custom section) first, then default commands.
|
||||
*/
|
||||
function sortCommandsWorkflowsFirst(commands: SlashCommandInfo[]): SlashCommandInfo[] {
|
||||
return [...commands.filter((cmd) => cmd.section === "custom"), ...commands.filter((cmd) => cmd.section !== "custom")]
|
||||
}
|
||||
|
||||
function extractSlashQuery(text: string, cursorPosition?: number): SlashQueryInfo {
|
||||
// Use text up to cursor position (or full text if no cursor position provided)
|
||||
const beforeCursor = cursorPosition !== undefined ? text.slice(0, cursorPosition) : text
|
||||
|
||||
// Find the last slash before cursor
|
||||
const slashIndex = beforeCursor.lastIndexOf("/")
|
||||
|
||||
if (slashIndex === -1) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
// Slash must be at start or preceded by whitespace
|
||||
const charBeforeSlash = slashIndex > 0 ? beforeCursor[slashIndex - 1] : null
|
||||
if (charBeforeSlash !== null && !/\s/.test(charBeforeSlash)) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
// Get text after slash (up to cursor)
|
||||
const textAfterSlash = beforeCursor.slice(slashIndex + 1)
|
||||
|
||||
// If there's whitespace after slash, we're not in slash mode anymore
|
||||
if (/\s/.test(textAfterSlash)) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
// Check if there's already a completed slash command earlier in the text
|
||||
// (only first slash command per message is processed)
|
||||
const firstSlashCommandRegex = /(^|\s)\/[a-zA-Z0-9_.-]+\s/
|
||||
const textBeforeCurrentSlash = text.slice(0, slashIndex)
|
||||
if (firstSlashCommandRegex.test(textBeforeCurrentSlash)) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
return {
|
||||
inSlashMode: true,
|
||||
query: textAfterSlash,
|
||||
slashIndex,
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,6 @@
|
||||
* Handles detection, filtering, and insertion of slash commands
|
||||
*/
|
||||
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { fuzzyFilter } from "./fuzzy-search"
|
||||
|
||||
export interface SlashQueryInfo {
|
||||
inSlashMode: boolean
|
||||
query: string
|
||||
slashIndex: number
|
||||
}
|
||||
|
||||
export interface VisibleWindow<T> {
|
||||
items: T[]
|
||||
startIndex: number
|
||||
@@ -22,7 +13,7 @@ export interface VisibleWindow<T> {
|
||||
* Centers the selected item in the visible window when possible.
|
||||
* Returns the visible items and the start index for selection tracking.
|
||||
*/
|
||||
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible: number = 5): VisibleWindow<T> {
|
||||
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible = 5): VisibleWindow<T> {
|
||||
if (items.length <= maxVisible) {
|
||||
return { items, startIndex: 0 }
|
||||
}
|
||||
@@ -39,68 +30,6 @@ export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisibl
|
||||
return { items: items.slice(startIndex, endIndex), startIndex }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort commands with workflows (custom section) first, then default commands.
|
||||
*/
|
||||
export function sortCommandsWorkflowsFirst(commands: SlashCommandInfo[]): SlashCommandInfo[] {
|
||||
return [...commands.filter((cmd) => cmd.section === "custom"), ...commands.filter((cmd) => cmd.section !== "custom")]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract slash command query from input text.
|
||||
* Returns info about whether we're in slash mode and what the query is.
|
||||
* Takes cursor position to only examine text before cursor (matching webview behavior).
|
||||
*/
|
||||
export function extractSlashQuery(text: string, cursorPosition?: number): SlashQueryInfo {
|
||||
// Use text up to cursor position (or full text if no cursor position provided)
|
||||
const beforeCursor = cursorPosition !== undefined ? text.slice(0, cursorPosition) : text
|
||||
|
||||
// Find the last slash before cursor
|
||||
const slashIndex = beforeCursor.lastIndexOf("/")
|
||||
|
||||
if (slashIndex === -1) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
// Slash must be at start or preceded by whitespace
|
||||
const charBeforeSlash = slashIndex > 0 ? beforeCursor[slashIndex - 1] : null
|
||||
if (charBeforeSlash !== null && !/\s/.test(charBeforeSlash)) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
// Get text after slash (up to cursor)
|
||||
const textAfterSlash = beforeCursor.slice(slashIndex + 1)
|
||||
|
||||
// If there's whitespace after slash, we're not in slash mode anymore
|
||||
if (/\s/.test(textAfterSlash)) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
// Check if there's already a completed slash command earlier in the text
|
||||
// (only first slash command per message is processed)
|
||||
const firstSlashCommandRegex = /(^|\s)\/[a-zA-Z0-9_.-]+\s/
|
||||
const textBeforeCurrentSlash = text.slice(0, slashIndex)
|
||||
if (firstSlashCommandRegex.test(textBeforeCurrentSlash)) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
return {
|
||||
inSlashMode: true,
|
||||
query: textAfterSlash,
|
||||
slashIndex,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter commands using fuzzy matching
|
||||
*/
|
||||
export function filterCommands(commands: SlashCommandInfo[], query: string): SlashCommandInfo[] {
|
||||
if (!query) {
|
||||
return commands
|
||||
}
|
||||
return fuzzyFilter(commands, query, (cmd) => cmd.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a slash command at the given slash index, replacing any partial query
|
||||
*/
|
||||
|
||||
@@ -89,6 +89,7 @@ export const TOOL_DESCRIPTIONS: Record<string, { ask: string; say: string }> = {
|
||||
attempt_completion: { ask: "wants to complete the task", say: "completed the task" },
|
||||
new_task: { ask: "wants to create a new task", say: "created a new task" },
|
||||
focus_chain: { ask: "wants to update the todo list", say: "updated the todo list" },
|
||||
change_directory: { ask: "wants to change working directory", say: "changed working directory" },
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ModelFamily } from "@/shared/prompts"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ClineToolSpec } from "../spec"
|
||||
|
||||
const GENERIC: ClineToolSpec = {
|
||||
variant: ModelFamily.GENERIC,
|
||||
id: ClineDefaultTool.CHANGE_DIRECTORY,
|
||||
name: "change_directory",
|
||||
description: `Request to change the current working directory for all subsequent operations. This changes the base directory used for file operations, terminal commands, and path resolution. Use this when you need to work in a different project or directory than the one you started in.
|
||||
|
||||
Important notes:
|
||||
- The path must be an absolute path to an existing directory
|
||||
- After changing directory, all relative paths will resolve against the new directory
|
||||
- File listings in environment_details will reflect the new directory
|
||||
- New terminal sessions will start in the new directory
|
||||
- Checkpoints will be disabled after changing directory
|
||||
- This tool is only available in CLI environments`,
|
||||
contextRequirements: (context) => context.isCliEnvironment === true,
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
required: true,
|
||||
instruction: "The absolute path of the directory to change to. Must be an existing directory.",
|
||||
usage: "/Users/username/projects/other-project",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const change_directory_variants = [GENERIC]
|
||||
@@ -4,6 +4,7 @@ export * from "./apply_patch"
|
||||
export * from "./ask_followup_question"
|
||||
export * from "./attempt_completion"
|
||||
export * from "./browser_action"
|
||||
export * from "./change_directory"
|
||||
export * from "./execute_command"
|
||||
export * from "./focus_chain"
|
||||
export * from "./init"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { apply_patch_variants } from "./apply_patch"
|
||||
import { ask_followup_question_variants } from "./ask_followup_question"
|
||||
import { attempt_completion_variants } from "./attempt_completion"
|
||||
import { browser_action_variants } from "./browser_action"
|
||||
import { change_directory_variants } from "./change_directory"
|
||||
import { execute_command_variants } from "./execute_command"
|
||||
import { focus_chain_variants } from "./focus_chain"
|
||||
import { generate_explanation_variants } from "./generate_explanation"
|
||||
@@ -55,6 +56,7 @@ export function registerClineToolSets(): void {
|
||||
...web_search_variants,
|
||||
...write_to_file_variants,
|
||||
...apply_patch_variants,
|
||||
...change_directory_variants,
|
||||
]
|
||||
|
||||
// Register each variant
|
||||
|
||||
@@ -66,6 +66,7 @@ export const config = createVariant(ModelFamily.GEMINI_3)
|
||||
ClineDefaultTool.GENERATE_EXPLANATION,
|
||||
ClineDefaultTool.USE_SKILL,
|
||||
ClineDefaultTool.USE_SUBAGENTS,
|
||||
ClineDefaultTool.CHANGE_DIRECTORY,
|
||||
)
|
||||
.placeholders({
|
||||
MODEL_FAMILY: ModelFamily.GEMINI_3,
|
||||
|
||||
@@ -74,6 +74,7 @@ export const config = createVariant(ModelFamily.GENERIC)
|
||||
ClineDefaultTool.GENERATE_EXPLANATION,
|
||||
ClineDefaultTool.USE_SKILL,
|
||||
ClineDefaultTool.USE_SUBAGENTS,
|
||||
ClineDefaultTool.CHANGE_DIRECTORY,
|
||||
)
|
||||
.placeholders({
|
||||
MODEL_FAMILY: "generic",
|
||||
|
||||
@@ -54,6 +54,7 @@ export const config = createVariant(ModelFamily.GLM)
|
||||
ClineDefaultTool.GENERATE_EXPLANATION,
|
||||
ClineDefaultTool.USE_SKILL,
|
||||
ClineDefaultTool.USE_SUBAGENTS,
|
||||
ClineDefaultTool.CHANGE_DIRECTORY,
|
||||
)
|
||||
.placeholders({
|
||||
MODEL_FAMILY: ModelFamily.GLM,
|
||||
|
||||
@@ -65,6 +65,7 @@ export const config = createVariant(ModelFamily.GPT_5)
|
||||
ClineDefaultTool.GENERATE_EXPLANATION,
|
||||
ClineDefaultTool.USE_SKILL,
|
||||
ClineDefaultTool.USE_SUBAGENTS,
|
||||
ClineDefaultTool.CHANGE_DIRECTORY,
|
||||
)
|
||||
.placeholders({
|
||||
MODEL_FAMILY: ModelFamily.GPT_5,
|
||||
|
||||
@@ -56,6 +56,7 @@ export const config = createVariant(ModelFamily.HERMES)
|
||||
ClineDefaultTool.GENERATE_EXPLANATION,
|
||||
ClineDefaultTool.USE_SKILL,
|
||||
ClineDefaultTool.USE_SUBAGENTS,
|
||||
ClineDefaultTool.CHANGE_DIRECTORY,
|
||||
)
|
||||
.placeholders({
|
||||
MODEL_FAMILY: "hermes",
|
||||
|
||||
@@ -72,6 +72,7 @@ export const config = createVariant(ModelFamily.NATIVE_GPT_5_1)
|
||||
ClineDefaultTool.GENERATE_EXPLANATION,
|
||||
ClineDefaultTool.USE_SKILL,
|
||||
ClineDefaultTool.USE_SUBAGENTS,
|
||||
ClineDefaultTool.CHANGE_DIRECTORY,
|
||||
)
|
||||
.placeholders({
|
||||
MODEL_FAMILY: ModelFamily.NATIVE_GPT_5_1,
|
||||
|
||||
@@ -78,6 +78,7 @@ export const config = createVariant(ModelFamily.NATIVE_GPT_5)
|
||||
ClineDefaultTool.GENERATE_EXPLANATION,
|
||||
ClineDefaultTool.USE_SKILL,
|
||||
ClineDefaultTool.USE_SUBAGENTS,
|
||||
ClineDefaultTool.CHANGE_DIRECTORY,
|
||||
)
|
||||
.placeholders({
|
||||
MODEL_FAMILY: ModelFamily.NATIVE_GPT_5,
|
||||
|
||||
@@ -64,6 +64,7 @@ export const config = createVariant(ModelFamily.NATIVE_NEXT_GEN)
|
||||
ClineDefaultTool.GENERATE_EXPLANATION,
|
||||
ClineDefaultTool.USE_SKILL,
|
||||
ClineDefaultTool.USE_SUBAGENTS,
|
||||
ClineDefaultTool.CHANGE_DIRECTORY,
|
||||
)
|
||||
.placeholders({
|
||||
MODEL_FAMILY: ModelFamily.NATIVE_NEXT_GEN,
|
||||
|
||||
@@ -68,6 +68,7 @@ export const config = createVariant(ModelFamily.NEXT_GEN)
|
||||
ClineDefaultTool.GENERATE_EXPLANATION,
|
||||
ClineDefaultTool.USE_SKILL,
|
||||
ClineDefaultTool.USE_SUBAGENTS,
|
||||
ClineDefaultTool.CHANGE_DIRECTORY,
|
||||
)
|
||||
.placeholders({
|
||||
MODEL_FAMILY: ModelFamily.NEXT_GEN,
|
||||
|
||||
@@ -50,6 +50,7 @@ export const config = createVariant(ModelFamily.XS)
|
||||
ClineDefaultTool.ATTEMPT,
|
||||
ClineDefaultTool.PLAN_MODE,
|
||||
ClineDefaultTool.USE_SUBAGENTS,
|
||||
ClineDefaultTool.CHANGE_DIRECTORY,
|
||||
)
|
||||
.placeholders({
|
||||
MODEL_FAMILY: ModelFamily.XS,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type Settings,
|
||||
type SettingsKey,
|
||||
} from "@shared/storage/state-keys"
|
||||
import type { StorageContext } from "@shared/storage/storage-context"
|
||||
import { createStorageContext, type StorageContext } from "@shared/storage/storage-context"
|
||||
import chokidar, { FSWatcher } from "chokidar"
|
||||
import { initializeDistinctId } from "@/services/logging/distinctId"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -938,4 +938,41 @@ export class StateManager {
|
||||
}
|
||||
return { ...this.workspaceStateCache }
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the workspace state to a new directory.
|
||||
*
|
||||
* Called after a change_directory tool execution so that workspace-scoped state
|
||||
* (e.g. workflowToggles, localClineRulesToggles) reflects the new directory.
|
||||
*
|
||||
* Workspace state is stored in a hash-keyed subdirectory under ~/.cline/data/workspaces/.
|
||||
* The StateManager is initialized once at startup with the original workspace path, so
|
||||
* its in-memory cache and backing store both point to the old directory's file.
|
||||
* This method flushes any pending writes to the old store, then swaps in a new
|
||||
* ClineFileStorage for the new path and reloads the cache from it.
|
||||
*/
|
||||
public async reinitializeWorkspaceState(newWorkspacePath: string): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
|
||||
}
|
||||
|
||||
// Flush any pending workspace state writes to the old directory before switching
|
||||
if (this.pendingWorkspaceState.size > 0) {
|
||||
await this.persistWorkspaceStateBatch(this.pendingWorkspaceState)
|
||||
this.pendingWorkspaceState.clear()
|
||||
}
|
||||
|
||||
// Create a new storage context for the new workspace path.
|
||||
// This computes the correct hash-based subdirectory and creates the file if needed.
|
||||
const newStorageContext = createStorageContext({ workspacePath: newWorkspacePath })
|
||||
|
||||
// Swap the workspace state backing store so future writes go to the new directory
|
||||
this.storage = { ...this.storage, workspaceState: newStorageContext.workspaceState }
|
||||
|
||||
// Reload workspace state cache from the new directory's file
|
||||
const newWorkspaceState = readWorkspaceStateFromStorage(newStorageContext.workspaceState)
|
||||
this.workspaceStateCache = newWorkspaceState
|
||||
|
||||
Logger.info(`[StateManager] Workspace state reinitialized for: ${newWorkspacePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,9 @@ export class ToolExecutor {
|
||||
userContent: ClineContent[],
|
||||
context: "initial_task" | "resume" | "feedback",
|
||||
) => Promise<{ cancel?: boolean; wasCancelled?: boolean; contextModification?: string; errorMessage?: string }>,
|
||||
|
||||
// Optional callback for changing CWD (CLI-only)
|
||||
private changeCwdCallback?: (newCwd: string) => Promise<void>,
|
||||
) {
|
||||
this.autoApprover = new AutoApprove(this.stateManager)
|
||||
|
||||
@@ -183,6 +186,7 @@ export class ToolExecutor {
|
||||
setActiveHookExecution: this.setActiveHookExecution,
|
||||
clearActiveHookExecution: this.clearActiveHookExecution,
|
||||
getActiveHookExecution: this.getActiveHookExecution,
|
||||
changeCwd: this.changeCwdCallback,
|
||||
runUserPromptSubmitHook: this.runUserPromptSubmitHook,
|
||||
},
|
||||
coordinator: this.coordinator,
|
||||
@@ -211,6 +215,15 @@ export class ToolExecutor {
|
||||
await this.execute(block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current working directory.
|
||||
* Called by Task.changeCwd() after a change_directory tool execution.
|
||||
* This ensures AutoApprove uses the new CWD for path locality checks.
|
||||
*/
|
||||
public setCwd(newCwd: string): void {
|
||||
this.cwd = newCwd
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the browser settings
|
||||
*/
|
||||
|
||||
+51
-1
@@ -150,7 +150,7 @@ export class Task {
|
||||
readonly taskId: string
|
||||
readonly ulid: string
|
||||
private taskIsFavorited?: boolean
|
||||
private cwd: string
|
||||
cwd: string
|
||||
private taskInitializationStartTime: number
|
||||
|
||||
taskState: TaskState
|
||||
@@ -564,9 +564,59 @@ export class Task {
|
||||
this.clearActiveHookExecution.bind(this),
|
||||
this.getActiveHookExecution.bind(this),
|
||||
this.runUserPromptSubmitHook.bind(this),
|
||||
this.changeCwd.bind(this),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the current working directory mid-task (CLI-only).
|
||||
* This updates the Task's CWD and refreshes all dependent subsystems:
|
||||
* - ClineIgnoreController (reloads .clineignore from new directory)
|
||||
* - WorkspaceManager (rebuilds for new directory)
|
||||
* - Checkpoints (disabled after CWD change)
|
||||
*
|
||||
* Terminal commands will automatically use the new CWD since
|
||||
* CommandExecutor receives cwd per-call from TaskConfig.
|
||||
*/
|
||||
private async changeCwd(newCwd: string): Promise<void> {
|
||||
const oldCwd = this.cwd
|
||||
|
||||
// Update the Task's CWD
|
||||
this.cwd = newCwd
|
||||
|
||||
// Keep ToolExecutor's CWD in sync so AutoApprove uses the new CWD for path locality checks.
|
||||
// This ensures files in the old directory require permission and files in the new directory don't.
|
||||
this.toolExecutor.setCwd(newCwd)
|
||||
|
||||
// Refresh ClineIgnoreController for the new directory
|
||||
this.clineIgnoreController.dispose()
|
||||
this.clineIgnoreController = new ClineIgnoreController(newCwd)
|
||||
await this.clineIgnoreController.initialize()
|
||||
|
||||
// Rebuild WorkspaceManager for the new directory
|
||||
this.workspaceManager = await WorkspaceRootManager.fromLegacyCwd(newCwd)
|
||||
|
||||
await HostProvider.workspace.openFolder({ path: newCwd, newWindow: false })
|
||||
|
||||
// Reload workspace state (workflowToggles, localClineRulesToggles, etc.) for the new
|
||||
// directory. Workspace state is keyed by a hash of the workspace path, so the
|
||||
// StateManager's in-memory cache still reflects the old directory after a CWD change.
|
||||
// Reinitializing it here ensures slash commands and rules pick up the correct toggles.
|
||||
await this.stateManager.reinitializeWorkspaceState(newCwd).catch((err) => {
|
||||
// Non-fatal: log and continue. Worst case, slash commands show stale toggles.
|
||||
Logger.error("[Task] Failed to reinitialize workspace state after CWD change:", err)
|
||||
})
|
||||
|
||||
// Disable checkpoints after CWD change (shadow git repo is tied to original CWD)
|
||||
if (this.checkpointManager) {
|
||||
this.checkpointManager = undefined
|
||||
this.taskState.checkpointManagerErrorMessage = `Checkpoints disabled: working directory changed from ${oldCwd} to ${newCwd}`
|
||||
}
|
||||
|
||||
// Notify the UI that the CWD has changed so the CLI footer updates immediately.
|
||||
await this.postStateToWebview().catch(() => {})
|
||||
}
|
||||
|
||||
// Communicate with webview
|
||||
|
||||
// partial has three valid states true (partial message), false (completion of partial message), undefined (individual complete message)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ApplyPatchHandler } from "./handlers/ApplyPatchHandler"
|
||||
import { AskFollowupQuestionToolHandler } from "./handlers/AskFollowupQuestionToolHandler"
|
||||
import { AttemptCompletionHandler } from "./handlers/AttemptCompletionHandler"
|
||||
import { BrowserToolHandler } from "./handlers/BrowserToolHandler"
|
||||
import { ChangeDirectoryToolHandler } from "./handlers/ChangeDirectoryToolHandler"
|
||||
import { CondenseHandler } from "./handlers/CondenseHandler"
|
||||
import { ExecuteCommandToolHandler } from "./handlers/ExecuteCommandToolHandler"
|
||||
import { GenerateExplanationToolHandler } from "./handlers/GenerateExplanationToolHandler"
|
||||
@@ -106,6 +107,7 @@ export class ToolExecutorCoordinator {
|
||||
[ClineDefaultTool.GENERATE_EXPLANATION]: (_v: ToolValidator) => new GenerateExplanationToolHandler(),
|
||||
[ClineDefaultTool.USE_SKILL]: (_v: ToolValidator) => new UseSkillToolHandler(),
|
||||
[ClineDefaultTool.USE_SUBAGENTS]: (_v: ToolValidator) => new UseSubagentsToolHandler(),
|
||||
[ClineDefaultTool.CHANGE_DIRECTORY]: (_v: ToolValidator) => new ChangeDirectoryToolHandler(),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -59,6 +59,7 @@ export class AutoApprove {
|
||||
case ClineDefaultTool.WEB_SEARCH:
|
||||
case ClineDefaultTool.MCP_ACCESS:
|
||||
case ClineDefaultTool.MCP_USE:
|
||||
case ClineDefaultTool.CHANGE_DIRECTORY:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -81,6 +82,7 @@ export class AutoApprove {
|
||||
case ClineDefaultTool.WEB_SEARCH:
|
||||
case ClineDefaultTool.MCP_ACCESS:
|
||||
case ClineDefaultTool.MCP_USE:
|
||||
case ClineDefaultTool.CHANGE_DIRECTORY:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -112,6 +114,8 @@ export class AutoApprove {
|
||||
case ClineDefaultTool.MCP_ACCESS:
|
||||
case ClineDefaultTool.MCP_USE:
|
||||
return autoApprovalSettings.actions.useMcp
|
||||
case ClineDefaultTool.CHANGE_DIRECTORY:
|
||||
return autoApprovalSettings.actions.changeDirectory ?? false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { ToolUse } from "../../../assistant-message"
|
||||
import { formatResponse } from "../../../prompts/responses"
|
||||
import { ToolResponse } from "../.."
|
||||
import { showNotificationForApproval } from "../../utils"
|
||||
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
|
||||
import { ToolResultUtils } from "../utils/ToolResultUtils"
|
||||
|
||||
export class ChangeDirectoryToolHandler implements IFullyManagedTool {
|
||||
readonly name = ClineDefaultTool.CHANGE_DIRECTORY
|
||||
|
||||
getDescription(block: ToolUse): string {
|
||||
return `[${block.name} for '${block.params.path}']`
|
||||
}
|
||||
|
||||
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
|
||||
const targetPath = block.params.path || ""
|
||||
const sharedMessageProps: ClineSayTool = {
|
||||
tool: "changeDirectory",
|
||||
path: uiHelpers.removeClosingTag(block, "path", targetPath),
|
||||
content: `Changing working directory to: ${uiHelpers.removeClosingTag(block, "path", targetPath)}`,
|
||||
operationIsLocatedInWorkspace: false,
|
||||
} satisfies ClineSayTool
|
||||
|
||||
const partialMessage = JSON.stringify(sharedMessageProps)
|
||||
|
||||
await uiHelpers.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await uiHelpers.ask("tool", partialMessage, block.partial).catch(() => {})
|
||||
}
|
||||
|
||||
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
|
||||
const targetPath: string | undefined = block.params.path
|
||||
|
||||
// Validate required parameters
|
||||
if (!targetPath) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return await config.callbacks.sayAndCreateMissingParamError(this.name, "path")
|
||||
}
|
||||
|
||||
// Validate path is absolute
|
||||
if (!path.isAbsolute(targetPath)) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return formatResponse.toolError(
|
||||
`The path must be an absolute path. Received relative path: "${targetPath}". Please provide the full absolute path to the directory.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Validate directory exists
|
||||
try {
|
||||
const stat = await fs.stat(targetPath)
|
||||
if (!stat.isDirectory()) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return formatResponse.toolError(`The path "${targetPath}" exists but is not a directory.`)
|
||||
}
|
||||
} catch {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return formatResponse.toolError(`The directory "${targetPath}" does not exist.`)
|
||||
}
|
||||
|
||||
// Check if we're already in this directory
|
||||
if (path.resolve(targetPath) === path.resolve(config.cwd)) {
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
return formatResponse.toolResult(`Already in directory: ${targetPath}`)
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Create message for approval
|
||||
const sharedMessageProps: ClineSayTool = {
|
||||
tool: "changeDirectory",
|
||||
path: targetPath,
|
||||
content: `Changing working directory from ${config.cwd} to: ${targetPath}`,
|
||||
operationIsLocatedInWorkspace: false,
|
||||
}
|
||||
const completeMessage = JSON.stringify(sharedMessageProps)
|
||||
|
||||
// Check auto-approval
|
||||
const autoApproveResult = config.callbacks.shouldAutoApproveTool(this.name)
|
||||
const shouldAutoApprove = typeof autoApproveResult === "boolean" ? autoApproveResult : autoApproveResult[0]
|
||||
|
||||
if (shouldAutoApprove) {
|
||||
// Auto-approve flow
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
|
||||
} else {
|
||||
// Manual approval flow
|
||||
showNotificationForApproval(
|
||||
`Cline wants to change working directory to: ${targetPath}`,
|
||||
config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
|
||||
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("tool", completeMessage, config)
|
||||
if (!didApprove) {
|
||||
return formatResponse.toolDenied()
|
||||
}
|
||||
}
|
||||
|
||||
// Run PreToolUse hook after approval but before execution
|
||||
try {
|
||||
const { ToolHookUtils } = await import("../utils/ToolHookUtils")
|
||||
await ToolHookUtils.runPreToolUseIfEnabled(config, block)
|
||||
} catch (error) {
|
||||
const { PreToolUseHookCancellationError } = await import("@core/hooks/PreToolUseHookCancellationError")
|
||||
if (error instanceof PreToolUseHookCancellationError) {
|
||||
return formatResponse.toolDenied()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Execute the directory change via the Task callback
|
||||
if (!config.callbacks.changeCwd) {
|
||||
return formatResponse.toolError(
|
||||
"Changing working directory is not supported in this environment. This feature is only available in CLI mode.",
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await config.callbacks.changeCwd(targetPath)
|
||||
} catch (error) {
|
||||
return formatResponse.toolError(`Failed to change working directory: ${(error as Error).message}`)
|
||||
}
|
||||
|
||||
return formatResponse.toolResult(
|
||||
`Successfully changed working directory to: ${targetPath}\n\nNote: All subsequent file operations, terminal commands, and path resolution will use this new directory. Checkpoints have been disabled for this task since the working directory changed.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,9 @@ export interface TaskCallbacks {
|
||||
clearActiveHookExecution: () => Promise<void>
|
||||
getActiveHookExecution: () => Promise<HookExecution | undefined>
|
||||
|
||||
// CWD change callback (CLI-only)
|
||||
changeCwd?: (newCwd: string) => Promise<void>
|
||||
|
||||
// User prompt hook callback
|
||||
runUserPromptSubmitHook: (
|
||||
userContent: ClineContent[],
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface AutoApprovalSettings {
|
||||
executeAllCommands?: boolean // Execute all commands
|
||||
useBrowser: boolean // Use browser
|
||||
useMcp: boolean // Use MCP servers
|
||||
changeDirectory?: boolean // Change working directory (CLI-only)
|
||||
}
|
||||
// Global settings
|
||||
enableNotifications: boolean // Show notifications for approval and task completion
|
||||
@@ -39,6 +40,7 @@ export const DEFAULT_AUTO_APPROVAL_SETTINGS: AutoApprovalSettings = {
|
||||
executeAllCommands: false,
|
||||
useBrowser: false,
|
||||
useMcp: true,
|
||||
changeDirectory: false,
|
||||
},
|
||||
enableNotifications: false,
|
||||
}
|
||||
|
||||
@@ -208,6 +208,7 @@ export interface ClineSayTool {
|
||||
| "webSearch"
|
||||
| "summarizeTask"
|
||||
| "useSkill"
|
||||
| "changeDirectory"
|
||||
path?: string
|
||||
diff?: string
|
||||
content?: string
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
export interface SlashCommand {
|
||||
name: string
|
||||
description?: string
|
||||
section?: "default" | "custom" | "mcp"
|
||||
cliCompatible?: boolean
|
||||
}
|
||||
import { SlashCommandInfo } from "./proto/index.cline"
|
||||
|
||||
export const BASE_SLASH_COMMANDS: SlashCommand[] = [
|
||||
export const BASE_SLASH_COMMANDS: SlashCommandInfo[] = [
|
||||
{
|
||||
name: "newtask",
|
||||
description: "Create a new task with context from the current task",
|
||||
@@ -39,16 +34,17 @@ export const BASE_SLASH_COMMANDS: SlashCommand[] = [
|
||||
]
|
||||
|
||||
// VS Code-only slash commands
|
||||
export const VSCODE_ONLY_COMMANDS: SlashCommand[] = [
|
||||
export const VSCODE_ONLY_COMMANDS: SlashCommandInfo[] = [
|
||||
{
|
||||
name: "explain-changes",
|
||||
description: "Explain code changes between git refs (PRs, commits, branches, etc.)",
|
||||
section: "default",
|
||||
cliCompatible: false,
|
||||
},
|
||||
]
|
||||
|
||||
// CLI-only slash commands (handled locally, not sent to backend)
|
||||
export const CLI_ONLY_COMMANDS: SlashCommand[] = [
|
||||
export const CLI_ONLY_COMMANDS: SlashCommandInfo[] = [
|
||||
{
|
||||
name: "help",
|
||||
description: "Learn how to use Cline CLI",
|
||||
|
||||
@@ -33,6 +33,7 @@ export enum ClineDefaultTool {
|
||||
GENERATE_EXPLANATION = "generate_explanation",
|
||||
USE_SKILL = "use_skill",
|
||||
USE_SUBAGENTS = "use_subagents",
|
||||
CHANGE_DIRECTORY = "change_directory",
|
||||
}
|
||||
|
||||
// Array of all tool names for compatibility
|
||||
|
||||
Reference in New Issue
Block a user