Compare commits

...

4 Commits

Author SHA1 Message Date
Max Paulus 🥪 dd3056e519 fix clinerules detection when using the change_directory tool 2026-02-23 21:44:30 -08:00
Max Paulus 🥪 a702dac0ee refactor chatview to use useFilteredSlashCommand hook 2026-02-23 21:44:29 -08:00
Max Paulus 🥪 740a557dc1 clean up autoapprove and tool executor 2026-02-23 21:44:29 -08:00
Max Paulus 🥪 5d9de9e837 add a "change_directory" tool to cline for CLI usage only
- this tool changes the working directory for the current task
2026-02-23 21:44:29 -08:00
28 changed files with 423 additions and 128 deletions
+44 -45
View File
@@ -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}
/>
+82
View File
@@ -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,
}
}
+1 -72
View File
@@ -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
*/
+1
View File
@@ -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,
+38 -1
View File
@@ -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}`)
}
}
+13
View File
@@ -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
View File
@@ -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(),
}
/**
+4
View File
@@ -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.`,
)
}
}
+3
View File
@@ -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[],
+2
View File
@@ -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,
}
+1
View File
@@ -208,6 +208,7 @@ export interface ClineSayTool {
| "webSearch"
| "summarizeTask"
| "useSkill"
| "changeDirectory"
path?: string
diff?: string
content?: string
+5 -9
View File
@@ -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",
+1
View File
@@ -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