Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -215,6 +217,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
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()
Expand Down Expand Up @@ -398,6 +401,15 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
),
)

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(() => [
{
Expand Down Expand Up @@ -590,6 +602,32 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
},
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",
Expand Down
36 changes: 36 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/tui/context/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}
},
Expand Down
11 changes: 9 additions & 2 deletions packages/opencode/src/cli/cmd/tui/context/sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/change-directory.ts
Original file line number Diff line number Diff line change
@@ -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<void>
showToast: (opts: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
}

export async function changeDirectory(
targetPath: string,
initialDirectory: string,
deps: ChangeDirectoryDeps,
dialog?: DialogContext,
): Promise<string | undefined> {
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
}
6 changes: 6 additions & 0 deletions packages/opencode/src/launch.ts
Original file line number Diff line number Diff line change
@@ -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")
5 changes: 3 additions & 2 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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",
})
Expand Down Expand Up @@ -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<typeof ShellInput>

Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ export const BashTool = Tool.define(
return {
...process.env,
...extra.env,
OPENCODE_SESSION_ID: ctx.sessionID,
}
})

Expand Down
59 changes: 59 additions & 0 deletions packages/opencode/src/tool/cd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import path from "path"
import os from "os"
import { Effect, Schema } from "effect"
import { AppFileSystem } from "@opencode-ai/core/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"

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* () {
const fs = yield* AppFileSystem.Service

return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, _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),
}
}),
)
13 changes: 13 additions & 0 deletions packages/opencode/src/tool/cd.txt
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CdTool } from "./cd"
import { PlanExitTool } from "./plan"
import { Session } from "../session"
import { QuestionTool } from "./question"
Expand Down Expand Up @@ -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<State>(
Expand Down Expand Up @@ -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 {
Expand All @@ -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] : []),
],
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2449,6 +2449,7 @@ export class Session2 extends HeyApiClient {
modelID: string
}
command?: string
display?: string
},
options?: Options<never, ThrowOnError>,
) {
Expand All @@ -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" },
],
},
],
Expand Down
Loading
Loading