summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLuke Parker <[email protected]>2026-03-09 07:57:48 +1000
committerGitHub <[email protected]>2026-03-09 07:57:48 +1000
commite51ed460a6d5392c67485a829e2b1991ec50dbe8 (patch)
tree699da89c0cf63a8fd1ffcfaed0ebfcc06e4a25a2
parentd15c2ce349f7c73aad25138de27df61ebe9bc634 (diff)
downloadopencode-e51ed460a6d5392c67485a829e2b1991ec50dbe8.tar.gz
opencode-e51ed460a6d5392c67485a829e2b1991ec50dbe8.zip
fix(tui): canonicalize cwd after chdir (#16641)
-rw-r--r--packages/opencode/src/cli/cmd/tui/thread.ts12
-rw-r--r--packages/opencode/test/cli/tui/thread.test.ts157
2 files changed, 164 insertions, 5 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index fea32a2b2..14a9c8873 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -110,18 +110,20 @@ export const TuiThreadCommand = cmd({
return
}
- // Resolve relative paths against PWD to preserve behavior when using --cwd flag
+ // Resolve relative --project paths from PWD, then use the real cwd after
+ // chdir so the thread and worker share the same directory key.
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
- const cwd = args.project
+ const next = args.project
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
- : root
+ : Filesystem.resolve(process.cwd())
const file = await target()
try {
- process.chdir(cwd)
+ process.chdir(next)
} catch {
- UI.error("Failed to change directory to " + cwd)
+ UI.error("Failed to change directory to " + next)
return
}
+ const cwd = Filesystem.resolve(process.cwd())
const worker = new Worker(file, {
env: Object.fromEntries(
diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts
new file mode 100644
index 000000000..d3de7c318
--- /dev/null
+++ b/packages/opencode/test/cli/tui/thread.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, mock, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { tmpdir } from "../../fixture/fixture"
+
+const stop = new Error("stop")
+const seen = {
+ tui: [] as string[],
+ inst: [] as string[],
+}
+
+mock.module("../../../src/cli/cmd/tui/app", () => ({
+ tui: async (input: { directory: string }) => {
+ seen.tui.push(input.directory)
+ throw stop
+ },
+}))
+
+mock.module("@/util/rpc", () => ({
+ Rpc: {
+ client: () => ({
+ call: async () => ({ url: "http://127.0.0.1" }),
+ on: () => {},
+ }),
+ },
+}))
+
+mock.module("@/cli/ui", () => ({
+ UI: {
+ error: () => {},
+ },
+}))
+
+mock.module("@/util/log", () => ({
+ Log: {
+ init: async () => {},
+ create: () => ({
+ error: () => {},
+ info: () => {},
+ warn: () => {},
+ debug: () => {},
+ time: () => ({ stop: () => {} }),
+ }),
+ Default: {
+ error: () => {},
+ info: () => {},
+ warn: () => {},
+ debug: () => {},
+ },
+ },
+}))
+
+mock.module("@/util/timeout", () => ({
+ withTimeout: <T>(input: Promise<T>) => input,
+}))
+
+mock.module("@/cli/network", () => ({
+ withNetworkOptions: <T>(input: T) => input,
+ resolveNetworkOptions: async () => ({
+ mdns: false,
+ port: 0,
+ hostname: "127.0.0.1",
+ }),
+}))
+
+mock.module("../../../src/cli/cmd/tui/win32", () => ({
+ win32DisableProcessedInput: () => {},
+ win32InstallCtrlCGuard: () => undefined,
+}))
+
+mock.module("@/config/tui", () => ({
+ TuiConfig: {
+ get: () => ({}),
+ },
+}))
+
+mock.module("@/project/instance", () => ({
+ Instance: {
+ provide: async (input: { directory: string; fn: () => Promise<unknown> | unknown }) => {
+ seen.inst.push(input.directory)
+ return input.fn()
+ },
+ },
+}))
+
+describe("tui thread", () => {
+ async function call(project?: string) {
+ const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread")
+ const args: Parameters<NonNullable<typeof TuiThreadCommand.handler>>[0] = {
+ _: [],
+ $0: "opencode",
+ project,
+ prompt: "hi",
+ model: undefined,
+ agent: undefined,
+ session: undefined,
+ continue: false,
+ fork: false,
+ port: 0,
+ hostname: "127.0.0.1",
+ mdns: false,
+ "mdns-domain": "opencode.local",
+ mdnsDomain: "opencode.local",
+ cors: [],
+ }
+ return TuiThreadCommand.handler(args)
+ }
+
+ async function check(project?: string) {
+ await using tmp = await tmpdir({ git: true })
+ const cwd = process.cwd()
+ const pwd = process.env.PWD
+ const worker = globalThis.Worker
+ const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
+ const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
+ const type = process.platform === "win32" ? "junction" : "dir"
+ seen.tui.length = 0
+ seen.inst.length = 0
+ await fs.symlink(tmp.path, link, type)
+
+ Object.defineProperty(process.stdin, "isTTY", {
+ configurable: true,
+ value: true,
+ })
+ globalThis.Worker = class extends EventTarget {
+ onerror = null
+ onmessage = null
+ onmessageerror = null
+ postMessage() {}
+ terminate() {}
+ } as unknown as typeof Worker
+
+ try {
+ process.chdir(tmp.path)
+ process.env.PWD = link
+ await expect(call(project)).rejects.toBe(stop)
+ expect(seen.inst[0]).toBe(tmp.path)
+ expect(seen.tui[0]).toBe(tmp.path)
+ } finally {
+ process.chdir(cwd)
+ if (pwd === undefined) delete process.env.PWD
+ else process.env.PWD = pwd
+ if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
+ else delete (process.stdin as { isTTY?: boolean }).isTTY
+ globalThis.Worker = worker
+ await fs.rm(link, { recursive: true, force: true }).catch(() => undefined)
+ }
+ }
+
+ test("uses the real cwd when PWD points at a symlink", async () => {
+ await check()
+ })
+
+ test("uses the real cwd after resolving a relative project from PWD", async () => {
+ await check(".")
+ })
+})