From 993b39c9e1043490a2a4849e5dca1b7b5516b201 Mon Sep 17 00:00:00 2001 From: yusimin Date: Sun, 19 Apr 2026 00:48:55 +0800 Subject: [PATCH 1/7] feat(tui): add /cd command for runtime working directory switching Add a /cd slash command that allows changing the working directory at runtime without restarting OpenCode. Supports relative paths, absolute paths, and ~ expansion. Shows confirmation when navigating outside the current project worktree. Records directory changes in the session as shell commands. Fixes #23358 --- packages/opencode/src/cli/cmd/tui/app.tsx | 40 +++++++++++- .../cli/cmd/tui/component/prompt/index.tsx | 36 +++++++++++ .../opencode/src/cli/cmd/tui/context/sdk.tsx | 11 +++- .../src/cli/cmd/tui/util/change-directory.ts | 62 +++++++++++++++++++ packages/opencode/src/session/prompt.ts | 3 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 1 + 7 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/change-directory.ts diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 015b0ed8f46d..064ca176f08f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -22,7 +22,7 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { ErrorComponent } from "@tui/component/error-component" import { PluginRouteMissing } from "@tui/component/plugin-route-missing" -import { ProjectProvider } from "@tui/context/project" +import { ProjectProvider, useProject } from "@tui/context/project" import { EditorContextProvider } from "@tui/context/editor" import { useEvent } from "@tui/context/event" import { SDKProvider, useSDK } from "@tui/context/sdk" @@ -60,6 +60,8 @@ import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { FormatError, FormatUnknownError } from "@/cli/error" +import { DialogPrompt } from "./ui/dialog-prompt" +import { changeDirectory } from "@tui/util/change-directory" import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" @@ -215,6 +217,7 @@ function App(props: { onSnapshot?: () => Promise }) { const themeState = useTheme() const { theme, mode, setMode, locked, lock, unlock } = themeState const sync = useSync() + const project = useProject() const exit = useExit() const promptRef = usePromptRef() const routes: RouteMap = new Map() @@ -398,6 +401,15 @@ function App(props: { onSnapshot?: () => Promise }) { ), ) + const initialDirectory = sdk.directory ?? process.cwd() + const cdDeps = { + getCurrentDirectory: () => sdk.directory ?? process.cwd(), + getWorktree: () => project.instance.path().worktree, + setDirectory: (dir: string) => sdk.setDirectory(dir), + syncProject: () => project.sync(), + showToast: toast.show, + } + const connected = useConnected() command.register(() => [ { @@ -590,6 +602,32 @@ function App(props: { onSnapshot?: () => Promise }) { }, category: "System", }, + { + title: "Change directory", + value: "directory.cd", + slash: { + name: "cd", + aliases: ["chdir"], + }, + description: "Change working directory", + onSelect: async (dlg) => { + const cdInput = await DialogPrompt.show(dlg, "Change Directory", { + placeholder: "Enter path (relative, absolute, or ~)", + }) + if (!cdInput) return + const resolved = await changeDirectory(cdInput, initialDirectory, cdDeps, dlg) + if (resolved && route.data.type === "session") { + const currentAgent = local.agent.current() + void sdk.client.session.shell({ + sessionID: route.data.sessionID, + agent: currentAgent?.name ?? "code", + command: "pwd", + display: `cd ${cdInput}`, + }) + } + }, + category: "System", + }, { title: "Switch theme", value: "theme.switch", diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 5288a819b3c9..6abdee131227 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -35,6 +35,7 @@ import { createColors, createFrames } from "../../ui/spinner.ts" import { useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" +import { changeDirectory } from "@tui/util/change-directory" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" @@ -651,6 +652,15 @@ export function Prompt(props: PromptProps) { }, ]) + const initialDirectory = sdk.directory ?? process.cwd() + const cdDeps = { + getCurrentDirectory: () => sdk.directory ?? process.cwd(), + getWorktree: () => project.instance.path().worktree, + setDirectory: (dir: string) => sdk.setDirectory(dir), + syncProject: () => project.sync(), + showToast: toast.show, + } + async function submit() { // IME: double-defer may fire before onContentChange flushes the last // composed character (e.g. Korean hangul) to the store, so read @@ -662,6 +672,32 @@ export function Prompt(props: PromptProps) { if (props.disabled) return false if (autocomplete?.visible) return false if (!store.prompt.input) return false + + // Intercept built-in /cd command early (no model/agent needed) + if (store.prompt.input.match(/^\/(?:cd|chdir)(?:\s|$)/)) { + const cdArg = store.prompt.input.replace(/^\/(?:cd|chdir)\s*/, "").trim() + history.append({ ...store.prompt, mode: store.mode }) + input.extmarks.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + props.onSubmit?.() + input.clear() + + // Run cd then record the result as a shell entry in the session + const resolved = await changeDirectory(cdArg, initialDirectory, cdDeps, dialog) + if (resolved && props.sessionID) { + const currentAgent = local.agent.current() + // display shows "cd .." in the session, command executes "pwd" to produce the new path as output + void sdk.client.session.shell({ + sessionID: props.sessionID, + agent: currentAgent?.name ?? "code", + command: "pwd", + display: `cd ${cdArg || "~"}`, + }) + } + return true + } + const agent = local.agent.current() if (!agent) return false const trimmed = store.prompt.input.trim() diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 96fa54487581..1fe9a21fe8e6 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -25,12 +25,13 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ return createOpencodeClient({ baseUrl: props.url, signal: abort.signal, - directory: props.directory, + directory: currentDirectory, fetch: props.fetch, headers: props.headers, }) } + let currentDirectory = props.directory let sdk = createSDK() const emitter = createGlobalEmitter<{ @@ -133,7 +134,13 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ get client() { return sdk }, - directory: props.directory, + get directory() { + return currentDirectory + }, + setDirectory(dir: string) { + currentDirectory = dir + sdk = createSDK() + }, event: emitter, fetch: props.fetch ?? fetch, url: props.url, diff --git a/packages/opencode/src/cli/cmd/tui/util/change-directory.ts b/packages/opencode/src/cli/cmd/tui/util/change-directory.ts new file mode 100644 index 000000000000..7b06e8aa0467 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/change-directory.ts @@ -0,0 +1,62 @@ +import path from "path" +import os from "os" +import fs from "fs/promises" +import type { DialogContext } from "@tui/ui/dialog" +import { DialogConfirm } from "@tui/ui/dialog-confirm" + +export interface ChangeDirectoryDeps { + getCurrentDirectory: () => string + getWorktree: () => string + setDirectory: (dir: string) => void + syncProject: () => Promise + showToast: (opts: { message: string; variant: "info" | "success" | "warning" | "error" }) => void +} + +export async function changeDirectory( + targetPath: string, + initialDirectory: string, + deps: ChangeDirectoryDeps, + dialog?: DialogContext, +): Promise { + const trimmed = targetPath.trim() + if (!trimmed) return changeDirectory(initialDirectory, initialDirectory, deps, dialog) + + const expanded = trimmed.startsWith("~") ? path.join(os.homedir(), trimmed.slice(1)) : trimmed + const resolved = path.isAbsolute(expanded) + ? path.resolve(expanded) + : path.resolve(deps.getCurrentDirectory(), expanded) + + const stat = await fs.stat(resolved).catch(() => null) + if (!stat?.isDirectory()) { + deps.showToast({ message: `Directory not found: ${resolved}`, variant: "error" }) + dialog?.clear() + return + } + + const worktree = deps.getWorktree() + if (worktree && worktree !== "/" && !resolved.startsWith(worktree)) { + if (!dialog) return + const confirmed = await DialogConfirm.show( + dialog, + "Change Directory", + `Target is outside the current project.\n${resolved}\nContinue?`, + ) + if (confirmed !== true) return + } + + const ok = await Promise.resolve() + .then(() => process.chdir(resolved)) + .then(() => true) + .catch(() => false) + if (!ok) { + deps.showToast({ message: `Failed to change directory to: ${resolved}`, variant: "error" }) + dialog?.clear() + return + } + + deps.setDirectory(resolved) + await deps.syncProject() + deps.showToast({ message: `Changed directory to ${resolved}`, variant: "success" }) + dialog?.clear() + return resolved +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 600eb42f795e..e00c8e1c3521 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -778,7 +778,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the state: { status: "running", time: { start: Date.now() }, - input: { command: input.command }, + input: { command: input.display ?? input.command }, }, } yield* sessions.updatePart(part) @@ -1758,6 +1758,7 @@ export const ShellInput = Schema.Struct({ agent: Schema.String, model: Schema.optional(ModelRef), command: Schema.String, + display: Schema.optional(Schema.String), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type ShellInput = Schema.Schema.Type diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6248eb8e4d64..45c3cfec0b76 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -2449,6 +2449,7 @@ export class Session2 extends HeyApiClient { modelID: string } command?: string + display?: string }, options?: Options, ) { @@ -2464,6 +2465,7 @@ export class Session2 extends HeyApiClient { { in: "body", key: "agent" }, { in: "body", key: "model" }, { in: "body", key: "command" }, + { in: "body", key: "display" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 40e661b46a2d..523b1595d714 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4101,6 +4101,7 @@ export type SessionShellData = { modelID: string } command: string + display?: string } path: { sessionID: string From 43840f4d237b164df3133d466a7e5ceaa35a2f38 Mon Sep 17 00:00:00 2001 From: yusimin Date: Wed, 22 Apr 2026 10:05:22 +0800 Subject: [PATCH 2/7] fix: use dynamic import in launch.ts to ensure chdir runs before module load Static ESM imports are hoisted above all executable code, so process.chdir() was never running before index.ts loaded. This caused opencode to always use the source directory as cwd instead of the user's actual working directory. Switch to await import() and also set process.env.PWD so thread.ts resolves the project path correctly. --- packages/opencode/src/launch.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 packages/opencode/src/launch.ts diff --git a/packages/opencode/src/launch.ts b/packages/opencode/src/launch.ts new file mode 100644 index 000000000000..f901847d467e --- /dev/null +++ b/packages/opencode/src/launch.ts @@ -0,0 +1,6 @@ +if (process.env.OPENCODE_REAL_CWD) { + process.chdir(process.env.OPENCODE_REAL_CWD) + process.env.PWD = process.env.OPENCODE_REAL_CWD + delete process.env.OPENCODE_REAL_CWD +} +await import("./index.ts") From b2c71561e33f5e5b48e9ce8496ded813c26f8fd7 Mon Sep 17 00:00:00 2001 From: yusimin Date: Sun, 26 Apr 2026 13:31:58 +0800 Subject: [PATCH 3/7] feat(tool): add cd tool for AI agent working directory switching Add a server-side cd tool that allows AI agents to change the working directory via tool calls. This enables skills like /gwt to switch into worktree directories programmatically. The tool calls process.chdir() and Instance.reload() which disposes the old instance (LSP, file watchers, etc.), creates a new instance context, and emits server.instance.disposed to notify the TUI to refresh. --- packages/opencode/src/tool/cd.ts | 55 ++++++++++++++++++++++++++ packages/opencode/src/tool/cd.txt | 13 ++++++ packages/opencode/src/tool/registry.ts | 4 ++ 3 files changed, 72 insertions(+) create mode 100644 packages/opencode/src/tool/cd.ts create mode 100644 packages/opencode/src/tool/cd.txt diff --git a/packages/opencode/src/tool/cd.ts b/packages/opencode/src/tool/cd.ts new file mode 100644 index 000000000000..db9dada9727a --- /dev/null +++ b/packages/opencode/src/tool/cd.ts @@ -0,0 +1,55 @@ +import path from "path" +import os from "os" +import z from "zod" +import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Instance } from "../project/instance" +import { InstanceBootstrap } from "../project/bootstrap" +import { AppRuntime } from "../effect/app-runtime" +import DESCRIPTION from "./cd.txt" +import * as Tool from "./tool" + +export const CdTool = Tool.define( + "cd", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + + return { + description: DESCRIPTION, + parameters: z.object({ + path: z.string().describe("The target directory path to change to. Supports absolute paths, relative paths, and ~ for home directory."), + }), + execute: (params: { path: string }, _ctx: Tool.Context) => + Effect.gen(function* () { + const trimmed = params.path.trim() + if (!trimmed) throw new Error("path parameter is required") + + const expanded = trimmed.startsWith("~") ? path.join(os.homedir(), trimmed.slice(1)) : trimmed + const resolved = path.isAbsolute(expanded) + ? path.resolve(expanded) + : path.resolve(Instance.directory, expanded) + + const stat = yield* fs.stat(resolved).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!stat || stat.type !== "Directory") throw new Error(`directory not found: ${resolved}`) + + const previousDirectory = Instance.directory + process.chdir(resolved) + yield* Effect.promise(() => + Instance.reload({ + directory: resolved, + init: () => AppRuntime.runPromise(InstanceBootstrap), + }), + ) + + return { + title: path.basename(resolved), + metadata: { + previousDirectory, + newDirectory: resolved, + }, + output: `Changed working directory to: ${resolved}`, + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/cd.txt b/packages/opencode/src/tool/cd.txt new file mode 100644 index 000000000000..17a306f5184a --- /dev/null +++ b/packages/opencode/src/tool/cd.txt @@ -0,0 +1,13 @@ +Change the working directory of the current OpenCode session. + +This tool changes the process working directory and reinitializes the instance context (project info, LSP, file watchers, etc.) for the new directory. After calling this tool, all subsequent tool calls (Bash, Read, Write, Edit, Glob, Grep, etc.) will resolve relative paths based on the new directory. + +Usage: +- Use this tool when you need to switch to a different project directory or a git worktree. +- Supports absolute paths, relative paths (resolved from current directory), and ~ for home directory. +- The directory must exist. +- After switching, the TUI will automatically refresh to reflect the new directory context. + +IMPORTANT: +- This tool changes global session state. Use it intentionally, not casually. +- All existing LSP connections, file watchers, and other instance-level services will be reinitialized for the new directory. diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 4e3d3d714b96..a5aa83e6f886 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,3 +1,4 @@ +import { CdTool } from "./cd" import { PlanExitTool } from "./plan" import { Session } from "../session" import { QuestionTool } from "./question" @@ -115,6 +116,7 @@ export const layer: Layer.Layer< const greptool = yield* GrepTool const patchtool = yield* ApplyPatchTool const skilltool = yield* SkillTool + const cdtool = yield* CdTool const agent = yield* Agent.Service const state = yield* InstanceState.make( @@ -204,6 +206,7 @@ export const layer: Layer.Layer< question: Tool.init(question), lsp: Tool.init(lsptool), plan: Tool.init(plan), + cd: Tool.init(cdtool), }) return { @@ -224,6 +227,7 @@ export const layer: Layer.Layer< tool.code, tool.skill, tool.patch, + tool.cd, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []), ], From 1d26fef0424394f63cd05471ac86fac23e6e7adc Mon Sep 17 00:00:00 2001 From: yusimin Date: Sun, 26 Apr 2026 14:02:29 +0800 Subject: [PATCH 4/7] feat: expose OPENCODE_SESSION_ID environment variable Inject OPENCODE_SESSION_ID into bash tool subprocesses via ctx.sessionID, and sync it to process.env on route navigation so the main process also reflects the current session. --- packages/opencode/src/cli/cmd/tui/context/route.tsx | 1 + packages/opencode/src/tool/bash.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx index 35be17801b1f..ab853f889bdd 100644 --- a/packages/opencode/src/cli/cmd/tui/context/route.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -39,6 +39,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({ }, navigate(route: Route) { setStore(reconcile(route)) + process.env.OPENCODE_SESSION_ID = route.type === "session" ? route.sessionID : undefined }, } }, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index a27d7c5ecb70..ce2b097b05df 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -402,6 +402,7 @@ export const BashTool = Tool.define( return { ...process.env, ...extra.env, + OPENCODE_SESSION_ID: ctx.sessionID, } }) From d77106788ba5c57210a58a7cbd8030b0015392f3 Mon Sep 17 00:00:00 2001 From: yusimin Date: Sun, 26 Apr 2026 14:31:53 +0800 Subject: [PATCH 5/7] fix: correct AppFileSystem import path in cd tool --- packages/opencode/src/tool/cd.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/cd.ts b/packages/opencode/src/tool/cd.ts index db9dada9727a..98d172cdea79 100644 --- a/packages/opencode/src/tool/cd.ts +++ b/packages/opencode/src/tool/cd.ts @@ -2,7 +2,7 @@ import path from "path" import os from "os" import z from "zod" import { Effect } from "effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Instance } from "../project/instance" import { InstanceBootstrap } from "../project/bootstrap" import { AppRuntime } from "../effect/app-runtime" From 4a489210c8daa6406ddd35518a0d66c0d92e6ffd Mon Sep 17 00:00:00 2001 From: yusimin Date: Sun, 26 Apr 2026 14:48:06 +0800 Subject: [PATCH 6/7] fix(tool): use Effect Schema instead of zod in cd tool The cd tool used z.object() (zod) for its parameters schema, but all OpenCode tools require Effect Schema (Schema.Struct). When tool.ts calls Schema.decodeUnknownEffect() on a zod schema, the AST is undefined, causing 'TypeError: undefined is not an object (evaluating ast.context)' on every prompt submission. --- packages/opencode/src/tool/cd.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/tool/cd.ts b/packages/opencode/src/tool/cd.ts index 98d172cdea79..60b029ac5a3a 100644 --- a/packages/opencode/src/tool/cd.ts +++ b/packages/opencode/src/tool/cd.ts @@ -1,7 +1,6 @@ import path from "path" import os from "os" -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Instance } from "../project/instance" import { InstanceBootstrap } from "../project/bootstrap" @@ -9,6 +8,13 @@ import { AppRuntime } from "../effect/app-runtime" import DESCRIPTION from "./cd.txt" import * as Tool from "./tool" +const Parameters = Schema.Struct({ + path: Schema.String.annotate({ + description: + "The target directory path to change to. Supports absolute paths, relative paths, and ~ for home directory.", + }), +}) + export const CdTool = Tool.define( "cd", Effect.gen(function* () { @@ -16,10 +22,8 @@ export const CdTool = Tool.define( return { description: DESCRIPTION, - parameters: z.object({ - path: z.string().describe("The target directory path to change to. Supports absolute paths, relative paths, and ~ for home directory."), - }), - execute: (params: { path: string }, _ctx: Tool.Context) => + parameters: Parameters, + execute: (params: Schema.Schema.Type, _ctx: Tool.Context) => Effect.gen(function* () { const trimmed = params.path.trim() if (!trimmed) throw new Error("path parameter is required") From 23932517a681e630c5a7c9b941f8babedd9b492c Mon Sep 17 00:00:00 2001 From: yusimin Date: Sun, 26 Apr 2026 15:05:03 +0800 Subject: [PATCH 7/7] fix: inject OPENCODE_SESSION_ID in shell execution env --- packages/opencode/src/session/prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e00c8e1c3521..7fccac7da445 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -835,7 +835,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const cmd = ChildProcess.make(sh, args, { cwd, extendEnv: true, - env: { ...shellEnv.env, TERM: "dumb" }, + env: { ...shellEnv.env, TERM: "dumb", OPENCODE_SESSION_ID: input.sessionID }, stdin: "ignore", forceKillAfter: "3 seconds", })