Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
Dax Raad
20f6635ca0 fix(cli): pass initialized SDK to TUI 2026-06-07 13:15:12 -04:00
Dax Raad
a7f335aec0 refactor(tui): use authenticated HTTP SDK transport 2026-06-07 13:14:25 -04:00
18 changed files with 156 additions and 190 deletions

View file

@ -2,14 +2,14 @@ import { run } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config" import { TuiConfig } from "@opencode-ai/tui/config"
import { Effect } from "effect" import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) { export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
const config = TuiConfig.resolve({}, { terminalSuspend: false }) const config = TuiConfig.resolve({}, { terminalSuspend: false })
return run({ return run({
...transport, sdk: createOpencodeClient({ baseUrl: transport.url, headers: transport.headers, fetch: gracefulFetch }),
args: {}, args: {},
config, config,
fetch: gracefulFetch,
pluginHost: { pluginHost: {
async start() {}, async start() {},
async dispose() {}, async dispose() {},

View file

@ -1,8 +1,10 @@
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { UI } from "@/cli/ui" import { UI } from "@/cli/ui"
import { errorMessage } from "@opencode-ai/tui/util/error" import { errorMessage } from "@opencode-ai/tui/util/error"
import { validateSession } from "../tui/validate-session"
import { ServerAuth } from "@/server/auth" import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
export const AttachCommand = cmd({ export const AttachCommand = cmd({
command: "attach <url>", command: "attach <url>",
@ -61,15 +63,22 @@ export const AttachCommand = cmd({
} }
})() })()
const headers = ServerAuth.headers({ password: args.password, username: args.username }) const headers = ServerAuth.headers({ password: args.password, username: args.username })
const sdk = createOpencodeClient({ baseUrl: args.url, directory, headers })
const config = await TuiConfig.get() const config = await TuiConfig.get()
try { try {
await validateSession({ if (args.session) {
url: args.url, const sessionID = (() => {
sessionID: args.session, try {
directory, return Schema.decodeUnknownSync(SessionID)(args.session)
headers, } catch (error) {
throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, {
cause: error,
}) })
}
})()
await sdk.session.get({ sessionID }, { throwOnError: true })
}
} catch (error) { } catch (error) {
UI.error(errorMessage(error)) UI.error(errorMessage(error))
process.exitCode = 1 process.exitCode = 1
@ -77,11 +86,12 @@ export const AttachCommand = cmd({
} }
const { Effect } = await import("effect") const { Effect } = await import("effect")
const { run } = await import("../tui/layer") const { Global } = await import("@opencode-ai/core/global")
const { run } = await import("@opencode-ai/tui")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise( await Effect.runPromise(
run({ run({
url: args.url, sdk,
config, config,
pluginHost: createLegacyTuiPluginHost(), pluginHost: createLegacyTuiPluginHost(),
args: { args: {
@ -90,8 +100,7 @@ export const AttachCommand = cmd({
fork: args.fork, fork: args.fork,
}, },
directory, directory,
headers, }).pipe(Effect.provide(Global.defaultLayer)),
}),
) )
}, },
}) })

View file

@ -1,7 +1,12 @@
import { cmd } from "@/cli/cmd/cmd" import { cmd } from "@/cli/cmd/cmd"
import { Rpc } from "@/util/rpc" import { Rpc } from "@/util/rpc"
import { type rpc } from "../tui/worker" import { type rpc } from "../tui/worker"
import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
import path from "path" import path from "path"
import { randomBytes } from "node:crypto"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { UI } from "@/cli/ui" import { UI } from "@/cli/ui"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
@ -9,8 +14,6 @@ import { errorMessage } from "@opencode-ai/tui/util/error"
import { withTimeout } from "@/util/timeout" import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "@opencode-ai/tui/context/sdk"
import { writeHeapSnapshot } from "v8" import { writeHeapSnapshot } from "v8"
import { import {
OPENCODE_PROCESS_ROLE, OPENCODE_PROCESS_ROLE,
@ -18,43 +21,12 @@ import {
ensureRunID, ensureRunID,
sanitizedProcessEnv, sanitizedProcessEnv,
} from "@opencode-ai/core/util/opencode-process" } from "@opencode-ai/core/util/opencode-process"
import { validateSession } from "../tui/validate-session"
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32" import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
declare global { declare global {
const OPENCODE_WORKER_PATH: string const OPENCODE_WORKER_PATH: string
} }
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
function createWorkerFetch(client: RpcClient): typeof fetch {
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const request = new Request(input, init)
const body = request.body ? await request.text() : undefined
const result = await client.call("fetch", {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body,
})
return new Response(result.body, {
status: result.status,
headers: result.headers,
})
}
return fn as typeof fetch
}
function createEventSource(client: RpcClient): EventSource {
return {
subscribe: async (handler) => {
return client.on<GlobalEvent>("global.event", (e) => {
handler(e)
})
},
}
}
async function target() { async function target() {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
const dist = new URL("./cli/tui/worker.js", import.meta.url) const dist = new URL("./cli/tui/worker.js", import.meta.url)
@ -132,6 +104,15 @@ export const TuiThreadCommand = cmd({
return return
} }
const cwd = Filesystem.resolve(process.cwd()) const cwd = Filesystem.resolve(process.cwd())
const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"
const password = external ? undefined : randomBytes(32).toString("base64url")
const env = sanitizedProcessEnv({ const env = sanitizedProcessEnv({
[OPENCODE_PROCESS_ROLE]: "worker", [OPENCODE_PROCESS_ROLE]: "worker",
[OPENCODE_RUN_ID]: ensureRunID(), [OPENCODE_RUN_ID]: ensureRunID(),
@ -183,34 +164,41 @@ export const TuiThreadCommand = cmd({
const prompt = await input(args.prompt) const prompt = await input(args.prompt)
const config = await TuiConfig.get() const config = await TuiConfig.get()
const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"
const transport = external const transport = external
? { ? {
url: (await client.call("server", network)).url, url: (await client.call("server", network)).url,
fetch: undefined, headers: ServerAuth.headers(),
events: undefined,
} }
: { : {
url: "http://opencode.internal", url: (
fetch: createWorkerFetch(client), await client.call("server", {
events: createEventSource(client), hostname: "127.0.0.1",
port: 0,
preferredPort: 0,
auth: { password },
})
).url,
headers: ServerAuth.headers({ password }),
} }
const sdk = createOpencodeClient({
baseUrl: transport.url,
directory: cwd,
headers: transport.headers,
})
try { try {
await validateSession({ if (args.session) {
url: transport.url, const sessionID = (() => {
sessionID: args.session, try {
directory: cwd, return Schema.decodeUnknownSync(SessionID)(args.session)
fetch: transport.fetch, } catch (error) {
throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, {
cause: error,
}) })
}
})()
await sdk.session.get({ sessionID }, { throwOnError: true })
}
} catch (error) { } catch (error) {
UI.error(errorMessage(error)) UI.error(errorMessage(error))
process.exitCode = 1 process.exitCode = 1
@ -223,11 +211,12 @@ export const TuiThreadCommand = cmd({
try { try {
const { Effect } = await import("effect") const { Effect } = await import("effect")
const { run } = await import("../tui/layer") const { Global } = await import("@opencode-ai/core/global")
const { run } = await import("@opencode-ai/tui")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise( await Effect.runPromise(
run({ run({
url: transport.url, sdk,
async onSnapshot() { async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot") const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined) const server = await client.call("snapshot", undefined)
@ -236,8 +225,6 @@ export const TuiThreadCommand = cmd({
config, config,
pluginHost: createLegacyTuiPluginHost(), pluginHost: createLegacyTuiPluginHost(),
directory: cwd, directory: cwd,
fetch: transport.fetch,
events: transport.events,
args: { args: {
continue: args.continue, continue: args.continue,
sessionID: args.session, sessionID: args.session,
@ -246,12 +233,11 @@ export const TuiThreadCommand = cmd({
prompt, prompt,
fork: args.fork, fork: args.fork,
}, },
}), }).pipe(Effect.provide(Global.defaultLayer)),
) )
} finally { } finally {
await stop() await stop()
} }
process.exit(0)
} finally { } finally {
try { try {
unguard?.() unguard?.()
@ -259,6 +245,6 @@ export const TuiThreadCommand = cmd({
Log.Default.warn("failed to restore terminal guard", { error: errorMessage(error) }) Log.Default.warn("failed to restore terminal guard", { error: errorMessage(error) })
} }
} }
process.exit(0)
}, },
}) })
// scratch

View file

@ -1,7 +0,0 @@
import { run as runTui, type TuiInput } from "@opencode-ai/tui"
import { Global } from "@opencode-ai/core/global"
import { Effect } from "effect"
export function run(input: TuiInput) {
return runTui(input).pipe(Effect.provide(Global.defaultLayer))
}

View file

@ -1,29 +0,0 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
const decodeSessionID = Schema.decodeUnknownSync(SessionID)
export async function validateSession(input: {
url: string
sessionID?: string
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
}) {
if (!input.sessionID) return
let sessionID: SessionID
try {
sessionID = decodeSessionID(input.sessionID)
} catch (error) {
throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error })
}
await createOpencodeClient({
baseUrl: input.url,
directory: input.directory,
fetch: input.fetch,
headers: input.headers,
}).session.get({ sessionID }, { throwOnError: true })
}

View file

@ -5,8 +5,6 @@ import { InstanceRuntime } from "@/project/instance-runtime"
import { Rpc } from "@/util/rpc" import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade" import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { ServerAuth } from "@/server/auth"
import { writeHeapSnapshot } from "node:v8" import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap" import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
@ -39,38 +37,22 @@ process.on("uncaughtException", (e) => {
}) })
}) })
// Subscribe to global events and forward them via RPC
GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
})
let server: Awaited<ReturnType<typeof Server.listen>> | undefined let server: Awaited<ReturnType<typeof Server.listen>> | undefined
export const rpc = { export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers }
const auth = ServerAuth.header()
if (auth && !headers["authorization"] && !headers["Authorization"]) {
headers["Authorization"] = auth
}
const request = new Request(input.url, {
method: input.method,
headers,
body: input.body,
})
const response = await Server.Default().app.fetch(request)
const body = await response.text()
return {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
body,
}
},
snapshot() { snapshot() {
const result = writeHeapSnapshot("server.heapsnapshot") const result = writeHeapSnapshot("server.heapsnapshot")
return result return result
}, },
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { async server(input: {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
preferredPort?: number
auth?: { username?: string; password?: string }
}) {
if (server) await server.stop(true) if (server) await server.stop(true)
server = await Server.listen(input) server = await Server.listen(input)
return { url: server.url.toString() } return { url: server.url.toString() }

View file

@ -13,6 +13,7 @@ import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
import { PublicApi } from "./routes/instance/httpapi/public" import { PublicApi } from "./routes/instance/httpapi/public"
import type { CorsOptions } from "./cors" import type { CorsOptions } from "./cors"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { ServerAuth } from "./auth"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
@ -36,6 +37,8 @@ type ListenOptions = CorsOptions & {
hostname: string hostname: string
mdns?: boolean mdns?: boolean
mdnsDomain?: string mdnsDomain?: string
preferredPort?: number
auth?: ServerAuth.Credentials
} }
type ListenerState = { type ListenerState = {
scope: Scope.Scope scope: Scope.Scope
@ -109,19 +112,30 @@ function listenerLayer(opts: ListenOptions, port: number) {
Layer.provideMerge(WebSocketTracker.layer), Layer.provideMerge(WebSocketTracker.layer),
Layer.provideMerge(serverLayer({ port, hostname: opts.hostname })), Layer.provideMerge(serverLayer({ port, hostname: opts.hostname })),
// Install a fresh `ConfigProvider` per listener so `Config.string(...)` // Install a fresh `ConfigProvider` per listener so `Config.string(...)`
// reads reflect the current `process.env`. Effect's default // reads reflect the current `process.env` and listener-local credentials. Effect's default
// `ConfigProvider` snapshots `process.env` on first read and caches the // `ConfigProvider` snapshots `process.env` on first read and caches the
// result on a module-singleton Reference; without overriding it here, // result on a module-singleton Reference; without overriding it here,
// every later `Server.listen()` keeps observing that initial snapshot. // every later `Server.listen()` keeps observing that initial snapshot.
Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv())), Layer.provide(
ConfigProvider.layer(
opts.auth
? ConfigProvider.fromUnknown({
...process.env,
OPENCODE_SERVER_PASSWORD: opts.auth.password,
OPENCODE_SERVER_USERNAME: opts.auth.username ?? "opencode",
})
: ConfigProvider.fromEnv(),
),
),
) )
} }
function startWithPortFallback(opts: ListenOptions) { function startWithPortFallback(opts: ListenOptions) {
if (opts.port !== 0) return startListener(opts, opts.port) if (opts.port !== 0) return startListener(opts, opts.port)
if (opts.preferredPort === 0) return startListener(opts, 0)
// Match the legacy listener port-resolution behavior: explicit `0` prefers // Match the legacy listener port-resolution behavior: explicit `0` prefers
// 4096 first, then any free port. // 4096 first, then any free port.
return startListener(opts, 4096).pipe(Effect.catch(() => startListener(opts, 0))) return startListener(opts, opts.preferredPort ?? 4096).pipe(Effect.catch(() => startListener(opts, 0)))
} }
function startListener(opts: ListenOptions, port: number) { function startListener(opts: ListenOptions, port: number) {

View file

@ -4,7 +4,7 @@ describe("tui attach", () => {
test("loads the TUI integration lazily", async () => { test("loads the TUI integration lazily", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/attach.ts", import.meta.url)).text() const source = await Bun.file(new URL("../../../src/cli/cmd/attach.ts", import.meta.url)).text()
expect(source).toContain('await import("../tui/layer")') expect(source).toContain('await import("@opencode-ai/tui")')
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/) expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
expect(source).not.toContain('import("./app")') expect(source).not.toContain('import("./app")')
}) })

View file

@ -8,7 +8,7 @@ describe("tui thread", () => {
test("loads the TUI integration lazily", async () => { test("loads the TUI integration lazily", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text() const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text()
expect(source).toContain('await import("../tui/layer")') expect(source).toContain('await import("@opencode-ai/tui")')
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/) expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
expect(source).not.toContain('import("./app")') expect(source).not.toContain('import("./app")')
}) })

View file

@ -3,6 +3,7 @@ import net from "node:net"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { ServerAuth } from "../../src/server/auth"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { withTimeout } from "../../src/util/timeout" import { withTimeout } from "../../src/util/timeout"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
@ -313,6 +314,38 @@ describe("HttpApi Server.listen", () => {
} }
}) })
test("preferred port 0 uses an OS-assigned port", async () => {
const listener = await Server.listen({ hostname: "127.0.0.1", port: 0, preferredPort: 0 })
try {
expect(listener.port).toBeGreaterThan(0)
expect(listener.port).not.toBe(4096)
} finally {
await stop(listener, "timed out cleaning up OS-assigned listener")
}
})
test("accepts listener-local credentials without changing process auth", async () => {
const listener = await Server.listen({
hostname: "127.0.0.1",
port: 0,
preferredPort: 0,
auth: { password: "local-secret" },
})
try {
expect((await fetch(new URL("/doc", listener.url))).status).toBe(401)
expect(
(
await fetch(new URL("/doc", listener.url), {
headers: { authorization: ServerAuth.header({ password: "local-secret" }) ?? "" },
})
).status,
).toBe(200)
expect(process.env.OPENCODE_SERVER_PASSWORD).toBe(original.envPassword)
} finally {
await stop(listener, "timed out cleaning up listener-local auth listener")
}
})
test("port 0 falls back when 4096 is taken", async () => { test("port 0 falls back when 4096 is taken", async () => {
const blocker = await occupyPort(4096) const blocker = await occupyPort(4096)
if (!blocker) return if (!blocker) return

View file

@ -9,7 +9,6 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { validateSession } from "../../src/cli/tui/validate-session"
import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { InstanceStore } from "../../src/project/instance-store" import { InstanceStore } from "../../src/project/instance-store"
import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema"
@ -470,14 +469,8 @@ describe("HttpApi SDK", () => {
Effect.gen(function* () { Effect.gen(function* () {
const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T" const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T"
const fetch = yield* serverFetch(serverPath) const fetch = yield* serverFetch(serverPath)
const thrown = yield* captureThrown(() => const sdk = createOpencodeClient({ baseUrl: "http://localhost", directory, fetch })
validateSession({ const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true }))
url: "http://localhost",
directory,
sessionID,
fetch,
}),
)
expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`) expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`)
return errorMessage(thrown) return errorMessage(thrown)
}), }),

View file

@ -31,6 +31,7 @@ import { ProjectProvider, useProject } from "./context/project"
import { EditorContextProvider } from "./context/editor" import { EditorContextProvider } from "./context/editor"
import { useEvent } from "./context/event" import { useEvent } from "./context/event"
import { SDKProvider, useSDK } from "./context/sdk" import { SDKProvider, useSDK } from "./context/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { StartupLoading } from "./component/startup-loading" import { StartupLoading } from "./component/startup-loading"
import { SyncProvider, useSync } from "./context/sync" import { SyncProvider, useSync } from "./context/sync"
import { SyncProviderV2 } from "./context/sync-v2" import { SyncProviderV2 } from "./context/sync-v2"
@ -130,13 +131,11 @@ const appBindingCommands = [
] as const ] as const
export type TuiInput = { export type TuiInput = {
url: string sdk: OpencodeClient
args: Args args: Args
config: TuiConfig.Resolved config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]> onSnapshot?: () => Promise<string[]>
directory?: string directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource events?: EventSource
pluginHost: TuiPluginHost pluginHost: TuiPluginHost
} }
@ -270,10 +269,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<TuiConfigProvider config={input.config}> <TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}> <PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider <SDKProvider
url={input.url} sdk={input.sdk}
directory={input.directory} directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events} events={input.events}
> >
<ProjectProvider> <ProjectProvider>

View file

@ -1,5 +1,4 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import type { GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { batch, onCleanup, onMount } from "solid-js" import { batch, onCleanup, onMount } from "solid-js"
@ -11,26 +10,14 @@ export type EventSource = {
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK", name: "SDK",
init: (props: { init: (props: {
url: string sdk: OpencodeClient
directory?: string directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource events?: EventSource
}) => { }) => {
const abort = new AbortController() const abort = new AbortController()
let sse: AbortController | undefined let sse: AbortController | undefined
function createSDK() { const sdk = props.sdk
return createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
directory: props.directory,
fetch: props.fetch,
headers: props.headers,
})
}
let sdk = createSDK()
const handlers = new Set<(event: GlobalEvent) => void>() const handlers = new Set<(event: GlobalEvent) => void>()
const emitter = { const emitter = {
@ -144,8 +131,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
}, },
directory: props.directory, directory: props.directory,
event: emitter, event: emitter,
fetch: props.fetch ?? fetch,
url: props.url,
} }
}, },
}) })

View file

@ -3,7 +3,7 @@ import { createTestRenderer } from "@opentui/core/testing"
import { Effect } from "effect" import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { createTuiResolvedConfig } from "./fixture/tui-runtime" import { createTuiResolvedConfig } from "./fixture/tui-runtime"
import { createEventSource, createFetch, directory } from "./fixture/tui-sdk" import { createClient, createEventSource, createFetch, directory } from "./fixture/tui-sdk"
test("SIGHUP clears title and disposes scoped resources once", async () => { test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@ -28,10 +28,9 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
const { run } = await import("../src/app") const { run } = await import("../src/app")
const task = Effect.runPromise( const task = Effect.runPromise(
run({ run({
url: "http://test", sdk: createClient(calls.fetch),
directory, directory,
config: createTuiResolvedConfig({ plugin_enabled: {} }), config: createTuiResolvedConfig({ plugin_enabled: {} }),
fetch: calls.fetch,
events: events.source, events: events.source,
args: {}, args: {},
pluginHost: { pluginHost: {

View file

@ -6,7 +6,7 @@ import { KVProvider, useKV } from "../../../../src/context/kv"
import { ProjectProvider, useProject } from "../../../../src/context/project" import { ProjectProvider, useProject } from "../../../../src/context/project"
import { SDKProvider } from "../../../../src/context/sdk" import { SDKProvider } from "../../../../src/context/sdk"
import { SyncProvider, useSync } from "../../../../src/context/sync" import { SyncProvider, useSync } from "../../../../src/context/sync"
import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk" import { createClient, createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
import { TestTuiContexts } from "../../../fixture/tui-environment" import { TestTuiContexts } from "../../../fixture/tui-environment"
export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk" export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
@ -46,7 +46,7 @@ export async function mount(override?: FetchHandler, state?: string) {
<TestTuiContexts paths={state ? { state } : undefined}> <TestTuiContexts paths={state ? { state } : undefined}>
<ArgsProvider> <ArgsProvider>
<KVProvider> <KVProvider>
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}> <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider> <ProjectProvider>
<SyncProvider> <SyncProvider>
<Probe /> <Probe />

View file

@ -6,7 +6,7 @@ import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/context/project" import { ProjectProvider } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk" import { SDKProvider } from "../../../src/context/sdk"
import { SyncProviderV2, useSyncV2 } from "../../../src/context/sync-v2" import { SyncProviderV2, useSyncV2 } from "../../../src/context/sync-v2"
import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk" import { createClient, createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment" import { TestTuiContexts } from "../../fixture/tui-environment"
async function wait(fn: () => boolean, timeout = 2000) { async function wait(fn: () => boolean, timeout = 2000) {
@ -44,7 +44,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider> <ProjectProvider>
<SyncProviderV2> <SyncProviderV2>
<Probe /> <Probe />
@ -173,7 +173,7 @@ test("sync v2 renders admitted prompts only after promotion", async () => {
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider> <ProjectProvider>
<SyncProviderV2> <SyncProviderV2>
<Probe /> <Probe />
@ -237,7 +237,7 @@ test("sync v2 renders a promoted prompt when admission was missed", async () =>
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider> <ProjectProvider>
<SyncProviderV2> <SyncProviderV2>
<Probe /> <Probe />
@ -285,7 +285,7 @@ test("sync v2 projects live context updates with their message ID", async () =>
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider> <ProjectProvider>
<SyncProviderV2> <SyncProviderV2>
<Probe /> <Probe />
@ -340,7 +340,7 @@ test("sync v2 preserves live events while snapshot hydration is in flight", asyn
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider> <ProjectProvider>
<SyncProviderV2> <SyncProviderV2>
<Probe /> <Probe />
@ -390,7 +390,7 @@ test("sync v2 replaces stale cached rows while preserving in-flight live rows",
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider> <ProjectProvider>
<SyncProviderV2> <SyncProviderV2>
<Probe /> <Probe />
@ -459,7 +459,7 @@ test("sync v2 preserves snapshot order and metadata for in-flight updates", asyn
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider> <ProjectProvider>
<SyncProviderV2> <SyncProviderV2>
<Probe /> <Probe />

View file

@ -6,7 +6,7 @@ import { onMount } from "solid-js"
import { ProjectProvider, useProject } from "../../../src/context/project" import { ProjectProvider, useProject } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk" import { SDKProvider } from "../../../src/context/sdk"
import { useEvent } from "../../../src/context/event" import { useEvent } from "../../../src/context/event"
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk" import { createClient, createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment" import { TestTuiContexts } from "../../fixture/tui-environment"
const projectID = "proj_test" const projectID = "proj_test"
@ -61,7 +61,7 @@ async function mount() {
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}> <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
<ProjectProvider> <ProjectProvider>
<Probe <Probe
onReady={async (ctx) => { onReady={async (ctx) => {

View file

@ -1,9 +1,13 @@
import type { GlobalEvent } from "@opencode-ai/sdk/v2" import { createOpencodeClient, type GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "../../src/context/sdk" import type { EventSource } from "../../src/context/sdk"
export const worktree = "/tmp/opencode" export const worktree = "/tmp/opencode"
export const directory = `${worktree}/packages/tui` export const directory = `${worktree}/packages/tui`
export function createClient(fetch: typeof globalThis.fetch) {
return createOpencodeClient({ baseUrl: "http://test", directory, fetch })
}
export function json(data: unknown, init?: ResponseInit) { export function json(data: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(data), { return new Response(JSON.stringify(data), {
...init, ...init,