Skip to content
Merged
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
6 changes: 2 additions & 4 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ import {
TuiStartupProvider,
TuiTerminalEnvironmentProvider,
useTuiApp,
useTuiPaths,
useTuiStartup,
useTuiTerminalEnvironment,
type TuiApp,
Expand Down Expand Up @@ -375,7 +374,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
>
<ClientProvider api={api} url={input.server.endpoint.url} service={service}>
<PermissionProvider>
<DataProvider>
<DataProvider directory={directory}>
<LocationProvider>
<SessionTabsProvider>
<SessionTerminalsProvider>
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
),
Expand Down
4 changes: 2 additions & 2 deletions packages/tui/src/context/data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/src/context/session-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down
100 changes: 100 additions & 0 deletions packages/tui/test/app-lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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<unknown>()
const submitted = Promise.withResolvers<unknown>()
const ready = Promise.withResolvers<void>()
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()
Expand Down Expand Up @@ -470,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 })
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/test/cli/tui/composer-keymap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ async function renderComposer(
<ConfigProvider config={createTuiResolvedConfig({ keybinds })}>
<Keymap.Provider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<DataProvider directory={process.cwd()}>
<LocationProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/test/cli/tui/data.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const config = createTuiResolvedConfig()
function DataProvider(props: ParentProps) {
return (
<ConfigProvider config={config}>
<DataProviderBase>
<DataProviderBase directory={process.cwd()}>
<LocationProvider>
<SyncLocation />
{props.children}
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/test/cli/tui/dialog-integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ async function renderIntegration() {
<Keymap.Provider>
<ToastProvider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<DataProvider directory={process.cwd()}>
<LocationProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<DialogProvider>
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/test/cli/tui/dialog-mcp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ async function renderMcp(options?: { failed?: boolean; location?: { directory: s
<ToastProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_existing" }}>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<DataProvider directory={process.cwd()}>
<LocationProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<DialogProvider>
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/test/cli/tui/dialog-open.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ async function renderOpen(
<ToastProvider>
<RouteProvider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<DataProvider directory={process.cwd()}>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/test/cli/tui/dialog-session-list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ test("scopes sessions to the active session location", async () => {
<RouteProvider>
<ClientProvider api={createApi(calls.fetch)}>
<PermissionProvider>
<DataProvider>
<DataProvider directory={process.cwd()}>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/test/cli/tui/form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ async function mountForm(
<ConfigProvider config={config}>
<Keymap.Provider>
<ClientProvider api={createApi(transport.fetch)}>
<DataProvider>
<DataProvider directory={process.cwd()}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>{response ? <CurrentForm /> : <FormPrompt form={form} />}</ToastProvider>
</ThemeProvider>
Expand Down
13 changes: 9 additions & 4 deletions packages/tui/test/context/session-tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ async function renderSessionTabs(
sessionTimes?: Record<string, { idle?: number; viewed?: number }>
sessionOutcomes?: Record<string, "succeeded" | "failed" | "interrupted">
newLocation?: "launch" | "inherit"
launchDirectory?: string
tabsEnabled?: boolean
viewFailures?: number
preview?: boolean
Expand Down Expand Up @@ -168,7 +169,7 @@ async function renderSessionTabs(
initialRoute={options?.home ? { type: "home" } : { type: "session", sessionID: initialSessionID }}
>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<DataProvider directory={options?.launchDirectory ?? directory}>
<LocationProvider>
<SessionTabsProvider>
<Probe />
Expand Down Expand Up @@ -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 {
Expand Down
Loading