From 32aecbf0001c68f9c401474e41ef5c766d668e5d Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 26 Aug 2026 18:19:41 +0000 Subject: [PATCH 1/2] fix(tui): preserve resolved server directory --- packages/tui/src/app.tsx | 6 +- packages/tui/src/context/data.tsx | 4 +- packages/tui/src/context/session-tabs.tsx | 2 +- packages/tui/test/app-lifecycle.test.tsx | 99 +++++++++++++++++++ .../tui/test/cli/tui/composer-keymap.test.tsx | 2 +- packages/tui/test/cli/tui/data.test.tsx | 2 +- .../test/cli/tui/dialog-integration.test.tsx | 2 +- packages/tui/test/cli/tui/dialog-mcp.test.tsx | 2 +- .../tui/test/cli/tui/dialog-open.test.tsx | 2 +- .../test/cli/tui/dialog-session-list.test.tsx | 2 +- packages/tui/test/cli/tui/form.test.tsx | 2 +- .../tui/test/context/session-tabs.test.tsx | 13 ++- 12 files changed, 120 insertions(+), 18 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 3e633056dc61..550787b8d163 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -38,7 +38,6 @@ import { TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiApp, - useTuiPaths, useTuiStartup, useTuiTerminalEnvironment, type TuiApp, @@ -375,7 +374,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { > - + @@ -460,7 +459,6 @@ function App(props: { pair?: DialogPairCredentials }) { const log = useLog({ component: "app" }) const app = useTuiApp() const startup = useTuiStartup() - const paths = useTuiPaths() const config = useConfig() const devtools = createMemo(() => config.data.debug?.devtools ?? app.channel === "local") const route = useRoute() @@ -727,7 +725,7 @@ function App(props: { pair?: DialogPairCredentials }) { type: "home", location: newSessionLocation( config.data.session.new_location, - paths.cwd, + data.location.default().directory, current, location.error?.location, ), diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index fa164735e4ef..934910407a7a 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -8,13 +8,13 @@ export type { FormWithLocation } from "@opencode-ai/client/solid" export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", - init: () => { + init: (props: { directory: string }) => { const client = useClient() const data = createData({ api: () => client.api, event: client.event, connection: client.connection, - directory: process.cwd(), + directory: props.directory, }) data satisfies Plugin.Context["data"] return data diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 84a761dcace5..47f1c84fc25c 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -417,7 +417,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp type: "home", location: newSessionLocation( config.session.new_location, - paths.cwd, + data.location.default().directory, currentLocation, location.error?.location, ), diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 0f4d881f66ec..9d8561bc1b4a 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -6,6 +6,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/util/global" import path from "node:path" import { createEventStream, createFetch, directory, json } from "./fixture/tui-client" +import { tmpdir } from "./fixture/fixture" test("SIGHUP clears title and disposes scoped resources once", async () => { const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) @@ -297,6 +298,104 @@ test("session startup prompt is submitted exactly once", async () => { } }) +test.each([false, true])("uses the resolved launch directory for new prompts (fallback: %s)", async (fallback) => { + await using state = await tmpdir() + const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true }) + setup.renderer.start() + const target = fallback ? directory : process.cwd() + const location = { directory: target, project: { id: "project", directory: target, canonical: target } } + const requests: URL[] = [] + const created = Promise.withResolvers() + const submitted = Promise.withResolvers() + const ready = Promise.withResolvers() + const events = createEventStream() + let session: unknown + const calls = createFetch(async (url, request) => { + requests.push(url) + if (url.searchParams.has("location[directory]") && url.searchParams.get("location[directory]") !== target) + return json({ message: "Directory does not exist on the server" }, { status: 500 }) + if (url.pathname === "/api/fs/list") return json({ location, data: [] }) + if (url.pathname === "/api/location") return json(location) + if (url.pathname === "/api/agent") + return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] }) + if (url.pathname === "/api/model") + return json({ location, data: [{ id: "model", providerID: "provider", name: "Remote Model", variants: [] }] }) + if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] }) + if (url.pathname === "/api/session" && request.method === "POST") { + const input: unknown = await request.json() + if (typeof input !== "object" || input === null) throw new Error("Expected a session input") + created.resolve(input) + session = { + ...input, + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + return json({ data: session }) + } + if (/^\/api\/session\/[^/]+\/prompt$/.test(url.pathname)) { + submitted.resolve(await request.json()) + return json({ data: {} }) + } + if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname)) return json({ data: [], cursor: {} }) + if (session && /^\/api\/session\/[^/]+$/.test(url.pathname)) return json({ data: session }) + return undefined + }, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { + get: async () => ({ animations: false, tabs: { enabled: false }, keybinds: { "session.new": "f6" } }), + update: async () => ({}), + }, + packages: { resolve: async () => undefined }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }), + args: {}, + log: () => {}, + }).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))), + ) + + await ready.promise + await setup.waitForFrame((frame) => frame.includes("Build ยท Remote Model Provider")) + setup.mockInput.pressKey("F6") + await setup.renderOnce() + await setup.mockInput.typeText("REMOTE_READY") + await setup.waitForFrame((frame) => frame.includes("REMOTE_READY")) + setup.mockInput.pressEnter() + expect( + await Promise.race([ + submitted.promise, + Bun.sleep(2_000).then(() => { + throw new Error("prompt was not submitted in the resolved server directory") + }), + ]), + ).toMatchObject({ text: "REMOTE_READY" }) + expect(await created.promise).toMatchObject({ location: { directory: target } }) + expect(requests[0]?.pathname).toBe("/api/fs/list") + expect(requests[0]?.searchParams.get("location[directory]")).toBe(process.cwd()) + expect( + requests.filter((url) => url.pathname === "/api/location" && !url.searchParams.has("location[directory]")), + ).toHaveLength(fallback ? 1 : 0) + expect( + requests + .slice(1) + .filter((url) => url.searchParams.has("location[directory]")) + .every((url) => url.searchParams.get("location[directory]") === target), + ).toBe(true) + setup.renderer.destroy() + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + } +}) + test("error investigations repeatedly seed editable home drafts without creating sessions", async () => { const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true }) setup.renderer.start() diff --git a/packages/tui/test/cli/tui/composer-keymap.test.tsx b/packages/tui/test/cli/tui/composer-keymap.test.tsx index 88a2381aac5b..cbec4d0bd27e 100644 --- a/packages/tui/test/cli/tui/composer-keymap.test.tsx +++ b/packages/tui/test/cli/tui/composer-keymap.test.tsx @@ -96,7 +96,7 @@ async function renderComposer( - + ({}) }}> diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 72b1cd4c7338..29bf5952becf 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -45,7 +45,7 @@ const config = createTuiResolvedConfig() function DataProvider(props: ParentProps) { return ( - + {props.children} diff --git a/packages/tui/test/cli/tui/dialog-integration.test.tsx b/packages/tui/test/cli/tui/dialog-integration.test.tsx index 88d97c07825f..82306abc8545 100644 --- a/packages/tui/test/cli/tui/dialog-integration.test.tsx +++ b/packages/tui/test/cli/tui/dialog-integration.test.tsx @@ -304,7 +304,7 @@ async function renderIntegration() { - + diff --git a/packages/tui/test/cli/tui/dialog-mcp.test.tsx b/packages/tui/test/cli/tui/dialog-mcp.test.tsx index cffd0dcfc4b4..30f598d93de5 100644 --- a/packages/tui/test/cli/tui/dialog-mcp.test.tsx +++ b/packages/tui/test/cli/tui/dialog-mcp.test.tsx @@ -142,7 +142,7 @@ async function renderMcp(options?: { failed?: boolean; location?: { directory: s - + diff --git a/packages/tui/test/cli/tui/dialog-open.test.tsx b/packages/tui/test/cli/tui/dialog-open.test.tsx index f963e8ee27f4..1aa1f0adfb30 100644 --- a/packages/tui/test/cli/tui/dialog-open.test.tsx +++ b/packages/tui/test/cli/tui/dialog-open.test.tsx @@ -318,7 +318,7 @@ async function renderOpen( - + diff --git a/packages/tui/test/cli/tui/dialog-session-list.test.tsx b/packages/tui/test/cli/tui/dialog-session-list.test.tsx index c8142906ef56..e09621dc7b92 100644 --- a/packages/tui/test/cli/tui/dialog-session-list.test.tsx +++ b/packages/tui/test/cli/tui/dialog-session-list.test.tsx @@ -90,7 +90,7 @@ test("scopes sessions to the active session location", async () => { - + diff --git a/packages/tui/test/cli/tui/form.test.tsx b/packages/tui/test/cli/tui/form.test.tsx index 0bc08b699708..f1b028948827 100644 --- a/packages/tui/test/cli/tui/form.test.tsx +++ b/packages/tui/test/cli/tui/form.test.tsx @@ -106,7 +106,7 @@ async function mountForm( - + {response ? : } diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index 280a24b9ce42..a82b78320883 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -39,6 +39,7 @@ async function renderSessionTabs( sessionTimes?: Record sessionOutcomes?: Record newLocation?: "launch" | "inherit" + launchDirectory?: string tabsEnabled?: boolean viewFailures?: number preview?: boolean @@ -168,7 +169,7 @@ async function renderSessionTabs( initialRoute={options?.home ? { type: "home" } : { type: "session", sessionID: initialSessionID }} > - + @@ -892,13 +893,17 @@ test("tracks a temporary new session tab across close and creation", async () => } }) -test("add opens the new session tab in the launch directory by default", async () => { - const setup = await renderSessionTabs("first", { sessionDirectories: { first: `${directory}/worktree` } }) +test("add opens the new session tab in the resolved server launch directory", async () => { + const launchDirectory = `${directory}/server` + const setup = await renderSessionTabs("first", { + launchDirectory, + sessionDirectories: { first: `${directory}/worktree` }, + }) try { await wait(() => setup.tabs.current() === "first" && setup.data.session.get("first") !== undefined) setup.tabs.add() - expect(setup.route.data).toEqual({ type: "home", location: { directory } }) + expect(setup.route.data).toEqual({ type: "home", location: { directory: launchDirectory } }) await wait(() => setup.tabs.newTab()) expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first"]) } finally { From d43539eb225ee3ae1317f48197c6ccee03db31ce Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 26 Aug 2026 19:14:51 +0000 Subject: [PATCH 2/2] test(tui): align startup location in model inheritance fixture --- packages/tui/test/app-lifecycle.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 9d8561bc1b4a..a1ba3f2dd4dc 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -569,6 +569,7 @@ test("new session inherits the active session model", async () => { time: { created: 0, updated: 0 }, } const calls = createFetch((url) => { + if (url.pathname === "/api/fs/list") return json({ location, data: [] }) if (url.pathname === "/api/location") return json(location) if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) if (url.pathname === "/api/session/dummy") return json({ data: session })