Repository navigation
Closed
Description
Feature hasn't been suggested before.
- I have verified this feature I'm about to request hasn't been suggested before.
Describe the enhancement you want to request
Add a /cd TUI slash command and a change_directory agent tool that lets users and agents switch the session working directory without restarting. This supports worktree-based workflows where you move between git worktrees of the same project.
TUI command (/cd): Opens a dialog, resolves relative paths against the current directory, calls moveSession, sends a synthetic system-reminder to the session, refreshes VCS state, and shows a toast.
Agent tool (change_directory): Resolves relative paths against the instance directory (not process.cwd()), calls MoveSession.Service.moveSession, and returns success/error output.
Both use the existing experimental.controlPlane.moveSession API.
Reference implementation — TUI command
// packages/tui/src/feature-plugins/home/change-directory.tsx
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createSignal, Show } from "solid-js"
import { reconcile } from "solid-js/store"
import { useSDK } from "../../context/sdk"
import { useSync } from "../../context/sync"
import { useToast } from "../../ui/toast"
import { useDialog } from "../../ui/dialog"
import { errorMessage } from "../../util/error"
import { useProject } from "../../context/project"
import { useRoute } from "../../context/route"
import path from "path"
function ChangeDirectoryDialog(props: { api: TuiPluginApi }) {
const sdk = useSDK()
const sync = useSync()
const toast = useToast()
const dialog = useDialog()
const project = useProject()
const route = useRoute()
const [input, setInput] = createSignal("")
const [busy, setBusy] = createSignal(false)
const currentDir = () => project.instance.directory() ?? ""
async function submit() {
const target = input().trim()
if (!target) return
const sessionID = route.data.type === "session" ? route.data.sessionID : undefined
if (!sessionID) {
toast.show({ title: "No active session", message: "Open a session before changing directory", variant: "error" })
return
}
const resolved = path.isAbsolute(target) ? target : path.resolve(currentDir(), target)
setBusy(true)
try {
await sdk.client.experimental.controlPlane.moveSession(
{ sessionID, destination: { directory: resolved }, moveChanges: false },
{ throwOnError: true },
)
// Non-critical: the move succeeded; a failed reminder doesn't warrant a user-facing error
await sdk.client.session.promptAsync({
sessionID, directory: resolved, noReply: true,
parts: [{ type: "text", text: `<system-reminder>The user has changed the working directory to "${target}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`, synthetic: true }],
}).catch(() => undefined)
const res = await sdk.client.vcs.get({ workspace: project.workspace.current() })
sync.set("vcs", reconcile(res.data))
toast.show({ title: "Changed directory", message: resolved, variant: "success" })
dialog.clear()
} catch (err) {
toast.show({ title: "Failed to change directory", message: errorMessage(err), variant: "error" })
} finally {
setBusy(false)
}
}
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={props.api.theme.current.text}>Change working directory</text>
<text fg={props.api.theme.current.textMuted}>Current: {currentDir()}</text>
<box flexDirection="row" gap={1}>
<text fg={props.api.theme.current.text}>{">"}</text>
<input value={input()} onInput={(value) => setInput(value)} onSubmit={() => void submit()} placeholder="/path/to/worktree" />
</box>
<box flexDirection="row" gap={2}>
<Show when={!busy()}><text fg={props.api.theme.current.accent}>Enter to confirm</text></Show>
<Show when={busy()}><text fg={props.api.theme.current.textMuted}>Moving...</text></Show>
<text fg={props.api.theme.current.textMuted}>Esc to cancel</text>
</box>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.keymap.registerLayer({
commands: [{
name: "cd.open", title: "Change directory", slashName: "cd",
category: "Session", namespace: "palette",
run() { api.ui.dialog.replace(() => <ChangeDirectoryDialog api={api} />) },
}],
})
}
const plugin: BuiltinTuiPlugin = { id: "change-directory", tui }
export default pluginReference implementation — Agent tool
// packages/opencode/src/tool/change-directory.ts
import path from "path"
import { Effect, Schema } from "effect"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { SessionV2 } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Tool } from "./tool"
import { InstanceState } from "@/effect/instance-state"
import DESCRIPTION from "./change-directory.txt"
export const Parameters = Schema.Struct({
directory: Schema.String.annotate({
description: "The directory to switch to (absolute or relative to the current working directory)",
}),
moveChanges: Schema.optional(Schema.Boolean).annotate({
description: "Whether to move uncommitted changes to the destination directory. Defaults to false.",
}),
})
export const ChangeDirectoryTool = Tool.define<typeof Parameters, { directory: string }, MoveSession.Service>(
"change_directory",
Effect.gen(function* () {
const moveSession = yield* MoveSession.Service
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params, ctx) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "change_directory",
patterns: [params.directory],
always: [params.directory],
metadata: { directory: params.directory },
})
const instance = yield* InstanceState.context
const directory = AbsolutePath.make(
path.isAbsolute(params.directory)
? params.directory
: path.resolve(instance.directory, params.directory),
)
yield* moveSession
.moveSession({ sessionID: ctx.sessionID, destination: { directory }, moveChanges: params.moveChanges ?? false })
.pipe(Effect.mapError((error) => new Error(describeError(error))))
return {
title: `Changed directory to ${directory}`,
output: `Successfully changed working directory to ${directory}`,
metadata: { directory },
}
}).pipe(Effect.orDie),
}
}),
)
function describeError(error: MoveSession.Error): string {
if (error instanceof SessionV2.NotFoundError) return `Session not found: ${error.sessionID}`
if (error instanceof MoveSession.DestinationProjectMismatchError) return "Destination directory belongs to another project"
if (error instanceof MoveSession.CaptureChangesError) return `Unable to capture changes in the source directory: ${error.message}`
if (error instanceof MoveSession.ApplyChangesError) return `Unable to apply changes in the destination directory: ${error.message}`
return `Unable to reset source changes in ${error.directory}: ${error.message}`
}Reference implementation — Tool description text
Change the session's working directory. Use this when you need to work in a different directory, such as a different worktree or project subdirectory.
The directory can be an absolute path or relative to the current working directory. It must belong to the same project. Set moveChanges to true to transfer uncommitted changes to the destination directory.
Metadata
Metadata
Assignees
Labels
No labels
Activity
github-actions commentedon Aug 18, 2026
This issue might be a duplicate of existing issues. Please check:
experimental.controlPlane.moveSessionAPI you reference. Your/cdcommand appears to be a more user-friendly wrapper around this existing functionality. It would be worth clarifying how your proposal differs from or extends the existing move-session command.cd— overlapping motivation around directory switching in the TUI.AndyS77 commentedon Aug 18, 2026
Thanks for the pointers.
#35306 — That issue is about the description text of the existing "Move session" dialog being unclear. Our
/cdis a different UX: a lightweight slash command with inline path input, not the full directory-picker dialog. Both use the samemoveSessionAPI, but/cdis for quick switching (e.g. between worktrees) without navigating a list. Thechange_directoryagent tool is a separate addition that doesn't exist today — agents currently have no way to switch directories at all.#38456 — That issue is about
!cd(shell command) not updating the statusline. Our feature callsmoveSessiondirectly (which updates session state server-side) and refreshes VCS state, so the statusline reflects the new directory. Different problem, different mechanism.Happy to adjust scope or split the PR if the maintainers prefer to review the TUI command and the agent tool separately.
AndyS77 commentedon Aug 18, 2026
Adding the test files we wrote for verification.
Agent tool tests — packages/opencode/test/tool/change-directory.test.ts
TUI command tests — packages/tui/src/feature-plugins/home/change-directory.test.tsx
AndyS77 commentedon Aug 18, 2026
PR: #43238
fobiat commentedon Sep 17, 2026
Design review request, following #43238 rather than opening a competing implementation:
Current dev has
DialogMoveSessionandpackages/core/src/control-plane/move-session.ts. The latter checks project identity and publishesSessionEvent.Moved; it does not itself serialize the move against an active drain.CONTEXT.mdsays the destination gets a new context epoch and Location-scoped services on the next drain.Proposed contract:
/cdopens the existing move picker;/cd <path>is a shortcut if command parsing supports arguments. Resolve paths on the server against the session's durable location, validate directories/permissions and canonical symlinks, then use the move service withmoveChanges: false. Preserve identity/history and reject different projects clearly. Transferring changes remains an explicit separate action.The agent tool needs a permission-checked, deferred relocation at a safe boundary after in-flight tools settle. Calling move directly inside a tool risks continuing the same drain with source-location services. The context-epoch mechanism should record the destination truthfully; a synthetic prompt claiming arbitrary directories share a project is insufficient.
Could the core team approve the command/tool contract and choose reject-while-busy versus deferred relocation? Verification should include a real tool after movement, concurrent sessions, project mismatch, missing/file/inaccessible targets, symlinks, spaces and no-op moves. I will wait for approval before implementation.
neriousy commentedon Oct 19, 2026
This is implemented in v2:
/cd <path>changes the session working directory, and agents can useopencode.session_moveto move at the next safe boundary. Upgrade withnpm i -g @opencode/cli@latest. If you still see this on v2, please open a new issue with the details.