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: (input: Promise) => input, +})) + +mock.module("@/cli/network", () => ({ + withNetworkOptions: (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 }) => { + 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>[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(".") + }) +})