Browse Source

refactor(tui): wire generated client reads (#34381)

Kit Langton 1 tháng trước cách đây
mục cha
commit
381d67572e

+ 3 - 0
bun.lock

@@ -98,6 +98,7 @@
       },
       "dependencies": {
         "@effect/platform-node": "catalog:",
+        "@opencode-ai/client": "workspace:*",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/server": "workspace:*",
@@ -581,6 +582,7 @@
         "@octokit/graphql": "9.0.2",
         "@octokit/rest": "catalog:",
         "@openauthjs/openauth": "catalog:",
+        "@opencode-ai/client": "workspace:*",
         "@opencode-ai/llm": "workspace:*",
         "@opencode-ai/plugin": "workspace:*",
         "@opencode-ai/protocol": "workspace:*",
@@ -932,6 +934,7 @@
       "name": "@opencode-ai/tui",
       "version": "1.17.11",
       "dependencies": {
+        "@opencode-ai/client": "workspace:*",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/plugin": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",

+ 1 - 0
packages/cli/package.json

@@ -17,6 +17,7 @@
   },
   "dependencies": {
     "@effect/platform-node": "catalog:",
+    "@opencode-ai/client": "workspace:*",
     "@opencode-ai/core": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/server": "workspace:*",

+ 10 - 9
packages/cli/src/tui.ts

@@ -3,6 +3,7 @@ import { TuiConfig } from "@opencode-ai/tui/config"
 import { Effect } from "effect"
 import { Global } from "@opencode-ai/core/global"
 import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
+import { OpenCode } from "@opencode-ai/client"
 import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
 
 type Transport = { url: string; headers: RequestInit["headers"] }
@@ -12,23 +13,23 @@ export function runTui(transport: Transport, reload?: () => Promise<Transport>)
   let disposeSlots: (() => void) | undefined
   return Effect.gen(function* () {
     const options = { baseUrl: transport.url, headers: transport.headers }
-    const client = createOpencodeClient(options)
-    const directory = yield* Effect.tryPromise(() =>
-      client.v2.fs.list({ location: { directory: process.cwd() } }, { throwOnError: true }),
-    ).pipe(
-      Effect.map((response) => response.data.location.directory),
+    const api = OpenCode.make(options)
+    const directory = yield* Effect.tryPromise(() => api.files.list({ location: { directory: process.cwd() } })).pipe(
+      Effect.map((response) => response.location.directory),
       Effect.catch(() =>
-        Effect.tryPromise(() => client.v2.location.get(undefined, { throwOnError: true })).pipe(
-          Effect.map((response) => response.data.directory),
-        ),
+        Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)),
       ),
     )
     return yield* run({
       client: createOpencodeClient({ ...options, directory }),
+      api,
       reload: reload
         ? async () => {
             const next = await reload()
-            return createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory })
+            return {
+              client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }),
+              api: OpenCode.make({ baseUrl: next.url, headers: next.headers }),
+            }
           }
         : undefined,
       args: {},

+ 1 - 0
packages/client/src/index.ts

@@ -1,2 +1,3 @@
 export * from "./generated/index"
 export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
+export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>

+ 1 - 0
packages/opencode/package.json

@@ -84,6 +84,7 @@
     "@octokit/graphql": "9.0.2",
     "@octokit/rest": "catalog:",
     "@openauthjs/openauth": "catalog:",
+    "@opencode-ai/client": "workspace:*",
     "@opencode-ai/llm": "workspace:*",
     "@opencode-ai/plugin": "workspace:*",
     "@opencode-ai/protocol": "workspace:*",

+ 2 - 0
packages/opencode/src/cli/cmd/attach.ts

@@ -3,6 +3,7 @@ import { UI } from "@/cli/ui"
 import { errorMessage } from "@opencode-ai/tui/util/error"
 import { validateSession } from "../tui/validate-session"
 import { ServerAuth } from "@/server/auth"
+import { OpenCode } from "@opencode-ai/client"
 import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 
 export const AttachCommand = cmd({
@@ -134,6 +135,7 @@ export const AttachCommand = cmd({
     await Effect.runPromise(
       run({
         client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
+        api: OpenCode.make({ baseUrl: args.url, headers }),
         config,
         pluginHost: createLegacyTuiPluginHost(),
         args: {

+ 2 - 0
packages/opencode/src/cli/cmd/tui.ts

@@ -8,6 +8,7 @@ import { errorMessage } from "@opencode-ai/tui/util/error"
 import { withTimeout } from "@/util/timeout"
 import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
 import { Filesystem } from "@/util/filesystem"
+import { OpenCode } from "@opencode-ai/client"
 import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 import { writeHeapSnapshot } from "v8"
 import { validateSession } from "../tui/validate-session"
@@ -205,6 +206,7 @@ export const TuiThreadCommand = cmd({
         await Effect.runPromise(
           run({
             client: createOpencodeClient({ baseUrl: url, directory: cwd }),
+            api: OpenCode.make({ baseUrl: url }),
             async onSnapshot() {
               const tui = writeHeapSnapshot("tui.heapsnapshot")
               const server = await client.call("snapshot", undefined)

+ 1 - 0
packages/tui/package.json

@@ -47,6 +47,7 @@
     "./component/spinner": "./src/component/spinner.tsx"
   },
   "dependencies": {
+    "@opencode-ai/client": "workspace:*",
     "@opencode-ai/core": "workspace:*",
     "@opencode-ai/plugin": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",

+ 4 - 2
packages/tui/src/app.tsx

@@ -76,6 +76,7 @@ import {
   useOpencodeKeymap,
 } from "./keymap"
 
+import type { OpenCodeClient } from "@opencode-ai/client"
 import type { OpencodeClient } from "@opencode-ai/sdk/v2"
 import { DialogVariant } from "./component/dialog-variant"
 import { createTuiAttention } from "./attention"
@@ -135,7 +136,8 @@ const appBindingCommands = [
 
 export type TuiInput = {
   client: OpencodeClient
-  reload?: () => Promise<OpencodeClient>
+  api: OpenCodeClient
+  reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
   args: Args
   config: TuiConfig.Resolved
   onSnapshot?: () => Promise<string[]>
@@ -290,7 +292,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
                                   >
                                     <TuiConfigProvider config={input.config}>
                                       <PluginRuntimeProvider value={pluginRuntime}>
-                                        <SDKProvider client={input.client} reload={input.reload}>
+                                        <SDKProvider client={input.client} api={input.api} reload={input.reload}>
                                           <ProjectProvider>
                                             <SyncProvider>
                                               <DataProvider>

+ 45 - 61
packages/tui/src/component/dialog-integration.tsx

@@ -1,5 +1,6 @@
 import { TextAttributes } from "@opentui/core"
-import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod, IntegrationAttempt } from "@opencode-ai/sdk/v2"
+import type { IntegrationsConnectOauthOutput } from "@opencode-ai/client"
+import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2"
 import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
 import { useClipboard } from "../context/clipboard"
 import { useData } from "../context/data"
@@ -22,6 +23,7 @@ const INTEGRATION_PRIORITY: Record<string, number> = {
 }
 
 type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }>
+type IntegrationAttempt = IntegrationsConnectOauthOutput["data"]
 
 export function integrationOptions(list: IntegrationInfo[]) {
   return list.toSorted(
@@ -109,11 +111,8 @@ function manageConnections(
             title: `Disconnect ${connection.label}`,
             value: connection.id,
             onSelect: () => {
-              void sdk.client.v2.credential
-                .remove(
-                  { credentialID: connection.id, location: location(data) },
-                  { throwOnError: true },
-                )
+              void sdk.api.credentials
+                .remove({ credentialID: connection.id, location: location(data) })
                 .then(() => disconnected(integration.name, data, dialog, toast))
                 .catch(toast.error)
             },
@@ -124,11 +123,7 @@ function manageConnections(
   })
 }
 
-function selectMethod(
-  integration: IntegrationInfo,
-  methods: ConnectMethod[],
-  dialog: ReturnType<typeof useDialog>,
-) {
+function selectMethod(integration: IntegrationInfo, methods: ConnectMethod[], dialog: ReturnType<typeof useDialog>) {
   if (methods.length === 1) return openMethod(integration, methods[0], dialog)
   dialog.replace(() => (
     <DialogSelect
@@ -142,11 +137,7 @@ function selectMethod(
   ))
 }
 
-function openMethod(
-  integration: IntegrationInfo,
-  method: ConnectMethod,
-  dialog: ReturnType<typeof useDialog>,
-) {
+function openMethod(integration: IntegrationInfo, method: ConnectMethod, dialog: ReturnType<typeof useDialog>) {
   if (method.type === "key") {
     dialog.replace(() => <KeyMethod integration={integration} method={method} />)
     return
@@ -168,21 +159,16 @@ function KeyMethod(props: { integration: IntegrationInfo; method: Extract<Connec
       placeholder="API key"
       onConfirm={(key) => {
         if (!key) return
-        void sdk.client.v2.integration.connect
-          .key(
-            {
-              integrationID: props.integration.id,
-              location: location(data),
-              key,
-            },
-            { throwOnError: true },
-          )
+        void sdk.api.integrations
+          .connectKey({
+            integrationID: props.integration.id,
+            location: location(data),
+            key,
+          })
           .then(() => connected(props.integration.name, data, dialog, toast))
           .catch((cause) => setError(message(cause)))
       }}
-      description={() => (
-        <Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>
-      )}
+      description={() => <Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>}
     />
   )
 }
@@ -208,25 +194,22 @@ function OAuthStarting(props: {
   const toast = useToast()
 
   onMount(() => {
-    void sdk.client.v2.integration.connect
-      .oauth(
-        {
-          integrationID: props.integration.id,
-          location: location(data),
-          methodID: props.method.id,
-          inputs: props.inputs,
-        },
-        { throwOnError: true },
-      )
+    void sdk.api.integrations
+      .connectOauth({
+        integrationID: props.integration.id,
+        location: location(data),
+        methodID: props.method.id,
+        inputs: props.inputs,
+      })
       .then((result) => {
-        if (result.data.data.mode === "code") {
+        if (result.data.mode === "code") {
           dialog.replace(() => (
-            <OAuthCode integration={props.integration} title={props.method.label} attempt={result.data.data} />
+            <OAuthCode integration={props.integration} title={props.method.label} attempt={result.data} />
           ))
           return
         }
         dialog.replace(() => (
-          <OAuthAuto integration={props.integration} title={props.method.label} attempt={result.data.data} />
+          <OAuthAuto integration={props.integration} title={props.method.label} attempt={result.data} />
         ))
       })
       .catch((cause) => {
@@ -265,10 +248,10 @@ function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt
   }))
 
   const poll = () => {
-    void sdk.client.v2.integration.attempt
-      .status({ attemptID: props.attempt.attemptID, location: location(data) }, { throwOnError: true })
+    void sdk.api.integrations
+      .attemptStatus({ attemptID: props.attempt.attemptID, location: location(data) })
       .then((result) => {
-        const status = result.data.data
+        const status = result.data
         if (status.status === "pending") {
           timer = setTimeout(poll, 500)
           return
@@ -292,7 +275,7 @@ function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt
   onCleanup(() => {
     if (timer) clearTimeout(timer)
     if (settled) return
-    void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
+    void sdk.api.integrations.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) })
   })
 
   return (
@@ -317,7 +300,7 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt
 
   onCleanup(() => {
     if (settled) return
-    void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
+    void sdk.api.integrations.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) })
   })
 
   return (
@@ -326,11 +309,8 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt
       placeholder="Authorization code"
       onConfirm={(code) => {
         if (!code) return
-        void sdk.client.v2.integration.attempt
-          .complete(
-            { attemptID: props.attempt.attemptID, location: location(data), code },
-            { throwOnError: true },
-          )
+        void sdk.api.integrations
+          .attemptComplete({ attemptID: props.attempt.attemptID, location: location(data), code })
           .then(() => {
             settled = true
             return connected(props.integration.name, data, dialog, toast)
@@ -348,13 +328,7 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt
   )
 }
 
-function OAuthView(props: {
-  title: string
-  url?: string
-  instructions?: string
-  message: string
-  copy?: boolean
-}) {
+function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
   const dialog = useDialog()
   const { theme } = useTheme()
   return (
@@ -371,7 +345,9 @@ function OAuthView(props: {
         {(url) => (
           <box gap={1}>
             <Link href={url()} fg={theme.primary} />
-            <Show when={props.instructions}>{(instructions) => <text fg={theme.textMuted}>{instructions()}</text>}</Show>
+            <Show when={props.instructions}>
+              {(instructions) => <text fg={theme.textMuted}>{instructions()}</text>}
+            </Show>
           </box>
         )}
       </Show>
@@ -436,7 +412,11 @@ async function connected(
   dialog: ReturnType<typeof useDialog>,
   toast: ReturnType<typeof useToast>,
 ) {
-  await Promise.all([data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh()])
+  await Promise.all([
+    data.location.integration.refresh(),
+    data.location.model.refresh(),
+    data.location.provider.refresh(),
+  ])
   toast.show({ variant: "success", message: `Connected ${name}` })
   dialog.clear()
 }
@@ -447,7 +427,11 @@ async function disconnected(
   dialog: ReturnType<typeof useDialog>,
   toast: ReturnType<typeof useToast>,
 ) {
-  await Promise.all([data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh()])
+  await Promise.all([
+    data.location.integration.refresh(),
+    data.location.model.refresh(),
+    data.location.provider.refresh(),
+  ])
   toast.show({ variant: "success", message: `Disconnected ${name}` })
   dialog.clear()
 }

+ 20 - 13
packages/tui/src/component/dialog-move-session.tsx

@@ -11,6 +11,7 @@ import { abbreviateHome } from "../runtime"
 import { useTuiPaths } from "../context/runtime"
 import { Locale } from "../util/locale"
 import { errorMessage } from "../util/error"
+import { isRecord } from "../util/record"
 import { useToast } from "../ui/toast"
 import { useCommandShortcut } from "../keymap"
 import { useProject } from "../context/project"
@@ -74,10 +75,10 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
     () => (props.initialRemoving ? undefined : props.projectID),
     async (projectID, info): Promise<ProjectDirectory[] | undefined> => {
       try {
-        await sdk.client.v2.projectCopy.refresh(
-          { projectID, location: { directory: projectContext.instance.directory() || paths.cwd } },
-          { throwOnError: true },
-        )
+        await sdk.api.projectCopies.refresh({
+          projectID,
+          location: { directory: projectContext.instance.directory() || paths.cwd },
+        })
         const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
         setLoadError(undefined)
         return directories.data ?? []
@@ -221,18 +222,21 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
     setToDelete(undefined)
     setRemoving(selected.directory)
     setWorking(true)
-    const result = await sdk.client.v2.projectCopy
+    const error = await sdk.api.projectCopies
       .remove({
         projectID: props.projectID,
         location: { directory: projectContext.instance.directory() || paths.cwd },
         directory: selected.directory,
         force: false,
       })
-      .catch((error) => ({ error }))
-    if (result.error) {
+      .then(
+        () => undefined,
+        (error) => error,
+      )
+    if (error) {
       setRemoving(undefined)
       setWorking(false)
-      if ("data" in result.error && result.error.data.forceRequired) {
+      if (isRecord(error) && isRecord(error.data) && error.data.forceRequired === true) {
         const status = await sdk.client.vcs.status({ directory: selected.directory }).catch(() => undefined)
         const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], {
           title: "Delete working copy?",
@@ -243,19 +247,22 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
           return
         }
         reopen(selected.directory)
-        const forced = await sdk.client.v2.projectCopy
+        const forcedError = await sdk.api.projectCopies
           .remove({
             projectID: props.projectID,
             location: { directory: projectContext.instance.directory() || paths.cwd },
             directory: selected.directory,
             force: true,
           })
-          .catch((error) => ({ error }))
-        if (forced.error) {
+          .then(
+            () => undefined,
+            (error) => error,
+          )
+        if (forcedError) {
           toast.show({
             variant: "error",
             title: "Failed to delete project copy",
-            message: errorMessage(forced.error),
+            message: errorMessage(forcedError),
           })
           reopen()
           return
@@ -269,7 +276,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
       toast.show({
         variant: "error",
         title: "Failed to delete project copy",
-        message: errorMessage(result.error),
+        message: errorMessage(error),
       })
       return
     }

+ 15 - 6
packages/tui/src/component/dialog-session-list.tsx

@@ -31,11 +31,16 @@ export function DialogSessionList() {
 
   const [searchResults] = createResource(search, async (query) => {
     if (!query) return
-    const response = await sdk.client.v2.session.list(
-      { search: query, limit: 50, order: "desc" },
-      { throwOnError: true },
-    )
-    return { query, sessions: response.data.data }
+    const location = data.location.default()
+    const response = await sdk.api.sessions.list({
+      search: query,
+      limit: 50,
+      order: "desc",
+      directory: location.directory,
+      workspace: location.workspaceID,
+    })
+    // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; session list UI reuses legacy mutable session types.
+    return { query, sessions: structuredClone(response.data) as SessionV2Info[] }
   })
 
   const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
@@ -59,7 +64,11 @@ export function DialogSessionList() {
 
   const options = createMemo(() => {
     const today = new Date().toDateString()
-    const sessionMap = new Map(sessions().filter((session) => !session.parentID).map((session) => [session.id, session]))
+    const sessionMap = new Map(
+      sessions()
+        .filter((session) => !session.parentID)
+        .map((session) => [session.id, session]),
+    )
     const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
     const pinnedSet = new Set(pinned)
     const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))

+ 7 - 3
packages/tui/src/component/dialog-session-rename.tsx

@@ -17,11 +17,15 @@ export function DialogSessionRename(props: { sessionID: string; currentTitle?: s
       onConfirm={(value) => {
         const title = value.trim()
         if (!title) return
-        void sdk.client.v2.session
-          .rename({ sessionID: props.sessionID, title }, { throwOnError: true })
+        void sdk.api.sessions
+          .rename({ sessionID: props.sessionID, title })
           .then(() => dialog.clear())
           .catch((error) =>
-            toast.show({ message: `Failed to rename session: ${errorMessage(error)}`, variant: "error", duration: 5000 }),
+            toast.show({
+              message: `Failed to rename session: ${errorMessage(error)}`,
+              variant: "error",
+              duration: 5000,
+            }),
           )
       }}
       onCancel={() => dialog.clear()}

+ 9 - 7
packages/tui/src/component/dialog-tag.tsx

@@ -17,13 +17,15 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
   const [files] = createResource(
     () => [store.filter],
     async () => {
-      const result = await sdk.client.find.files({
-        query: store.filter,
-        workspace: project.workspace.current(),
-      })
-      if (result.error) return []
-      const sliced = (result.data ?? []).slice(0, 5)
-      return sliced
+      const result = await sdk.api.files
+        .find({
+          query: store.filter,
+          type: "file",
+          limit: 5,
+          location: { workspace: project.workspace.current() },
+        })
+        .catch(() => undefined)
+      return result?.data.map((item) => item.path) ?? []
     },
   )
 

+ 13 - 16
packages/tui/src/component/prompt/autocomplete.tsx

@@ -320,29 +320,26 @@ export function Autocomplete(props: {
       if (referenceMatch()) return []
       const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
 
-      // Get files from SDK
-      const result = await sdk.client.v2.fs.find({
-        query: baseQuery,
-        limit: "20",
-        location: {
-          directory: input.location?.directory,
-          workspace: input.location?.workspaceID ?? project.workspace.current(),
-        },
-      })
+      const result = await sdk.api.files
+        .find({
+          query: baseQuery,
+          limit: 20,
+          location: {
+            directory: input.location?.directory,
+            workspace: input.location?.workspaceID ?? project.workspace.current(),
+          },
+        })
+        .catch(() => undefined)
 
       const options: AutocompleteOption[] = []
 
       // Add file options. Trust the order returned by fff (frecency, fuzzy
       // score, filename bonus, etc. are already factored in).
-      if (!result.error && result.data) {
+      if (result) {
         const width = props.anchor().width - 4
         options.push(
-          ...result.data.data.map((item): AutocompleteOption => {
-            const { filename, part } = createFilePart(
-              item,
-              path.join(result.data.location.directory, item.path),
-              lineRange,
-            )
+          ...result.data.map((item): AutocompleteOption => {
+            const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
             return {
               display: Locale.truncateMiddle(filename, width),
               value: filename,

+ 80 - 68
packages/tui/src/component/prompt/index.tsx

@@ -37,7 +37,7 @@ import { usePromptStash } from "../../prompt/stash"
 import { DialogStash } from "../dialog-stash"
 import { type AutocompleteRef, Autocomplete } from "./autocomplete"
 import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
-import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
+import type { AssistantMessage, FilePart, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2"
 import { Locale } from "../../util/locale"
 import { errorMessage } from "../../util/error"
 import { createColors, createFrames } from "../../ui/spinner"
@@ -158,11 +158,12 @@ export function Prompt(props: PromptProps) {
   const dialog = useDialog()
   const toast = useToast()
   const status = createMemo(() => data.session.status(props.sessionID ?? ""))
-  const activeSubagents = createMemo(() =>
-    data.session
-      .list()
-      .filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running")
-      .length,
+  const activeSubagents = createMemo(
+    () =>
+      data.session
+        .list()
+        .filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running")
+        .length,
   )
   const runningShells = createMemo(
     () => data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID).length,
@@ -424,7 +425,7 @@ export function Prompt(props: PromptProps) {
           }, 5000)
 
           if (store.interrupt >= 2) {
-            void sdk.client.v2.session.interrupt({
+            void sdk.api.sessions.interrupt({
               sessionID: props.sessionID,
             })
             setStore("interrupt", 0)
@@ -1009,18 +1010,23 @@ export function Prompt(props: PromptProps) {
       const directory = await move.getDirectory(store.prompt.input)
       if (move.pending() && !directory) return false
       finishMoveProgress = Boolean(move.progress())
+      const location = data.location.default()
+
+      const created = await sdk.api.sessions
+        .create({
+          location: directory
+            ? { directory, workspaceID }
+            : { directory: location.directory, workspaceID: workspaceID ?? location.workspaceID },
+          agent: agent.id,
+          model: {
+            providerID: selectedModel.providerID,
+            id: selectedModel.modelID,
+            variant,
+          },
+        })
+        .catch(() => undefined)
 
-      const res = await sdk.client.v2.session.create({
-        location: directory ? { directory, workspaceID } : undefined,
-        agent: agent.id,
-        model: {
-          providerID: selectedModel.providerID,
-          id: selectedModel.modelID,
-          variant,
-        },
-      })
-
-      if (res.error) {
+      if (!created) {
         if (finishMoveProgress) move.finishSubmit()
         toast.show({
           message: "Creating a session failed. Open console for more details.",
@@ -1030,8 +1036,9 @@ export function Prompt(props: PromptProps) {
         return true
       }
 
-      sessionID = res.data.data.id
-      session = res.data.data
+      sessionID = created.id
+      // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; prompt state still uses legacy mutable session types.
+      session = structuredClone(created) as SessionV2Info
     }
 
     const inputText = expandTrackedPastedText(
@@ -1107,65 +1114,70 @@ export function Prompt(props: PromptProps) {
         session = data.session.get(sessionID)
       }
       if (session?.agent !== agent.id) {
-        await sdk.client.v2.session.switchAgent({ sessionID, agent: agent.id }, { throwOnError: true })
+        await sdk.api.sessions.switchAgent({ sessionID, agent: agent.id })
       }
       if (
         session?.model?.providerID !== selectedModel.providerID ||
         session.model.id !== selectedModel.modelID ||
         session.model.variant !== variant
       ) {
-        await sdk.client.v2.session.switchModel(
-          {
-            sessionID,
-            model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
-          },
-          { throwOnError: true },
-        )
+        await sdk.api.sessions.switchModel({
+          sessionID,
+          model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
+        })
       }
       if (session?.revert) {
-        const revertResult = await sdk.client.v2.session.revert.commit({ sessionID })
-        if (revertResult.error) {
-          toast.show({ title: "Failed to commit revert", message: errorMessage(revertResult.error), variant: "error" })
+        const error = await sdk.api.sessions.commit({ sessionID }).then(
+          () => undefined,
+          (error) => error,
+        )
+        if (error) {
+          toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" })
           return false
         }
       }
-      const result = await sdk.client.v2.session.prompt({
-        sessionID,
-        prompt: {
-          text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"),
-          files: nonTextParts.flatMap((part) =>
-            part.type === "file"
-              ? [
-                  {
-                    uri: part.url,
-                    name: part.filename,
-                    source: part.source
-                      ? {
-                          start: part.source.text.start,
-                          end: part.source.text.end,
-                          text: part.source.text.value,
-                        }
-                      : undefined,
-                  },
-                ]
-              : [],
-          ),
-          agents: nonTextParts.flatMap((part) =>
-            part.type === "agent"
-              ? [
-                  {
-                    name: part.name,
-                    source: part.source
-                      ? { start: part.source.start, end: part.source.end, text: part.source.value }
-                      : undefined,
-                  },
-                ]
-              : [],
-          ),
-        },
-      })
-      if (result.error) {
-        toast.show({ title: "Failed to send prompt", message: errorMessage(result.error), variant: "error" })
+      const error = await sdk.api.sessions
+        .prompt({
+          sessionID,
+          prompt: {
+            text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"),
+            files: nonTextParts.flatMap((part) =>
+              part.type === "file"
+                ? [
+                    {
+                      uri: part.url,
+                      name: part.filename,
+                      source: part.source
+                        ? {
+                            start: part.source.text.start,
+                            end: part.source.text.end,
+                            text: part.source.text.value,
+                          }
+                        : undefined,
+                    },
+                  ]
+                : [],
+            ),
+            agents: nonTextParts.flatMap((part) =>
+              part.type === "agent"
+                ? [
+                    {
+                      name: part.name,
+                      source: part.source
+                        ? { start: part.source.start, end: part.source.end, text: part.source.value }
+                        : undefined,
+                    },
+                  ]
+                : [],
+            ),
+          },
+        })
+        .then(
+          () => undefined,
+          (error) => error,
+        )
+      if (error) {
+        toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
         return false
       }
       if (editorParts.length > 0) editor.markSelectionSent()

+ 8 - 11
packages/tui/src/component/prompt/move.tsx

@@ -37,17 +37,14 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
         { projectID, context },
         { throwOnError: true },
       )
-      const result = await sdk.client.v2.projectCopy.create(
-        {
-          projectID,
-          location: { directory: project.instance.directory() || paths.cwd },
-          strategy: "git_worktree",
-          directory: path.join(paths.worktree, projectID.slice(0, 6)),
-          name: generated.data.name,
-        },
-        { throwOnError: true },
-      )
-      const directory = result.data?.directory
+      const result = await sdk.api.projectCopies.create({
+        projectID,
+        location: { directory: project.instance.directory() || paths.cwd },
+        strategy: "git_worktree",
+        directory: path.join(paths.worktree, projectID.slice(0, 6)),
+        name: generated.data.name,
+      })
+      const directory = result.directory
       if (!directory) throw new Error("No project copy directory returned")
 
       // Call a location-based route to make sure it's bootstrapped

+ 85 - 56
packages/tui/src/context/data.tsx

@@ -61,6 +61,14 @@ function locationQuery(ref?: LocationRef) {
   return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
 }
 
+type Mutable<T> =
+  T extends ReadonlyArray<infer U> ? Mutable<U>[] : T extends object ? { -readonly [K in keyof T]: Mutable<T[K]> } : T
+
+function mutable<T>(value: T): Mutable<T> {
+  // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client data is readonly; the TUI store mutates cloned state.
+  return structuredClone(value) as Mutable<T>
+}
+
 export const { use: useData, provider: DataProvider } = createSimpleContext({
   name: "Data",
   init: () => {
@@ -288,13 +296,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           break
         case "session.next.text.delta":
           message.update(event.data.sessionID, (draft, index) => {
-            const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID), event.data.textID)
+            const match = message.latestText(
+              message.assistant(draft, index, event.data.assistantMessageID),
+              event.data.textID,
+            )
             if (match) match.text += event.data.delta
           })
           break
         case "session.next.text.ended":
           message.update(event.data.sessionID, (draft, index) => {
-            const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID), event.data.textID)
+            const match = message.latestText(
+              message.assistant(draft, index, event.data.assistantMessageID),
+              event.data.textID,
+            )
             if (match) match.text = event.data.text
           })
           break
@@ -311,19 +325,28 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           break
         case "session.next.tool.input.delta":
           message.update(event.data.sessionID, (draft, index) => {
-            const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
+            const match = message.latestTool(
+              message.assistant(draft, index, event.data.assistantMessageID),
+              event.data.callID,
+            )
             if (match?.state.status === "pending") match.state.input += event.data.delta
           })
           break
         case "session.next.tool.input.ended":
           message.update(event.data.sessionID, (draft, index) => {
-            const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
+            const match = message.latestTool(
+              message.assistant(draft, index, event.data.assistantMessageID),
+              event.data.callID,
+            )
             if (match?.state.status === "pending") match.state.input = event.data.text
           })
           break
         case "session.next.tool.called":
           message.update(event.data.sessionID, (draft, index) => {
-            const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
+            const match = message.latestTool(
+              message.assistant(draft, index, event.data.assistantMessageID),
+              event.data.callID,
+            )
             if (!match) return
             match.time.ran = event.data.timestamp
             match.provider = event.data.provider
@@ -332,7 +355,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           break
         case "session.next.tool.progress":
           message.update(event.data.sessionID, (draft, index) => {
-            const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
+            const match = message.latestTool(
+              message.assistant(draft, index, event.data.assistantMessageID),
+              event.data.callID,
+            )
             if (match?.state.status !== "running") return
             match.state.structured = event.data.structured
             match.state.content = [...event.data.content]
@@ -340,7 +366,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           break
         case "session.next.tool.success":
           message.update(event.data.sessionID, (draft, index) => {
-            const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
+            const match = message.latestTool(
+              message.assistant(draft, index, event.data.assistantMessageID),
+              event.data.callID,
+            )
             if (match?.state.status !== "running") return
             match.state = {
               status: "completed",
@@ -359,7 +388,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           break
         case "session.next.tool.failed":
           message.update(event.data.sessionID, (draft, index) => {
-            const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
+            const match = message.latestTool(
+              message.assistant(draft, index, event.data.assistantMessageID),
+              event.data.callID,
+            )
             if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
             match.state = {
               status: "error",
@@ -522,8 +554,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           return store.session.status[sessionID] ?? "idle"
         },
         async refresh(sessionID: string) {
-          const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true })
-          setStore("session", "info", sessionID, result.data.data)
+          setStore("session", "info", sessionID, mutable(await sdk.api.sessions.get({ sessionID })))
         },
         message: {
           ids(sessionID: string) {
@@ -540,11 +571,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           async refresh(sessionID: string) {
             setStore("session", "message", sessionID, [])
             messageIndex.set(sessionID, new Map())
-            const response = await sdk.client.v2.session.messages(
-              { sessionID, limit: 200, order: "desc" },
-              { throwOnError: true },
-            )
-            const loaded = response.data.data.toReversed()
+            const loaded = mutable(
+              (await sdk.api.messages.list({ sessionID, limit: 200, order: "desc" })).data,
+            ).toReversed()
             const live = store.session.message[sessionID] ?? []
             const liveByID = new Map(live.map((message) => [message.id, message]))
             const messages = [...loaded.map((message) => liveByID.get(message.id) ?? message), ...live]
@@ -559,8 +588,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             return store.session.permission[sessionID]
           },
           async refresh(sessionID: string) {
-            const result = await sdk.client.v2.session.permission.list({ sessionID }, { throwOnError: true })
-            setStore("session", "permission", sessionID, result.data.data)
+            setStore("session", "permission", sessionID, mutable(await sdk.api.permissions.list({ sessionID })))
           },
         },
         question: {
@@ -568,8 +596,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             return store.session.question[sessionID]
           },
           async refresh(sessionID: string) {
-            const result = await sdk.client.v2.session.question.list({ sessionID }, { throwOnError: true })
-            setStore("session", "question", sessionID, result.data.data)
+            setStore("session", "question", sessionID, mutable(await sdk.api.questions.list({ sessionID })))
           },
         },
       },
@@ -579,8 +606,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             return store.project.permission[projectID]
           },
           async refresh(projectID: string) {
-            const result = await sdk.client.v2.permission.saved.list({ projectID }, { throwOnError: true })
-            setStore("project", "permission", projectID, result.data.data)
+            setStore("project", "permission", projectID, mutable(await sdk.api.permissions.listSaved({ projectID })))
           },
         },
       },
@@ -606,8 +632,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           return defaultLocation()
         },
         async refresh(ref?: LocationRef) {
-          const response = await sdk.client.v2.location.get({ location: locationQuery(ref) }, { throwOnError: true })
-          const location = response.data
+          const location = await sdk.api.location.get({ location: locationQuery(ref ?? defaultLocation()) })
           const key = locationKey(location)
           if (!store.location[key]) setStore("location", key, {})
           if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
@@ -617,9 +642,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             return store.location[locationKey(location ?? defaultLocation())]?.agent
           },
           async refresh(ref?: LocationRef) {
-            const result = await sdk.client.v2.agent.list({ location: locationQuery(ref) }, { throwOnError: true })
-            const key = locationKey(result.data.location)
-            setStore("location", key, { ...store.location[key], agent: result.data.data })
+            const result = await sdk.api.agents.list({ location: locationQuery(ref ?? defaultLocation()) })
+            const key = locationKey(result.location)
+            setStore("location", key, { ...store.location[key], agent: mutable(result.data) })
           },
         },
         command: {
@@ -627,9 +652,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             return store.location[locationKey(location ?? defaultLocation())]?.command
           },
           async refresh(ref?: LocationRef) {
-            const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true })
-            const key = locationKey(result.data.location)
-            setStore("location", key, { ...store.location[key], command: result.data.data })
+            const result = await sdk.api.commands.list({ location: locationQuery(ref ?? defaultLocation()) })
+            const key = locationKey(result.location)
+            setStore("location", key, { ...store.location[key], command: mutable(result.data) })
           },
         },
         integration: {
@@ -637,12 +662,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             return store.location[locationKey(location ?? defaultLocation())]?.integration
           },
           async refresh(ref?: LocationRef) {
-            const result = await sdk.client.v2.integration.list(
-              { location: locationQuery(ref) },
-              { throwOnError: true },
-            )
-            const key = locationKey(result.data.location)
-            setStore("location", key, { ...store.location[key], integration: result.data.data })
+            const result = await sdk.api.integrations.list({ location: locationQuery(ref ?? defaultLocation()) })
+            const key = locationKey(result.location)
+            setStore("location", key, { ...store.location[key], integration: mutable(result.data) })
           },
         },
         model: {
@@ -650,9 +672,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             return store.location[locationKey(location ?? defaultLocation())]?.model
           },
           async refresh(ref?: LocationRef) {
-            const result = await sdk.client.v2.model.list({ location: locationQuery(ref) }, { throwOnError: true })
-            const key = locationKey(result.data.location)
-            setStore("location", key, { ...store.location[key], model: result.data.data })
+            const result = await sdk.api.models.list({ location: locationQuery(ref ?? defaultLocation()) })
+            const key = locationKey(result.location)
+            setStore("location", key, { ...store.location[key], model: mutable(result.data) })
           },
         },
         provider: {
@@ -660,9 +682,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             return store.location[locationKey(location ?? defaultLocation())]?.provider
           },
           async refresh(ref?: LocationRef) {
-            const result = await sdk.client.v2.provider.list({ location: locationQuery(ref) }, { throwOnError: true })
-            const key = locationKey(result.data.location)
-            setStore("location", key, { ...store.location[key], provider: result.data.data })
+            const result = await sdk.api.providers.list({ location: locationQuery(ref ?? defaultLocation()) })
+            const key = locationKey(result.location)
+            setStore("location", key, { ...store.location[key], provider: mutable(result.data) })
           },
         },
         reference: {
@@ -670,9 +692,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             return store.location[locationKey(location ?? defaultLocation())]?.reference
           },
           async refresh(ref?: LocationRef) {
-            const result = await sdk.client.v2.reference.list({ location: locationQuery(ref) }, { throwOnError: true })
-            const key = locationKey(result.data.location)
-            setStore("location", key, { ...store.location[key], reference: result.data.data })
+            const result = await sdk.api.references.list({ location: locationQuery(ref ?? defaultLocation()) })
+            const key = locationKey(result.location)
+            setStore("location", key, { ...store.location[key], reference: mutable(result.data) })
           },
         },
         skill: {
@@ -680,9 +702,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             return store.location[locationKey(location ?? defaultLocation())]?.skill
           },
           async refresh(ref?: LocationRef) {
-            const result = await sdk.client.v2.skill.list({ location: locationQuery(ref) }, { throwOnError: true })
-            const key = locationKey(result.data.location)
-            setStore("location", key, { ...store.location[key], skill: result.data.data })
+            const result = await sdk.api.skills.list({ location: locationQuery(ref ?? defaultLocation()) })
+            const key = locationKey(result.location)
+            setStore("location", key, { ...store.location[key], skill: mutable(result.data) })
           },
         },
       },
@@ -690,24 +712,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
 
     async function bootstrap() {
       const settled = await Promise.allSettled([
-        sdk.client.v2.session
-          .list({ limit: 50, order: "desc" }, { throwOnError: true })
+        sdk.api.sessions
+          .list({
+            limit: 50,
+            order: "desc",
+            directory: defaultLocation().directory,
+            workspace: defaultLocation().workspaceID,
+          })
           .then((response) =>
             setStore(
               "session",
               "info",
               produce((draft) => {
-                for (const session of response.data.data) draft[session.id] = session
+                for (const session of response.data) draft[session.id] = mutable(session)
               }),
             ),
           ),
-        sdk.client.v2.session.active({ throwOnError: true }).then((response) =>
-          setStore(
-            "session",
-            "status",
-            Object.fromEntries(Object.keys(response.data.data).map((sessionID) => [sessionID, "running" as const])),
+        sdk.api.sessions
+          .active()
+          .then((active) =>
+            setStore(
+              "session",
+              "status",
+              Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const])),
+            ),
           ),
-        ),
         result.location.refresh(),
         result.location.agent.refresh(),
         result.location.integration.refresh(),

+ 16 - 3
packages/tui/src/context/sdk.tsx

@@ -1,3 +1,4 @@
+import type { OpenCodeClient } from "@opencode-ai/client"
 import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
 import { onCleanup, onMount } from "solid-js"
@@ -11,9 +12,14 @@ const connectTimeout = 2_000
 
 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
   name: "SDK",
-  init: (props: { client: OpencodeClient; reload?: () => Promise<OpencodeClient> }) => {
+  init: (props: {
+    client: OpencodeClient
+    api: OpenCodeClient
+    reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
+  }) => {
     const abort = new AbortController()
     let client = props.client
+    let api = props.api
     const events = createGlobalEmitter<SDKEventMap>()
     const [connection, setConnection] = createStore<{
       status: SDKConnectionStatus
@@ -40,7 +46,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
         while (!abort.signal.aborted && !controller.signal.aborted) {
           const connection = new AbortController()
           const cancel = () => connection.abort(controller.signal.reason)
-          const timeout = setTimeout(() => connection.abort(new Error("Timed out connecting to server")), connectTimeout)
+          const timeout = setTimeout(
+            () => connection.abort(new Error("Timed out connecting to server")),
+            connectTimeout,
+          )
           controller.signal.addEventListener("abort", cancel, { once: true })
           const error = await (async () => {
             const response = await current.v2.event.subscribe({
@@ -91,7 +100,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
           pending = Promise.resolve()
             .then(props.reload)
             .then(async (next) => {
-              client = next
+              client = next.client
+              api = next.api
               if (!abort.signal.aborted) await start()
             })
             .finally(() => {
@@ -112,6 +122,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
       get client() {
         return client
       },
+      get api() {
+        return api
+      },
       event: {
         on: events.on,
         listen: events.listen,

+ 4 - 8
packages/tui/src/routes/session/dialog-message.tsx

@@ -11,9 +11,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set
   const clipboard = useClipboard()
   const toast = useToast()
   const sdk = useSDK()
-  const message = createMemo(() =>
-    data.session.message.get(props.sessionID, props.messageID),
-  )
+  const message = createMemo(() => data.session.message.get(props.sessionID, props.messageID))
 
   return (
     <DialogSelect
@@ -24,11 +22,9 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set
           value: "session.revert",
           description: "undo messages and file changes",
           onSelect: async (dialog) => {
-            const result = await sdk.client.v2.session.revert.stage({
-              sessionID: props.sessionID,
-              messageID: props.messageID,
-            })
-            if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
+            await sdk.api.sessions
+              .stage({ sessionID: props.sessionID, messageID: props.messageID })
+              .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
             dialog.clear()
           },
         },

+ 119 - 109
packages/tui/src/routes/session/index.tsx

@@ -156,10 +156,11 @@ export function Session() {
   const promptRef = usePromptRef()
   const session = createMemo(() => data.session.get(route.sessionID))
   const messageIDs = createMemo(() => data.session.message.ids(route.sessionID))
-  const sessionMessages = () => messageIDs().flatMap((id) => {
-    const message = data.session.message.get(route.sessionID, id)
-    return message ? [message] : []
-  })
+  const sessionMessages = () =>
+    messageIDs().flatMap((id) => {
+      const message = data.session.message.get(route.sessionID, id)
+      return message ? [message] : []
+    })
   const location = createMemo(() => session()?.location)
 
   createEffect(() => {
@@ -286,7 +287,10 @@ export function Session() {
         if (!message) return false
 
         if (message.type === "user") return Boolean(message.text.trim())
-        return message.type === "assistant" && message.content.some((content) => content.type === "text" && content.text.trim())
+        return (
+          message.type === "assistant" &&
+          message.content.some((content) => content.type === "text" && content.text.trim())
+        )
       })
       .sort((a, b) => a.y - b.y)
 
@@ -361,7 +365,7 @@ export function Session() {
         aliases: ["summarize"],
       },
       run: () => {
-        void sdk.client.v2.session.compact({ sessionID: route.sessionID })
+        void sdk.api.sessions.compact({ sessionID: route.sessionID })
         dialog.clear()
       },
     },
@@ -395,8 +399,11 @@ export function Session() {
             dialog.clear()
             return
           }
-          const result = await sdk.client.v2.session.revert.stage({ sessionID: route.sessionID, messageID: target })
-          if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
+          const error = await sdk.api.sessions.stage({ sessionID: route.sessionID, messageID: target }).then(
+            () => undefined,
+            (error) => error,
+          )
+          if (error) toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })
           dialog.clear()
         })()
       },
@@ -409,8 +416,11 @@ export function Session() {
       slash: { name: "redo" },
       run: () => {
         void (async () => {
-          const result = await sdk.client.v2.session.revert.clear({ sessionID: route.sessionID })
-          if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
+          const error = await sdk.api.sessions.clear({ sessionID: route.sessionID }).then(
+            () => undefined,
+            (error) => error,
+          )
+          if (error) toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })
           dialog.clear()
         })()
       },
@@ -878,23 +888,21 @@ export function Session() {
                 </For>
                 <Show when={session()?.revert?.messageID}>
                   <RevertMessage
-                    count={messages().filter((message) => message.id >= session()!.revert!.messageID && message.type === "user").length}
+                    count={
+                      messages().filter(
+                        (message) => message.id >= session()!.revert!.messageID && message.type === "user",
+                      ).length
+                    }
                     files={session()!.revert!.files ?? []}
                   />
                 </Show>
               </scrollbox>
               <box flexShrink={0}>
                 <Show when={permissions().length > 0}>
-                  <PermissionPrompt
-                    request={permissions()[0]}
-                    directory={session()?.location.directory}
-                  />
+                  <PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
                 </Show>
                 <Show when={permissions().length === 0 && questions().length > 0}>
-                  <QuestionPrompt
-                    request={questions()[0]}
-                    directory={session()?.location.directory}
-                  />
+                  <QuestionPrompt request={questions()[0]} directory={session()?.location.directory} />
                 </Show>
                 <Show when={session()?.parentID}>
                   <SubagentFooter />
@@ -957,9 +965,7 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) =
       <Switch>
         <Match when={props.row.type === "message" ? props.row : undefined}>
           {(row) => (
-            <Show when={props.message(row().messageID)}>
-              {(message) => <SessionMessageView message={message()} />}
-            </Show>
+            <Show when={props.message(row().messageID)}>{(message) => <SessionMessageView message={message()} />}</Show>
           )}
         </Match>
         <Match when={props.row.type === "part" ? props.row : undefined}>
@@ -1073,8 +1079,8 @@ function SessionGroupView(props: {
       result[name] = (result[name] ?? 0) + 1
       return result
     }, {})
-    const tools = Object.entries(counts).map(([name, count]) =>
-      `${count} ${count === 1 ? name : name === "search" ? "searches" : `${name}s`}`,
+    const tools = Object.entries(counts).map(
+      ([name, count]) => `${count} ${count === 1 ? name : name === "search" ? "searches" : `${name}s`}`,
     )
     return `${props.completed ? "Explored" : "Exploring"} — ${tools.join(", ")}`
   })
@@ -1116,9 +1122,10 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
   const { theme } = useTheme()
   const model = createMemo(
     () =>
-      ctx.models().find(
-        (model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id,
-      )?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
+      ctx
+        .models()
+        .find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
+        ?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
   )
   const duration = createMemo(() =>
     props.message.time.completed ? props.message.time.completed - props.message.time.created : 0,
@@ -1158,14 +1165,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessage }) {
 
 function CompactionMessage() {
   const { theme } = useTheme()
-  return (
-    <box
-      border={["top"]}
-      title=" Compaction "
-      titleAlignment="center"
-      borderColor={theme.borderActive}
-    />
-  )
+  return <box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive} />
 }
 
 function statusLabel(status: "added" | "modified" | "deleted") {
@@ -1197,8 +1197,11 @@ function RevertMessage(props: {
       onMouseUp={() => {
         if (renderer.getSelection()?.getSelectedText()) return
         void (async () => {
-          const result = await sdk.client.v2.session.revert.clear({ sessionID: route.sessionID })
-          if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
+          const error = await sdk.api.sessions.clear({ sessionID: route.sessionID }).then(
+            () => undefined,
+            (error) => error,
+          )
+          if (error) toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })
         })()
       }}
       flexShrink={0}
@@ -1207,7 +1210,12 @@ function RevertMessage(props: {
       customBorderChars={SplitBorder.customBorderChars}
       borderColor={theme.backgroundPanel}
     >
-      <box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}>
+      <box
+        paddingTop={1}
+        paddingBottom={1}
+        paddingLeft={2}
+        backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
+      >
         <text fg={theme.textMuted}>
           {props.count} message{props.count === 1 ? "" : "s"} reverted
         </text>
@@ -1251,54 +1259,63 @@ function UserMessage(props: { message: SessionMessageUser }) {
 
   return (
     <Show when={props.message.text.trim() || files().length}>
+      <box
+        id={props.message.id}
+        border={["left"]}
+        borderColor={color()}
+        customBorderChars={SplitBorder.customBorderChars}
+      >
         <box
-          id={props.message.id}
-          border={["left"]}
-          borderColor={color()}
-          customBorderChars={SplitBorder.customBorderChars}
+          onMouseOver={() => {
+            setHover(true)
+          }}
+          onMouseOut={() => {
+            setHover(false)
+          }}
+          onMouseUp={() => {
+            if (renderer.getSelection()?.getSelectedText()) return
+            dialog.replace(() => <DialogMessage messageID={props.message.id} sessionID={ctx.sessionID} />)
+          }}
+          paddingTop={1}
+          paddingBottom={1}
+          paddingLeft={2}
+          backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
+          flexShrink={0}
         >
-          <box
-            onMouseOver={() => {
-              setHover(true)
-            }}
-            onMouseOut={() => {
-              setHover(false)
-            }}
-            onMouseUp={() => {
-              if (renderer.getSelection()?.getSelectedText()) return
-              dialog.replace(() => <DialogMessage messageID={props.message.id} sessionID={ctx.sessionID} />)
-            }}
-            paddingTop={1}
-            paddingBottom={1}
-            paddingLeft={2}
-            backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
-            flexShrink={0}
-          >
-            <text fg={theme.text}>{props.message.text}</text>
-            <Show when={files().length}>
-              <box flexDirection="row" paddingBottom={ctx.showTimestamps() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
-                <For each={files()}>
-                  {(file) => {
-                    const directory = file.mime === "application/x-directory"
-                    return (
-                      <text fg={theme.text}>
-                        <span style={{ bg: theme.secondary, fg: theme.background }}>
-                          {directory ? " Directory " : " File "}
-                        </span>
-                        <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
-                      </text>
-                    )
-                  }}
-                </For>
-              </box>
-            </Show>
-            <Show when={ctx.showTimestamps()}>
-              <text fg={theme.textMuted}>
-                <span style={{ fg: theme.textMuted }}>{Locale.todayTimeOrDateTime(props.message.time.created)}</span>
-              </text>
-            </Show>
-          </box>
+          <text fg={theme.text}>{props.message.text}</text>
+          <Show when={files().length}>
+            <box
+              flexDirection="row"
+              paddingBottom={ctx.showTimestamps() ? 1 : 0}
+              paddingTop={1}
+              gap={1}
+              flexWrap="wrap"
+            >
+              <For each={files()}>
+                {(file) => {
+                  const directory = file.mime === "application/x-directory"
+                  return (
+                    <text fg={theme.text}>
+                      <span style={{ bg: theme.secondary, fg: theme.background }}>
+                        {directory ? " Directory " : " File "}
+                      </span>
+                      <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}>
+                        {" "}
+                        {file.name ?? file.uri}{" "}
+                      </span>
+                    </text>
+                  )
+                }}
+              </For>
+            </box>
+          </Show>
+          <Show when={ctx.showTimestamps()}>
+            <text fg={theme.textMuted}>
+              <span style={{ fg: theme.textMuted }}>{Locale.todayTimeOrDateTime(props.message.time.created)}</span>
+            </text>
+          </Show>
         </box>
+      </box>
     </Show>
   )
 }
@@ -1309,9 +1326,10 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
   const { theme } = useTheme()
   const model = createMemo(
     () =>
-      ctx.models().find(
-        (model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id,
-      )?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
+      ctx
+        .models()
+        .find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
+        ?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
   )
 
   const final = createMemo(() => {
@@ -1325,10 +1343,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
   })
 
   const exploration = createMemo(() => {
-    const grouped = new Map<
-      string,
-      { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean }
-    >()
+    const grouped = new Map<string, { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean }>()
     if (!ctx.groupExploration()) return grouped
     const runs = props.message.content
       .map((part) =>
@@ -1471,7 +1486,9 @@ function ReasoningPart(props: {
     // OpenRouter encrypts some reasoning blocks; drop the placeholder.
     return props.part.text.replace("[REDACTED]", "").trim()
   })
-  const isDone = createMemo(() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined)
+  const isDone = createMemo(
+    () => props.part.time?.completed !== undefined || props.message.time.completed !== undefined,
+  )
   const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
   const duration = createMemo(() => {
     const end = props.part.time?.completed ?? props.message.time.completed
@@ -1488,11 +1505,7 @@ function ReasoningPart(props: {
 
   return (
     <Show when={content()}>
-      <box
-        paddingLeft={3}
-        flexDirection="column"
-        flexShrink={0}
-      >
+      <box paddingLeft={3} flexDirection="column" flexShrink={0}>
         <box onMouseUp={toggle}>
           <ReasoningHeader
             toggleable={inMinimal()}
@@ -1808,12 +1821,7 @@ export function InlineToolRow(props: {
   onMouseUp?: () => void
 }) {
   return (
-    <box
-      paddingLeft={3}
-      onMouseOver={props.onMouseOver}
-      onMouseOut={props.onMouseOut}
-      onMouseUp={props.onMouseUp}
-    >
+    <box paddingLeft={3} onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp}>
       <Switch>
         <Match when={props.spinner}>
           <Spinner color={props.color} children={props.children} />
@@ -2003,12 +2011,7 @@ function Write(props: ToolProps) {
         </BlockTool>
       </Match>
       <Match when={true}>
-        <InlineTool
-          icon="←"
-          pending="Preparing write..."
-          complete={stringValue(props.input.path)}
-          part={props.part}
-        >
+        <InlineTool icon="←" pending="Preparing write..." complete={stringValue(props.input.path)} part={props.part}>
           Write {pathFormatter.format(stringValue(props.input.path))}
         </InlineTool>
       </Match>
@@ -2473,7 +2476,14 @@ export function parseApplyPatchFiles(value: unknown) {
     const patch = stringValue(file.patch)
     const additions = numberValue(file.additions)
     const deletions = numberValue(file.deletions)
-    if (!type || !relativePath || !filePath || patch === undefined || additions === undefined || deletions === undefined)
+    if (
+      !type ||
+      !relativePath ||
+      !filePath ||
+      patch === undefined ||
+      additions === undefined ||
+      deletions === undefined
+    )
       return []
     return [{ type, relativePath, filePath, patch, additions, deletions, movePath: stringValue(file.movePath) }]
   })

+ 4 - 4
packages/tui/src/routes/session/permission.tsx

@@ -186,7 +186,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
           onSelect={(option) => {
             setStore("stage", "permission")
             if (option === "cancel") return
-            void sdk.client.v2.session.permission.reply({
+            void sdk.api.permissions.reply({
               sessionID: props.request.sessionID,
               reply: "always",
               requestID: props.request.id,
@@ -197,7 +197,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
       <Match when={store.stage === "reject"}>
         <RejectPrompt
           onConfirm={(message) => {
-            void sdk.client.v2.session.permission.reply({
+            void sdk.api.permissions.reply({
               sessionID: props.request.sessionID,
               reply: "reject",
               requestID: props.request.id,
@@ -443,14 +443,14 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
                     setStore("stage", "reject")
                     return
                   }
-                  void sdk.client.v2.session.permission.reply({
+                  void sdk.api.permissions.reply({
                     sessionID: props.request.sessionID,
                     reply: "reject",
                     requestID: props.request.id,
                   })
                   return
                 }
-                void sdk.client.v2.session.permission.reply({
+                void sdk.api.permissions.reply({
                   sessionID: props.request.sessionID,
                   reply: "once",
                   requestID: props.request.id,

+ 5 - 5
packages/tui/src/routes/session/question.tsx

@@ -47,15 +47,15 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?:
 
   function submit() {
     const answers = questions().map((_, i) => store.answers[i] ?? [])
-    void sdk.client.v2.session.question.reply({
+    void sdk.api.questions.reply({
       sessionID: props.request.sessionID,
       requestID: props.request.id,
-      questionV2Reply: { answers },
+      answers,
     })
   }
 
   function reject() {
-    void sdk.client.v2.session.question.reject({
+    void sdk.api.questions.reject({
       sessionID: props.request.sessionID,
       requestID: props.request.id,
     })
@@ -71,10 +71,10 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?:
       setStore("custom", inputs)
     }
     if (single()) {
-      void sdk.client.v2.session.question.reply({
+      void sdk.api.questions.reply({
         sessionID: props.request.sessionID,
         requestID: props.request.id,
-        questionV2Reply: { answers: [[answer]] },
+        answers: [[answer]],
       })
       return
     }

+ 20 - 21
packages/tui/test/app-lifecycle.test.tsx

@@ -4,7 +4,7 @@ import { createTestRenderer } from "@opentui/core/testing"
 import { Effect } from "effect"
 import { Global } from "@opencode-ai/core/global"
 import { createTuiResolvedConfig } from "./fixture/tui-runtime"
-import { createClient, createEventStream, createFetch, directory, json } from "./fixture/tui-sdk"
+import { createApi, createClient, createEventStream, createFetch, directory, json } from "./fixture/tui-sdk"
 
 test("SIGHUP clears title and disposes scoped resources once", async () => {
   const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@@ -30,6 +30,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
     const task = Effect.runPromise(
       run({
         client: createClient(calls.fetch),
+        api: createApi(calls.fetch),
         config: createTuiResolvedConfig({ plugin_enabled: {} }),
         args: {},
         pluginHost: {
@@ -61,26 +62,23 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
   const core = await import("@opentui/core")
   mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
   const events = createEventStream()
-  const calls = createFetch(
-    (url) => {
-      if (url.pathname === "/api/session")
-        return json({
-          data: [
-            {
-              id: "dummy",
-              title: "Demo session",
-              projectID: "project",
-              location: { directory },
-              cost: 0,
-              tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
-              time: { created: 0, updated: 0 },
-            },
-          ],
-          cursor: {},
-        })
-    },
-    events,
-  )
+  const calls = createFetch((url) => {
+    if (url.pathname === "/api/session")
+      return json({
+        data: [
+          {
+            id: "dummy",
+            title: "Demo session",
+            projectID: "project",
+            location: { directory },
+            cost: 0,
+            tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+            time: { created: 0, updated: 0 },
+          },
+        ],
+        cursor: {},
+      })
+  }, events)
   const originalWrite = process.stdout.write.bind(process.stdout)
   let stdout = ""
   let api: TuiPluginApi | undefined
@@ -99,6 +97,7 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
     const task = Effect.runPromise(
       run({
         client: createClient(calls.fetch),
+        api: createApi(calls.fetch),
         config: createTuiResolvedConfig({ plugin_enabled: {} }),
         args: { continue: true },
         pluginHost: {

+ 2 - 2
packages/tui/test/cli/cmd/tui/sync-fixture.tsx

@@ -7,7 +7,7 @@ import { ProjectProvider, useProject } from "../../../../src/context/project"
 import { SDKProvider } from "../../../../src/context/sdk"
 import { SyncProvider, useSync } from "../../../../src/context/sync"
 import { ExitProvider } from "../../../../src/context/exit"
-import { createClient, createEventStream, createFetch, type FetchHandler } from "../../../fixture/tui-sdk"
+import { createApi, createClient, createEventStream, createFetch, type FetchHandler } from "../../../fixture/tui-sdk"
 import { TestTuiContexts } from "../../../fixture/tui-environment"
 export { createEventStream, createFetch, directory, json, worktree } from "../../../fixture/tui-sdk"
 
@@ -47,7 +47,7 @@ export async function mount(override?: FetchHandler, state?: string) {
     <TestTuiContexts paths={state ? { state } : undefined}>
       <ArgsProvider>
         <KVProvider>
-          <SDKProvider client={createClient(calls.fetch)}>
+          <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
             <ProjectProvider>
               <ExitProvider exit={() => {}}>
                 <SyncProvider>

+ 13 - 14
packages/tui/test/cli/tui/data.test.tsx

@@ -6,7 +6,7 @@ import { onMount } from "solid-js"
 import { ProjectProvider } from "../../../src/context/project"
 import { SDKProvider } from "../../../src/context/sdk"
 import { DataProvider, useData } from "../../../src/context/data"
-import { createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
+import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
 import { TestTuiContexts } from "../../fixture/tui-environment"
 
 async function wait(fn: () => boolean, timeout = 2000) {
@@ -69,7 +69,7 @@ test("refreshes resources into reactive getters", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider client={createClient(calls.fetch)}>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -139,7 +139,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider client={createClient(calls.fetch)}>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -172,8 +172,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
 test("tracks session status from active sessions and execution events", async () => {
   const events = createEventStream()
   const calls = createFetch((url) => {
-    if (url.pathname === "/api/session/active")
-      return json({ data: { "session-active": { type: "running" } } })
+    if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } })
   }, events)
   let data!: ReturnType<typeof useData>
 
@@ -184,7 +183,7 @@ test("tracks session status from active sessions and execution events", async ()
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider client={createClient(calls.fetch)}>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -296,7 +295,7 @@ test("refreshes integrations after integration updates", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider client={createClient(calls.fetch)}>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -337,7 +336,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider client={createClient(calls.fetch)}>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <box />
@@ -382,7 +381,7 @@ test("refreshes references after updates", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider client={createClient(calls.fetch)}>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -415,7 +414,7 @@ test("adds and dismisses permission requests from live events", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider client={createClient(calls.fetch)}>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -480,7 +479,7 @@ test("adds and dismisses question requests from live events", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider client={createClient(calls.fetch)}>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -548,7 +547,7 @@ test("settles pending tools when a live failure arrives", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider client={createClient(calls.fetch)}>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -677,7 +676,7 @@ test("renders admitted prompts only after they become model-visible", async () =
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider client={createClient(calls.fetch)}>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />
@@ -750,7 +749,7 @@ test("projects live context updates with their message ID", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider client={createClient(calls.fetch)}>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
         <ProjectProvider>
           <DataProvider>
             <Probe />

+ 14 - 9
packages/tui/test/cli/tui/use-event.test.tsx

@@ -1,12 +1,13 @@
 /** @jsxImportSource @opentui/solid */
 import { describe, expect, test } from "bun:test"
+import type { OpenCodeClient } from "@opencode-ai/client"
 import { testRender } from "@opentui/solid"
 import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2"
 import { onMount } from "solid-js"
 import { ProjectProvider, useProject } from "../../../src/context/project"
 import { SDKProvider, useSDK } from "../../../src/context/sdk"
 import { useEvent } from "../../../src/context/event"
-import { createClient, createEventStream, createFetch, directory } from "../../fixture/tui-sdk"
+import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk"
 import { TestTuiContexts } from "../../fixture/tui-environment"
 
 const projectID = "proj_test"
@@ -46,7 +47,7 @@ function update(version: string): V2Event {
   }
 }
 
-async function mount(reload?: () => Promise<OpencodeClient>) {
+async function mount(reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>) {
   const events = createEventStream()
   const calls = createFetch(undefined, events)
   const seen: V2Event[] = []
@@ -60,7 +61,7 @@ async function mount(reload?: () => Promise<OpencodeClient>) {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider client={createClient(calls.fetch)} reload={reload}>
+      <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)} reload={reload}>
         <ProjectProvider>
           <Probe
             onReady={async (ctx) => {
@@ -150,7 +151,8 @@ describe("useEvent", () => {
   test("reloads the host and reconnects the event stream", async () => {
     let calls = 0
     const events = createEventStream()
-    const replacement = createClient(createFetch(undefined, events).fetch)
+    const replacementCalls = createFetch(undefined, events)
+    const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) }
     const { app, sdk, seen } = await mount(async () => {
       calls += 1
       return replacement
@@ -164,19 +166,21 @@ describe("useEvent", () => {
       await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "reloaded"))
 
       expect(calls).toBe(1)
-      expect(sdk.client).toBe(replacement)
+      expect(sdk.client).toBe(replacement.client)
+      expect(sdk.api).toBe(replacement.api)
     } finally {
       app.renderer.destroy()
     }
   })
 
   test("keeps the current event stream alive while the host reload is pending", async () => {
-    let complete!: (client: OpencodeClient) => void
-    const pending = new Promise<OpencodeClient>((resolve) => {
+    let complete!: (client: { client: OpencodeClient; api: OpenCodeClient }) => void
+    const pending = new Promise<{ client: OpencodeClient; api: OpenCodeClient }>((resolve) => {
       complete = resolve
     })
     const replacementEvents = createEventStream()
-    const replacement = createClient(createFetch(undefined, replacementEvents).fetch)
+    const replacementCalls = createFetch(undefined, replacementEvents)
+    const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) }
     const { app, emit, sdk, seen } = await mount(() => pending)
 
     try {
@@ -188,7 +192,8 @@ describe("useEvent", () => {
       expect(sdk.connection.status()).toBe("connected")
       complete(replacement)
       await reload
-      expect(sdk.client).toBe(replacement)
+      expect(sdk.client).toBe(replacement.client)
+      expect(sdk.api).toBe(replacement.api)
     } finally {
       app.renderer.destroy()
     }

+ 5 - 0
packages/tui/test/fixture/tui-sdk.ts

@@ -1,3 +1,4 @@
+import { OpenCode } from "@opencode-ai/client"
 import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 import type { V2Event } from "@opencode-ai/sdk/v2"
 
@@ -117,3 +118,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
 export function createClient(fetch: typeof globalThis.fetch) {
   return createOpencodeClient({ baseUrl: "http://test", fetch })
 }
+
+export function createApi(fetch: typeof globalThis.fetch) {
+  return OpenCode.make({ baseUrl: "http://test", fetch })
+}