diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index b8ddeda82352..8cd0adc59be9 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -87,7 +87,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const navigateMessageByOffset = actions.navigateMessageByOffset const setActiveMessage = actions.setActiveMessage const focusInput = actions.focusInput - const sessionCommand = withCategory(language.t("command.category.session")) const fileCommand = withCategory(language.t("command.category.file")) const contextCommand = withCategory(language.t("command.category.context")) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-cd.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-cd.tsx new file mode 100644 index 000000000000..fa2155c147b0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-cd.tsx @@ -0,0 +1,60 @@ +import path from "path" +import os from "os" +import fs from "fs/promises" +import { createMemo, createResource } from "solid-js" +import { createStore } from "solid-js/store" +import * as fuzzysort from "fuzzysort" +import { DialogSelect } from "@tui/ui/dialog-select" +import { useDialog } from "@tui/ui/dialog" +import { useSync } from "@tui/context/sync" + +export function DialogCd(props: { onSelect?: (value: string) => void }) { + const dialog = useDialog() + const sync = useSync() + const [store, setStore] = createStore({ + filter: "", + }) + + const [dirs] = createResource( + () => store.filter, + async (filter) => { + const base = sync.data.path.directory || process.cwd() + const raw = filter.trim() + const expanded = raw.startsWith("~") ? path.join(os.homedir(), raw.slice(1)) : raw + const full = raw ? (path.isAbsolute(expanded) ? expanded : path.resolve(base, expanded)) : base + const dir = raw.endsWith("/") || raw.endsWith(path.sep) || !raw ? full : path.dirname(full) + const name = raw.endsWith("/") || raw.endsWith(path.sep) ? "" : path.basename(full) + const list = await fs.readdir(dir, { withFileTypes: true }).catch(() => []) + const items = list + .filter((x) => x.isDirectory()) + .map((x) => path.join(dir, x.name)) + + if (!name) return items.sort().slice(0, 50) + return fuzzysort.go(name, items, { limit: 50 }).map((x) => x.target) + }, + ) + + const options = createMemo(() => + (dirs() ?? []).map((dir) => ({ + value: dir, + title: path.basename(dir) || dir, + description: dir, + })), + ) + + return ( + { + setStore("filter", filter) + }} + onSelect={(option) => { + props.onSelect?.(option.value) + dialog.clear() + }} + /> + ) +} 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 d63c248fb83e..4b2fa10bdd8a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -34,6 +34,7 @@ import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +import { DialogCd } from "../dialog-cd" export type PromptProps = { sessionID?: string @@ -329,6 +330,29 @@ export function Prompt(props: PromptProps) { input.cursorOffset = Bun.stringWidth(content) }, }, + { + title: "Change directory", + value: "prompt.cd", + category: "Session", + slash: { + name: "cd", + }, + onSelect: () => { + dialog.replace(() => ( + { + input.setText(`/cd ${dir}`) + setStore("prompt", { + input: `/cd ${dir}`, + parts: [], + }) + input.gotoBufferEnd() + submit() + }} + /> + )) + }, + }, { title: "Skills", value: "prompt.skills", @@ -534,8 +558,10 @@ export function Prompt(props: PromptProps) { exit() return } + const firstLine = store.prompt.input.split("\n")[0] + const slash = firstLine.startsWith("/") ? firstLine.split(" ")[0].slice(1) : "" const selectedModel = local.model.current() - if (!selectedModel) { + if (!selectedModel && slash !== "cd") { promptModelWarning() return } @@ -576,8 +602,8 @@ export function Prompt(props: PromptProps) { sessionID, agent: local.agent.current().name, model: { - providerID: selectedModel.providerID, - modelID: selectedModel.modelID, + providerID: selectedModel!.providerID, + modelID: selectedModel!.modelID, }, command: inputText, }) @@ -585,9 +611,8 @@ export function Prompt(props: PromptProps) { } else if ( inputText.startsWith("/") && iife(() => { - const firstLine = inputText.split("\n")[0] const command = firstLine.split(" ")[0].slice(1) - return sync.data.command.some((x) => x.name === command) + return command === "cd" || sync.data.command.some((x) => x.name === command) }) ) { // Parse command from first line, preserve multi-line content in arguments @@ -602,7 +627,7 @@ export function Prompt(props: PromptProps) { command: command.slice(1), arguments: args, agent: local.agent.current().name, - model: `${selectedModel.providerID}/${selectedModel.modelID}`, + model: selectedModel ? `${selectedModel.providerID}/${selectedModel.modelID}` : undefined, messageID, variant, parts: nonTextParts diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 269ed7ae0bd1..54e2dd665e00 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -341,6 +341,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } } + + if ((event as { type: string }).type === "instance.directory.changed") { + const props = (event as unknown as { properties: { directory: string; worktree: string } }).properties + setStore("path", "directory", props.directory) + setStore("path", "worktree", props.worktree) + fullSyncedSessions.clear() + void Promise.all(Object.keys(store.message).map((sessionID) => result.session.sync(sessionID))) + void bootstrap() + } }) const exit = useExit() diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 4452d6d764aa..b6a849f8af0e 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -36,18 +36,25 @@ process.on("uncaughtException", (e) => { // Subscribe to global events and forward them via RPC GlobalBus.on("event", (event) => { Rpc.emit("global.event", event) + if (event.payload.type !== "instance.directory.changed") return + if (event.payload.properties.previousDirectory !== state.directory) return + state.directory = event.payload.properties.directory + process.chdir(state.directory) + Rpc.emit("event", event.payload as Event) + startEventStream(state.directory) }) let server: Bun.Server | undefined -const eventStream = { +const state = { + directory: process.cwd(), abort: undefined as AbortController | undefined, } const startEventStream = (directory: string) => { - if (eventStream.abort) eventStream.abort.abort() + if (state.abort) state.abort.abort() const abort = new AbortController() - eventStream.abort = abort + state.abort = abort const signal = abort.signal const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -95,16 +102,20 @@ const startEventStream = (directory: string) => { }) } -startEventStream(process.cwd()) +startEventStream(state.directory) export const rpc = { async fetch(input: { url: string; method: string; headers: Record; body?: string }) { const headers = { ...input.headers } + const url = new URL(input.url) + url.searchParams.set("directory", state.directory) + headers["x-opencode-directory"] = state.directory + headers["X-Opencode-Directory"] = state.directory const auth = getAuthorizationHeader() if (auth && !headers["authorization"] && !headers["Authorization"]) { headers["Authorization"] = auth } - const request = new Request(input.url, { + const request = new Request(url, { method: input.method, headers, body: input.body, @@ -137,7 +148,7 @@ export const rpc = { }, async shutdown() { Log.Default.info("worker shutting down") - if (eventStream.abort) eventStream.abort.abort() + if (state.abort) state.abort.abort() await Instance.disposeAll() if (server) server.stop(true) }, diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 59a896e77bc2..1c04d8ca549c 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -5,6 +5,10 @@ import { State } from "./state" import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" import { Filesystem } from "@/util/filesystem" +import { BusEvent } from "@/bus/bus-event" +import z from "zod" +import fs from "fs/promises" +import path from "path" interface Context { directory: string @@ -18,6 +22,17 @@ const disposal = { all: undefined as Promise | undefined, } +export namespace InstanceEvent { + export const DirectoryChanged = BusEvent.define( + "instance.directory.changed", + z.object({ + directory: z.string(), + worktree: z.string(), + previousDirectory: z.string(), + }), + ) +} + function emit(directory: string) { GlobalBus.emit("event", { directory, @@ -87,6 +102,43 @@ export const Instance = { get project() { return context.use().project }, + async setDirectory(input: string) { + const ctx = context.use() + const prev = ctx.directory + const next = input.startsWith("~") ? path.join(process.env.HOME ?? "", input.slice(1)) : input + const dir = path.resolve(prev, next) + const stat = await fs.stat(dir).catch(() => undefined) + if (!stat?.isDirectory()) throw new Error(`Directory not found: ${input}`) + if (dir === prev) { + return { + directory: ctx.directory, + worktree: ctx.worktree, + previousDirectory: prev, + } + } + + const { project, sandbox } = await Project.fromDirectory(dir) + await State.dispose(prev) + ctx.directory = dir + ctx.worktree = sandbox + ctx.project = project + cache.delete(prev) + track(dir, Promise.resolve(ctx)) + + const result = { + directory: dir, + worktree: sandbox, + previousDirectory: prev, + } + GlobalBus.emit("event", { + directory: dir, + payload: { + type: InstanceEvent.DirectoryChanged.type, + properties: result, + }, + }) + return result + }, /** * Check if a path is within the project boundary. * Returns true if path is inside Instance.directory OR Instance.worktree. diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b117632051f7..34c86e07f422 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -434,6 +434,32 @@ export namespace Session { }, ) + export const setDirectory = fn( + z.object({ + sessionID: Identifier.schema("session"), + directory: z.string(), + projectID: z.string(), + }), + async (input) => { + return Database.use((db) => { + const row = db + .update(SessionTable) + .set({ + directory: input.directory, + project_id: input.projectID, + time_updated: Date.now(), + }) + .where(eq(SessionTable.id, input.sessionID)) + .returning() + .get() + if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` }) + const info = fromRow(row) + Database.effect(() => Bus.publish(Event.Updated, { info })) + return info + }) + }, + ) + export const setRevert = fn( z.object({ sessionID: Identifier.schema("session"), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4f77920cc987..9aa48e888ba3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1745,6 +1745,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the export async function command(input: CommandInput) { log.info("command", input) + if (input.command === "cd") return cd(input) const command = await Command.get(input.command) const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) @@ -1887,6 +1888,69 @@ NOTE: At any point in time through this workflow you should feel free to ask the return result } + async function cd(input: CommandInput) { + const arg = input.arguments.trim() + if (!arg) throw new Error("Usage: /cd ") + + const user = await createUserMessage({ + sessionID: input.sessionID, + messageID: input.messageID, + agent: input.agent, + variant: input.variant, + parts: [ + { + type: "text", + text: `/cd ${arg}`, + }, + ], + }) + const result = await Instance.setDirectory(arg) + await Session.setDirectory({ + sessionID: input.sessionID, + directory: result.directory, + projectID: Instance.project.id, + }) + + const msg: MessageV2.Assistant = { + id: Identifier.ascending("message"), + sessionID: input.sessionID, + parentID: user.info.id, + mode: user.info.agent, + agent: user.info.agent, + cost: 0, + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + time: { + created: Date.now(), + completed: Date.now(), + }, + role: "assistant", + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: user.info.model.modelID, + providerID: user.info.model.providerID, + variant: user.info.variant, + finish: "stop", + } + await Session.updateMessage(msg) + const part = await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: msg.id, + sessionID: input.sessionID, + type: "text", + text: `Changed directory to ${result.directory}`, + }) + await Session.touch(input.sessionID) + SessionStatus.set(input.sessionID, { type: "idle" }) + return { info: msg, parts: [part] } + } + async function ensureTitle(input: { session: Session.Info history: MessageV2.WithParts[] diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts new file mode 100644 index 000000000000..ce75971bb93a --- /dev/null +++ b/packages/opencode/test/project/instance.test.ts @@ -0,0 +1,30 @@ +import path from "path" +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +describe("Instance.setDirectory", () => { + test("changes the active directory inside the current instance", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "pkg", ".gitkeep"), "") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(Instance.directory).toBe(tmp.path) + + const next = await Instance.setDirectory("pkg") + + expect(next.directory).toBe(path.join(tmp.path, "pkg")) + expect(next.worktree).toBe(tmp.path) + expect(next.previousDirectory).toBe(tmp.path) + expect(Instance.directory).toBe(path.join(tmp.path, "pkg")) + expect(Instance.worktree).toBe(tmp.path) + }, + }) + }) +}) diff --git a/packages/opencode/test/session/cd.test.ts b/packages/opencode/test/session/cd.test.ts new file mode 100644 index 000000000000..447a8aa466d5 --- /dev/null +++ b/packages/opencode/test/session/cd.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionPrompt } from "../../src/session/prompt" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("session.command /cd", () => { + test("moves the session to the new directory and stores the exchange", async () => { + await using root = await tmpdir({ + git: true, + config: { + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + await using next = await tmpdir({ git: true }) + + await Instance.provide({ + directory: root.path, + fn: async () => { + const session = await Session.create({}) + const prev = Instance.project.id + + const result = await SessionPrompt.command({ + sessionID: session.id, + command: "cd", + arguments: next.path, + agent: "build", + }) + if (result.info.role !== "assistant") throw new Error("expected assistant message") + + const info = await Session.get(session.id) + const messages = await MessageV2.get({ + sessionID: session.id, + messageID: result.info.id, + }) + + expect(info.directory).toBe(next.path) + expect(info.projectID).toBe(Instance.project.id) + expect(info.projectID).not.toBe(prev) + expect(Instance.directory).toBe(next.path) + expect(result.info.path.cwd).toBe(next.path) + expect(messages.parts.some((part) => part.type === "text" && part.text.includes(next.path))).toBe(true) + }, + }) + }) +})